diff --git a/admin/cmd/server.go b/admin/cmd/server.go index d1b4daa..8199351 100644 --- a/admin/cmd/server.go +++ b/admin/cmd/server.go @@ -17,6 +17,7 @@ import ( "git.ilapage.cn/OPC/chorus/admin/common/database" common "git.ilapage.cn/OPC/chorus/admin/common/middleware" ext "git.ilapage.cn/OPC/chorus/admin/config" + sharedconfig "git.ilapage.cn/OPC/chorus/internal/config" safehttp "git.ilapage.cn/OPC/chorus/internal/platform/http" "github.com/gin-gonic/gin" "github.com/go-admin-team/go-admin-core/config/source/file" @@ -100,7 +101,11 @@ func serviceConfigFromEnvironment(lookup func(string) (string, bool)) (configure if err != nil { return configuredService{}, errors.New("CHORUS_PROVIDER_MAX_RESPONSE_BYTES must be a positive integer") } - client, err := safehttp.New(safehttp.Config{Timeout: timeout, MaxRedirects: 3}) + allowedPorts, err := sharedconfig.ParseProviderAllowedPorts(read("CHORUS_PROVIDER_ALLOWED_PORTS")) + if err != nil { + return configuredService{}, errors.New("CHORUS_PROVIDER_ALLOWED_PORTS must be a comma-separated list of ports") + } + client, err := safehttp.New(safehttp.Config{Timeout: timeout, MaxRedirects: 3, AllowedPorts: allowedPorts}) if err != nil { return configuredService{}, errors.New("configure SSRF-safe connectivity client") } diff --git a/admin/cmd/server_test.go b/admin/cmd/server_test.go index 0f5f477..8f2d7a2 100644 --- a/admin/cmd/server_test.go +++ b/admin/cmd/server_test.go @@ -24,6 +24,35 @@ func TestServiceConfigRequiresAllExplicitConnectivityLimits(t *testing.T) { } } +func TestServiceConfigRejectsInvalidProviderAllowedPorts(t *testing.T) { + _, err := serviceConfigFromEnvironment(testEnvironment(map[string]string{ + "CHORUS_ADMIN_ALLOW_CONNECTIVITY_PROBES": "1", + "CHORUS_ADMIN_CONNECTIVITY_COOLDOWN_SECONDS": "60", + "CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS": "10", + "CHORUS_PROVIDER_MAX_RESPONSE_BYTES": "1024", + "CHORUS_PROVIDER_ALLOWED_PORTS": "80,,443", + })) + if err == nil || !strings.Contains(err.Error(), "CHORUS_PROVIDER_ALLOWED_PORTS") { + t.Fatalf("invalid provider ports error = %v", err) + } +} + +func TestServiceConfigAcceptsCustomProviderAllowedPort(t *testing.T) { + configured, err := serviceConfigFromEnvironment(testEnvironment(map[string]string{ + "CHORUS_ADMIN_ALLOW_CONNECTIVITY_PROBES": "1", + "CHORUS_ADMIN_CONNECTIVITY_COOLDOWN_SECONDS": "60", + "CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS": "10", + "CHORUS_PROVIDER_MAX_RESPONSE_BYTES": "1024", + "CHORUS_PROVIDER_ALLOWED_PORTS": "80,443,8080", + })) + if err != nil { + t.Fatal(err) + } + if !configured.config.AllowConnectivityChecks || configured.config.Probe == nil { + t.Fatal("connectivity probe was not configured") + } +} + func testEnvironment(values map[string]string) func(string) (string, bool) { return func(key string) (string, bool) { value, ok := values[key] diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 4966de9..716b873 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.- -wiki_revision: 47afee496f6d4105e51cddb47e9ba9dae7380293 -synchronized_at: 2026-08-25T01:52:58Z +wiki_revision: 84986fd08a4814ac64fd61d7ab5c02a06d815dc8 +synchronized_at: 2026-08-25T03:56:20Z # 架构与代码地图 @@ -198,7 +198,7 @@ ClaimLease(只取得租约并记录 lease 事件) | `images_edits` | OpenAI-compatible `/images/edits` multipart | image_edit;至少一张图且恰好一个 primary | | `gemini` | 固定 `models/{url-escaped-model}:generateContent` | 按 capability 发送 text/inlineData,读取 text/inlineData;不接受配置覆盖 endpoint | -`auth_type` 只允许 `none`、`bearer`、`x-goog-api-key`,不允许管理员输入任意认证 header。`extra_body` 只合并白名单字段,不能覆盖 URL、认证、model、Prompt、输入、超时、输出数量或响应上限。四种协议的请求、重定向和结果下载全部复用 DNS 后、Dial 前 SSRF 拦截;Bearer 不跨 origin 转发,响应正文与密钥不写日志/attempt/audit。 +`auth_type` 只允许 `none`、`bearer`、`x-goog-api-key`,不允许管理员输入任意认证 header。`extra_body` 只合并白名单字段,不能覆盖 URL、认证、model、Prompt、输入、超时、输出数量或响应上限。四种协议的请求、重定向和结果下载全部复用 DNS 后、Dial 前 SSRF 拦截;Bearer 不跨 origin 转发,响应正文与密钥不写日志/attempt/audit。出站端口由 `CHORUS_PROVIDER_ALLOWED_PORTS` 显式控制,默认仅 `80,443`;Portal worker 与管理端连通性探针使用同一白名单。扩展端口不会放宽 IP、DNS、重定向或结果 URL 校验。 #### 管理端契约 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index a1f12f1..e0e7eb7 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Local-Development-and-Verification.- -wiki_revision: 635183e2476eaa41109fd2b28886a89462b9ed81 -synchronized_at: 2026-08-25T03:21:32Z +wiki_revision: 9931fb7acc36ebcbcc122bcbd528887b970efb10 +synchronized_at: 2026-08-25T03:56:31Z # 本地开发与验证 @@ -231,7 +231,7 @@ go run ./portal | 成功但内容不对 | rendered_prompt 与 prompt template 版本 | | 400/401 尝试多家 | retryable 实现错误 | | 文件存在但页面 403/404 | 用户归属、原子落位、缩略图记录 | -| 上游连不上 | SSRF 日志、DNS/IPv6、redirect、proxy 和 base_url | +| 上游连不上 | SSRF 日志、DNS/IPv6、redirect、proxy、base_url 和 `CHORUS_PROVIDER_ALLOWED_PORTS` | | 管理端生成异常 | 是否先迁移再导表,是否误把菜单脚本当业务 DDL | | 样式丢失 | portal Tailwind 产物或 admin-ui pnpm/Vue CLI 构建 | diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index f1bb979..959a153 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Troubleshooting -wiki_revision: 343eedb7cb319e2de24725965817444454ef9b8d -synchronized_at: 2026-08-25T01:53:19Z +wiki_revision: 47ed912393c434b5547bff7ee4a3c488af03b749 +synchronized_at: 2026-08-25T03:56:42Z # 故障排查 @@ -62,7 +62,7 @@ LIMIT 10; 依次检查: -1. base_url scheme/host/port; +1. base_url scheme/host/port,并确认非标准公开端口已显式加入 `CHORUS_PROVIDER_ALLOWED_PORTS`;未设置时只允许 80/443; 2. DNS 的每个 IPv4/IPv6 结果; 3. DialContext 是否拒绝私网、回环、链路本地、组播和未指定地址; 4. 每次 redirect 是否重新校验并限制次数; diff --git a/docs/10-deployment-and-operations.md b/docs/10-deployment-and-operations.md index c992704..c12f46b 100644 --- a/docs/10-deployment-and-operations.md +++ b/docs/10-deployment-and-operations.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Deployment-and-Operations.- -wiki_revision: dca6da65b9f80b0278ce221847b671b4ecb86ba9 -synchronized_at: 2026-08-25T01:12:36Z +wiki_revision: 62fb1a6eb027824fce0d4147e6afba7c06e68fcf +synchronized_at: 2026-08-25T03:57:04Z # 部署与运维 @@ -127,6 +127,7 @@ portal 单二进制会同时启动 HTTP server 和内嵌 worker。生产除现 | `CHORUS_WORKER_POLL_MILLISECONDS` | 正整数;控制空队列轮询间隔 | | `CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS` | 正整数;安全 HTTP client 总超时和响应头超时 | | `CHORUS_PROVIDER_MAX_RESPONSE_BYTES` | 正整数;Provider JSON/Base64/下载响应读取上限 | +| `CHORUS_PROVIDER_ALLOWED_PORTS` | 可选的逗号分隔端口白名单;默认 `80,443`,仅在受控变更后加入所需公开端口 | Provider API Key 由授权管理员通过管理端写入 `provider_credentials.api_key`。数据库、备份和管理员会话泄露会直接暴露完整 Key;必须限制数据库和管理端权限,列表/普通详情/日志/audit 禁止返回完整值,单条读取响应禁止缓存。部署环境不再需要 `CHORUS_MASTER_KEY`。 diff --git a/internal/config/config.go b/internal/config/config.go index 692b212..b6fcf2a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,6 +38,7 @@ type Config struct { WorkerPollInterval time.Duration ProviderHTTPTimeout time.Duration ProviderMaxResponseBytes int64 + ProviderAllowedPorts []uint16 UserRateLimitCapacity uint64 UserRateLimitWindow time.Duration APIKeyRateLimitCapacity uint64 @@ -158,6 +159,9 @@ func LoadFromLookup(lookup func(string) (string, bool)) (Config, error) { return Config{}, fmt.Errorf("CHORUS_PROVIDER_MAX_RESPONSE_BYTES is invalid") } cfg.ProviderMaxResponseBytes = int64(providerMaxResponseBytes) + if cfg.ProviderAllowedPorts, err = ParseProviderAllowedPorts(read("CHORUS_PROVIDER_ALLOWED_PORTS")); err != nil { + return Config{}, fmt.Errorf("CHORUS_PROVIDER_ALLOWED_PORTS is invalid") + } userRateCapacity, err := intConfig(read("CHORUS_USER_RATE_LIMIT_CAPACITY"), 60) if err != nil { return Config{}, fmt.Errorf("CHORUS_USER_RATE_LIMIT_CAPACITY is invalid") @@ -203,12 +207,35 @@ func (c Config) ValidateProduction() error { if c.Environment != Production { return nil } - if c.DBDSN == "" || len(c.SessionKey) < 32 || c.StorageRoot == "" || c.SessionTTL <= 0 || c.MaxImageBytes <= 0 || c.MaxUploadBytes < c.MaxImageBytes || c.WorkerLeaseDuration <= c.ProviderHTTPTimeout+5*time.Second || c.ProviderMaxResponseBytes <= 0 || c.UserRateLimitCapacity == 0 || c.UserRateLimitWindow <= 0 || c.APIKeyRateLimitCapacity == 0 || c.APIKeyRateLimitWindow <= 0 || c.ProviderRateLimitCapacity == 0 || c.ProviderRateLimitWindow <= 0 { + if c.DBDSN == "" || len(c.SessionKey) < 32 || c.StorageRoot == "" || c.SessionTTL <= 0 || c.MaxImageBytes <= 0 || c.MaxUploadBytes < c.MaxImageBytes || c.WorkerLeaseDuration <= c.ProviderHTTPTimeout+5*time.Second || c.ProviderMaxResponseBytes <= 0 || len(c.ProviderAllowedPorts) == 0 || c.UserRateLimitCapacity == 0 || c.UserRateLimitWindow <= 0 || c.APIKeyRateLimitCapacity == 0 || c.APIKeyRateLimitWindow <= 0 || c.ProviderRateLimitCapacity == 0 || c.ProviderRateLimitWindow <= 0 { return errors.New("production configuration is incomplete") } return nil } +func ParseProviderAllowedPorts(raw string) ([]uint16, error) { + if strings.TrimSpace(raw) == "" { + return []uint16{80, 443}, nil + } + parts := strings.Split(raw, ",") + ports := make([]uint16, 0, len(parts)) + seen := make(map[uint16]struct{}, len(parts)) + for _, part := range parts { + value := strings.TrimSpace(part) + parsed, err := strconv.ParseUint(value, 10, 16) + if err != nil || parsed == 0 { + return nil, errors.New("invalid provider port list") + } + port := uint16(parsed) + if _, exists := seen[port]; exists { + continue + } + seen[port] = struct{}{} + ports = append(ports, port) + } + return ports, nil +} + func intConfig(value string, fallback int) (int, error) { if value == "" { return fallback, nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8a16323..e1baa24 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -28,6 +28,38 @@ func TestLoadDevelopmentRequiresDSNWithoutLeakingIt(t *testing.T) { if cfg.WorkerLeaseDuration != 60*time.Second || cfg.WorkerPollInterval != 250*time.Millisecond || cfg.ProviderHTTPTimeout != 45*time.Second || cfg.ProviderMaxResponseBytes != 32<<20 || cfg.UserRateLimitCapacity != 60 || cfg.APIKeyRateLimitCapacity != 120 || cfg.ProviderRateLimitCapacity != 60 { t.Fatalf("unexpected worker defaults: %#v", cfg) } + if len(cfg.ProviderAllowedPorts) != 2 || cfg.ProviderAllowedPorts[0] != 80 || cfg.ProviderAllowedPorts[1] != 443 { + t.Fatalf("unexpected provider port defaults: %v", cfg.ProviderAllowedPorts) + } +} + +func TestLoadProviderAllowedPorts(t *testing.T) { + cfg, err := LoadFromLookup(lookup(map[string]string{ + "CHORUS_DSN": "test-dsn", "CHORUS_PROVIDER_ALLOWED_PORTS": "80, 443,8080,80", + })) + if err != nil { + t.Fatal(err) + } + want := []uint16{80, 443, 8080} + if len(cfg.ProviderAllowedPorts) != len(want) { + t.Fatalf("provider ports = %v, want %v", cfg.ProviderAllowedPorts, want) + } + for index := range want { + if cfg.ProviderAllowedPorts[index] != want[index] { + t.Fatalf("provider ports = %v, want %v", cfg.ProviderAllowedPorts, want) + } + } +} + +func TestLoadRejectsInvalidProviderAllowedPorts(t *testing.T) { + for _, value := range []string{"0", "65536", "80,,443", "https", "-1"} { + _, err := LoadFromLookup(lookup(map[string]string{ + "CHORUS_DSN": "test-dsn", "CHORUS_PROVIDER_ALLOWED_PORTS": value, + })) + if err == nil || !strings.Contains(err.Error(), "CHORUS_PROVIDER_ALLOWED_PORTS") { + t.Fatalf("invalid provider ports %q were accepted: %v", value, err) + } + } } func TestLoadProductionFailsClosed(t *testing.T) { diff --git a/internal/platform/http/safehttp_test.go b/internal/platform/http/safehttp_test.go index 1f94df0..bcdb864 100644 --- a/internal/platform/http/safehttp_test.go +++ b/internal/platform/http/safehttp_test.go @@ -212,6 +212,32 @@ func TestURLRestrictionsAndRedaction(t *testing.T) { } } +func TestCustomAllowedPortStillUsesValidatedDial(t *testing.T) { + resolver := &fakeResolver{addresses: map[string][][]net.IPAddr{ + "provider.test": {ips("93.184.216.34"), ips("93.184.216.34")}, + }} + var dialed string + fakeDial := func(_ context.Context, _, address string) (net.Conn, error) { + dialed = address + return nil, errors.New("synthetic dial stop") + } + defaultClient, err := New(Config{Resolver: resolver, DialContext: fakeDial, Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + if _, err := defaultClient.Fetch(context.Background(), "http://provider.test:8080/data", 10); !errors.Is(err, ErrInvalidTarget) || dialed != "" { + t.Fatalf("default port policy: dialed=%q error=%v", dialed, err) + } + customClient, err := New(Config{Resolver: resolver, DialContext: fakeDial, Timeout: time.Second, AllowedPorts: []uint16{80, 443, 8080}}) + if err != nil { + t.Fatal(err) + } + _, _ = customClient.Fetch(context.Background(), "http://provider.test:8080/data", 10) + if dialed != "93.184.216.34:8080" { + t.Fatalf("custom port dialed address = %q", dialed) + } +} + func TestMaliciousResultURLAndTimeout(t *testing.T) { resolver := &fakeResolver{addresses: map[string][][]net.IPAddr{ "slow.test": {ips("93.184.216.34"), ips("93.184.216.34")}, diff --git a/portal/main.go b/portal/main.go index d7f8f9a..1c3b990 100644 --- a/portal/main.go +++ b/portal/main.go @@ -69,7 +69,7 @@ func run() error { return err } queueController := queue.NewController(queueRepository) - httpClient, err := safehttp.New(safehttp.Config{Timeout: cfg.ProviderHTTPTimeout, MaxRedirects: 3}) + httpClient, err := safehttp.New(safehttp.Config{Timeout: cfg.ProviderHTTPTimeout, MaxRedirects: 3, AllowedPorts: cfg.ProviderAllowedPorts}) if err != nil { return err }