security: 配置 Provider 出站端口白名单 (#55)
This commit is contained in:
+6
-1
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 构建 |
|
||||
|
||||
|
||||
@@ -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 是否重新校验并限制次数;
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user