403 lines
12 KiB
Go
403 lines
12 KiB
Go
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)), ".")
|
||
}
|