security: 配置 Provider 出站端口白名单 (#55)

This commit is contained in:
ila
2026-08-25 11:59:53 +08:00
parent 841d396cdf
commit b0ecbabdf0
10 changed files with 134 additions and 14 deletions
+6 -1
View File
@@ -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")
}
+29
View File
@@ -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]
+3 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -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 校验。
#### 管理端契约
@@ -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
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -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 构建 |
+3 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -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 是否重新校验并限制次数;
+3 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -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`。
+28 -1
View File
@@ -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
+32
View File
@@ -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) {
+26
View File
@@ -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")},
+1 -1
View File
@@ -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
}