fix(security): 私有上游 - 支持精确主机白名单
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)), ".")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user