Files
ttstd-infinite-canvas/backend/internal/outbound/outbound.go

403 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package outbound
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"strings"
"time"
"golang.org/x/net/http/httpproxy"
)
const (
maxOutboundRedirects = 5
maxOutboundHeaderCount = 32
maxOutboundHeaderBytes = 16 << 10
CustomRelayHeadersHeader = "X-Canvas-Upstream-Headers"
DefaultOutboundUserAgent = "InfiniteCanvas/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)"
)
type OutboundHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
var (
outboundTransport = newOutboundTransport(resolveOutboundHost)
customRelayTransport = newOutboundTransport(resolveCustomRelayHost)
blockedCustomRelayPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("100::/64"),
netip.MustParsePrefix("2001:db8::/32"),
}
)
func ValidateOutboundURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Hostname() == "" {
return nil, BadAuthRequest("外部服务地址无效")
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return nil, BadAuthRequest("外部服务地址只支持 http/https")
}
if parsed.User != nil {
return nil, BadAuthRequest("外部服务地址不允许包含认证信息")
}
if err := ValidateOutboundHost(parsed.Hostname()); err != nil {
return nil, err
}
return parsed, nil
}
// 用户自定义渠道必须使用更严格的出口策略:不接受 URL 凭据,且仅部署者精确配置的
// 主机可以路由到私网或使用会明文传输 API Key 的 HTTP。
func ValidateCustomRelayURL(rawURL string) (*url.URL, error) {
if len(strings.TrimSpace(rawURL)) > 4096 {
return nil, BadAuthRequest("自定义渠道地址过长")
}
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Hostname() == "" || !parsed.IsAbs() {
return nil, BadAuthRequest("自定义渠道地址无效")
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && scheme != "http" {
return nil, BadAuthRequest("自定义渠道地址只支持 http/https")
}
if scheme == "http" && !AllowedPrivateUpstreamHost(parsed.Hostname()) {
return nil, BadAuthRequest("自定义渠道 HTTP 仅允许访问已配置的可信上游主机")
}
if parsed.User != nil {
return nil, BadAuthRequest("自定义渠道地址不允许包含认证信息")
}
if parsed.Fragment != "" {
return nil, BadAuthRequest("自定义渠道地址不允许包含片段")
}
if err := validateCustomRelayHost(parsed.Hostname()); err != nil {
return nil, err
}
return parsed, nil
}
func OutboundHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: outboundTransport,
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= maxOutboundRedirects {
return errors.New("外部服务重定向次数过多")
}
_, err := ValidateOutboundURL(req.URL.String())
return err
},
}
}
func CustomRelayHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: customRelayTransport,
Timeout: timeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return errors.New("自定义渠道中转不允许重定向")
},
}
}
// ApplyDefaultOutboundHeaders 为所有服务端出网请求提供可识别的产品标识,
// 避免 Go 默认 User-Agent 被部分 WAF 或中转网关拦截。
func ApplyDefaultOutboundHeaders(req *http.Request) {
if req != nil && strings.TrimSpace(req.Header.Get("User-Agent")) == "" {
req.Header.Set("User-Agent", DefaultOutboundUserAgent)
}
}
// NormalizeOutboundHeaders 是自定义 Header 的安全边界:允许业务/WAF 校验头和 User-Agent,
// 但禁止覆盖鉴权、请求体协议、代理内部头及 hop-by-hop 头。
func NormalizeOutboundHeaders(headers []OutboundHeader) ([]OutboundHeader, error) {
if len(headers) > maxOutboundHeaderCount {
return nil, BadAuthRequest(fmt.Sprintf("自定义请求头最多支持 %d 项", maxOutboundHeaderCount))
}
seen := make(map[string]struct{}, len(headers))
normalized := make([]OutboundHeader, 0, len(headers))
totalBytes := 0
for _, item := range headers {
name := strings.TrimSpace(item.Name)
value := strings.TrimSpace(item.Value)
if name == "" && value == "" {
continue
}
if name == "" || !validOutboundHeaderName(name) {
return nil, BadAuthRequest("自定义请求头名称无效,请使用标准 HTTP Header 名称")
}
name = http.CanonicalHeaderKey(name)
lowerName := strings.ToLower(name)
if blockedOutboundHeader(lowerName) {
return nil, BadAuthRequest("请求头 " + name + " 由系统管理,不允许自定义")
}
if _, exists := seen[lowerName]; exists {
return nil, BadAuthRequest("自定义请求头不能重复:" + name)
}
if !validOutboundHeaderValue(value) {
return nil, BadAuthRequest("请求头 " + name + " 的值包含非法控制字符")
}
if len(name) > 128 || len(value) > 4096 {
return nil, BadAuthRequest("单个自定义请求头名称或值过长")
}
totalBytes += len(name) + len(value)
if totalBytes > maxOutboundHeaderBytes {
return nil, BadAuthRequest("自定义请求头总大小不能超过 16KB")
}
seen[lowerName] = struct{}{}
normalized = append(normalized, OutboundHeader{Name: name, Value: value})
}
return normalized, nil
}
func ApplyOutboundHeaders(req *http.Request, headers []OutboundHeader) {
if req == nil {
return
}
for _, item := range headers {
req.Header.Set(item.Name, item.Value)
}
}
func EncodeOutboundHeadersJSON(headers []OutboundHeader) (string, error) {
normalized, err := NormalizeOutboundHeaders(headers)
if err != nil {
return "", err
}
data, err := json.Marshal(normalized)
return string(data), err
}
func ParseOutboundHeadersJSON(raw string) ([]OutboundHeader, error) {
if strings.TrimSpace(raw) == "" {
return []OutboundHeader{}, nil
}
var headers []OutboundHeader
if err := json.Unmarshal([]byte(raw), &headers); err != nil {
return nil, errors.New("渠道自定义请求头配置损坏")
}
return NormalizeOutboundHeaders(headers)
}
func DecodeRelayOutboundHeaders(encoded string) ([]OutboundHeader, error) {
if strings.TrimSpace(encoded) == "" {
return []OutboundHeader{}, nil
}
if len(encoded) > (maxOutboundHeaderBytes*2)+1024 {
return nil, BadAuthRequest("自定义请求头配置过大")
}
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, BadAuthRequest("自定义请求头编码无效")
}
var headers []OutboundHeader
if err := json.Unmarshal(data, &headers); err != nil {
return nil, BadAuthRequest("自定义请求头格式无效")
}
return NormalizeOutboundHeaders(headers)
}
func validOutboundHeaderName(name string) bool {
if name == "" {
return false
}
for index := 0; index < len(name); index++ {
char := name[index]
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(char)) {
continue
}
return false
}
return true
}
func validOutboundHeaderValue(value string) bool {
for index := 0; index < len(value); index++ {
char := value[index]
if char == '\t' || char >= 32 && char != 127 {
continue
}
return false
}
return true
}
func blockedOutboundHeader(name string) bool {
switch name {
case "authorization", "proxy-authorization", "cookie", "set-cookie", "host", "content-length", "content-type", "accept", "connection", "proxy-connection", "keep-alive", "transfer-encoding", "te", "trailer", "upgrade", "forwarded", "x-goog-api-key":
return true
}
return strings.HasPrefix(name, "x-canvas-") || strings.HasPrefix(name, "x-forwarded-")
}
func newOutboundTransport(resolveHost func(context.Context, string) ([]net.IP, error)) *http.Transport {
dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
return &http.Transport{
Proxy: outboundProxyFromEnvironment,
DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
// 代理地址由部署者通过环境变量配置;目标 URL 仍在请求前经过 SSRF 校验。
if configuredProxyHost(host) {
return dialer.DialContext(ctx, network, address)
}
addresses, err := resolveHost(ctx, host)
if err != nil {
return nil, err
}
return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].String(), port))
},
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 15 * time.Second,
ExpectContinueTimeout: time.Second,
}
}
func outboundProxyFromEnvironment(req *http.Request) (*url.URL, error) {
if req == nil || req.URL == nil {
return nil, nil
}
return httpproxy.FromEnvironment().ProxyFunc()(req.URL)
}
func configuredProxyHost(host string) bool {
host = normalizeOutboundHost(host)
for _, name := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"} {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
continue
}
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
parsed, err := url.Parse(raw)
if err == nil && normalizeOutboundHost(parsed.Hostname()) == host {
return true
}
}
return false
}
func ValidateOutboundHost(host string) error {
_, err := resolveOutboundHost(context.Background(), host)
return err
}
func validateCustomRelayHost(host string) error {
_, err := resolveCustomRelayHost(context.Background(), host)
return err
}
func resolveOutboundHost(ctx context.Context, host string) ([]net.IP, error) {
host = normalizeOutboundHost(host)
return resolveOutboundHostWithPolicy(ctx, host, allowPrivateUpstreams() || AllowedPrivateUpstreamHost(host))
}
func resolveCustomRelayHost(ctx context.Context, host string) ([]net.IP, error) {
host = normalizeOutboundHost(host)
allowPrivateHost := AllowedPrivateUpstreamHost(host)
addresses, err := resolveOutboundHostWithPolicy(ctx, host, allowPrivateHost)
if err != nil {
return nil, err
}
if !allowPrivateHost {
for _, ip := range addresses {
if blockedCustomRelayIP(ip) {
return nil, BadAuthRequest("不允许访问保留地址或特殊用途地址")
}
}
}
return addresses, nil
}
func resolveOutboundHostWithPolicy(ctx context.Context, host string, allowPrivateHost bool) ([]net.IP, error) {
if host == "" {
return nil, BadAuthRequest("外部服务域名无效")
}
if !allowPrivateHost && (host == "localhost" || strings.HasSuffix(host, ".localhost")) {
return nil, BadAuthRequest("不允许访问本机或内网地址")
}
addresses, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, BadAuthRequest("外部服务域名解析失败")
}
if len(addresses) == 0 {
return nil, BadAuthRequest("外部服务域名没有可用地址")
}
if !allowPrivateHost {
for _, ip := range addresses {
if blockedOutboundIP(ip) {
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
}
}
}
return addresses, nil
}
func blockedOutboundIP(ip net.IP) bool {
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast()
}
func blockedCustomRelayIP(ip net.IP) bool {
if blockedOutboundIP(ip) {
return true
}
address, ok := netip.AddrFromSlice(ip)
if !ok {
return true
}
address = address.Unmap()
for _, prefix := range blockedCustomRelayPrefixes {
if prefix.Contains(address) {
return true
}
}
return false
}
func allowPrivateUpstreams() bool {
value := strings.ToLower(strings.TrimSpace(os.Getenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS")))
return value == "1" || value == "true" || value == "yes"
}
// allowedPrivateUpstreamHost lets operators pin only explicitly trusted upstream
// hostnames to an internal route without disabling SSRF protection for every URL.
func AllowedPrivateUpstreamHost(host string) bool {
host = normalizeOutboundHost(host)
if host == "" {
return false
}
for _, configured := range strings.Split(os.Getenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS"), ",") {
if normalizeOutboundHost(configured) == host {
return true
}
}
return false
}
func normalizeOutboundHost(host string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
}