diff --git a/.env.example b/.env.example index d9fa328f4..2b7587387 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # 本文件只声明可公开的运行配置,不要在这里填写任何密钥。 CANVAS_REGISTRATION_ENABLED=false CANVAS_ALLOW_PRIVATE_UPSTREAMS=false +CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS= CANVAS_CORS_ORIGINS=http://localhost:3000 CANVAS_DATA_PATH=./data diff --git a/backend/internal/service/outbound.go b/backend/internal/service/outbound.go index 3684b17a0..19410b1fe 100644 --- a/backend/internal/service/outbound.go +++ b/backend/internal/service/outbound.go @@ -75,11 +75,12 @@ func validateOutboundHost(host string) error { } func resolveOutboundHost(ctx context.Context, host string) ([]net.IP, error) { - host = strings.TrimSpace(strings.ToLower(host)) + host = normalizeOutboundHost(host) if host == "" { return nil, BadAuthRequest("外部服务域名无效") } - if !allowPrivateUpstreams() && (host == "localhost" || strings.HasSuffix(host, ".localhost")) { + allowPrivateHost := allowPrivateUpstreams() || allowedPrivateUpstreamHost(host) + if !allowPrivateHost && (host == "localhost" || strings.HasSuffix(host, ".localhost")) { return nil, BadAuthRequest("不允许访问本机或内网地址") } addresses, err := net.DefaultResolver.LookupIP(ctx, "ip", host) @@ -89,7 +90,7 @@ func resolveOutboundHost(ctx context.Context, host string) ([]net.IP, error) { if len(addresses) == 0 { return nil, BadAuthRequest("外部服务域名没有可用地址") } - if !allowPrivateUpstreams() { + if !allowPrivateHost { for _, ip := range addresses { if blockedOutboundIP(ip) { return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址") @@ -107,3 +108,22 @@ 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)), ".") +} diff --git a/backend/internal/service/outbound_test.go b/backend/internal/service/outbound_test.go index 276dac170..5e86e3953 100644 --- a/backend/internal/service/outbound_test.go +++ b/backend/internal/service/outbound_test.go @@ -4,6 +4,7 @@ import "testing" func TestValidateOutboundURLRejectsPrivateHosts(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false") + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "") for _, rawURL := range []string{"http://127.0.0.1:8080", "http://localhost:8080", "http://169.254.169.254/latest/meta-data"} { if _, err := ValidateOutboundURL(rawURL); err == nil { t.Fatalf("ValidateOutboundURL(%q) should fail", rawURL) @@ -13,7 +14,29 @@ func TestValidateOutboundURLRejectsPrivateHosts(t *testing.T) { func TestValidateOutboundURLAllowsExplicitPrivateUpstreamOverride(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "") if _, err := ValidateOutboundURL("http://127.0.0.1:8080"); err != nil { t.Fatalf("ValidateOutboundURL() error = %v", err) } } + +func TestValidateOutboundURLAllowsOnlyNamedPrivateUpstream(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false") + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + if _, err := ValidateOutboundURL("http://127.0.0.1:8080"); err != nil { + t.Fatalf("ValidateOutboundURL() error = %v", err) + } + if _, err := ValidateOutboundURL("http://127.0.0.2:8080"); err == nil { + t.Fatal("ValidateOutboundURL() should reject an unlisted private host") + } +} + +func TestAllowedPrivateUpstreamHostUsesExactCaseInsensitiveMatch(t *testing.T) { + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", " API.EXAMPLE.COM.,trusted.internal ") + if !allowedPrivateUpstreamHost("api.example.com") { + t.Fatal("allowedPrivateUpstreamHost() should allow exact normalized hostname") + } + if allowedPrivateUpstreamHost("api.example.com.evil.test") { + t.Fatal("allowedPrivateUpstreamHost() should reject hostname suffix confusion") + } +} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 3c7ef96bb..97d40c610 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -41,6 +41,7 @@ services: REDIS_URL: redis://redis:6379/0 CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false} CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false} + CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-} CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-} GIN_MODE: release volumes: diff --git a/docker-compose.local.yml b/docker-compose.local.yml index b0ecdb6a9..e72b30a84 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -14,6 +14,7 @@ services: REDIS_URL: ${REDIS_URL:-} CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false} CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false} + CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-} CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-http://localhost:3000} GIN_MODE: release volumes: diff --git a/docker-compose.server.yml b/docker-compose.server.yml index e488a29b2..4ed4e2a5f 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -44,6 +44,7 @@ services: REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false} CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false} + CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-} CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-*} GIN_MODE: release volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 1fcd1cdf5..9ebd1af5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: REDIS_URL: ${REDIS_URL:-} CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false} CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false} + CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-} CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-http://localhost:3000} GIN_MODE: release volumes: diff --git a/scripts/install-server.sh b/scripts/install-server.sh index e250c4c06..a9e64cb0e 100755 --- a/scripts/install-server.sh +++ b/scripts/install-server.sh @@ -105,6 +105,7 @@ DATABASE_URL=postgresql://open_ai_canvas:${database_password}@postgres:5432/open CANVAS_HTTP_PORT=${CANVAS_HTTP_PORT} CANVAS_REGISTRATION_ENABLED=false CANVAS_ALLOW_PRIVATE_UPSTREAMS=false +CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS= CANVAS_CORS_ORIGINS= EOF }