fix(security): 私有上游 - 支持精确主机白名单

This commit is contained in:
Hao OUYANG
2026-07-22 21:25:41 +08:00
parent 277aba44bb
commit ef65839c9d
8 changed files with 52 additions and 3 deletions

View File

@@ -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

View File

@@ -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)), ".")
}

View File

@@ -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")
}
}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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
}