From 6f637151829fa190c83a8508159d9a0183006955 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 7 Oct 2026 23:51:36 +0800 Subject: [PATCH] fix(openflare): trust Cloudflare client IP ranges by default --- docs/changelog/index.md | 4 + docs/reference/configuration.md | 3 + .../apps/openflare/config_version/logics.go | 9 ++ .../apps/openflare/config_version/snapshot.go | 23 ++++ .../trusted_proxy_snapshot_test.go | 115 ++++++++++++++++++ .../openflare/option/openresty_validators.go | 20 +++ .../option/openresty_validators_test.go | 8 ++ ...0005_add_openresty_trusted_proxy_cidrs.sql | 7 ++ ...0005_add_openresty_trusted_proxy_cidrs.sql | 7 ++ .../persistence/migrator/migrator_test.go | 47 ++++++- internal/model/openflare_option.go | 1 + internal/model/system_configs.go | 1 + pkg/render/openresty/render.go | 43 +++++++ pkg/render/openresty/render_test.go | 50 ++++++++ pkg/render/openresty/types.go | 17 +++ 15 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 internal/apps/openflare/config_version/trusted_proxy_snapshot_test.go create mode 100644 internal/infra/persistence/migrator/goose/postgres/202610070005_add_openresty_trusted_proxy_cidrs.sql create mode 100644 internal/infra/persistence/migrator/goose/sqlite/202610070005_add_openresty_trusted_proxy_cidrs.sql diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 650290d6..b748bad1 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -10,6 +10,10 @@ sidebar: false ## [Unreleased] +### 🛠 修复 + +- 默认信任 Cloudflare 官方 IPv4/IPv6 网段,并允许管理员追加或覆盖可信代理 CIDR;显式 `[]` 可关闭信任。客户端地址恢复后统一用于访问日志、WAF、`X-Real-IP` 和 `X-Forwarded-For` 追加项,旧版自定义 OpenResty 主模板也会自动补入相关指令。 + ## [v3.5.7] - 2026-10-07 ### ✨ 新功能 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c3f181f6..678c07c6 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -259,6 +259,9 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环 | `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 | | `openresty_default_limit_req_per_ip` | `string` | 站点未配置时的默认单 IP 请求频率限制(如 `10r/s`、`100r/m`);空表示默认关闭 | 空 | | `openresty_main_config_template` | `string` | 允许用户完全重写整个 OpenResty nginx.conf 的底层结构大骨架模板 | 空 (内置缺省骨架) | +| `openresty_trusted_proxy_cidrs` | `string` (JSON 数组) | 可读取 `CF-Connecting-IP` 的可信代理 IPv4/IPv6 CIDR 列表;默认包含 Cloudflare 官方 22 个 IPv4/IPv6 网段;显式 `[]` 会关闭自动生成的可信代理配置 | Cloudflare 官方网段 | + +在管理端「系统管理 → 系统配置 → 业务配置」中编辑 `openresty_trusted_proxy_cidrs`。默认值为 Cloudflare 官方完整 22 个 IPv4/IPv6 网段,可从 [Cloudflare IPv4 网段](https://www.cloudflare.com/ips-v4) 和 [IPv6 网段](https://www.cloudflare.com/ips-v6) 核对并按官方更新维护。若要信任额外内网代理,应将其 CIDR 追加到现有数组;例如 `10.2.0.227` 只有在确认它是可信代理、且它发送的 `CF-Connecting-IP` 真实可靠后,才追加 `10.2.0.227/32`。该地址不属于默认信任范围。显式设置为 `[]` 会关闭由此配置生成的转发头信任;已有显式空值不会在升级时自动改为默认。自定义主模板中手工写入的 Real-IP 指令仍按模板生效。源站请求必须带有真实的 `CF-Connecting-IP`。保存后需重新生成并发布节点配置版本。Nginx Real-IP 会按 `CF-Connecting-IP` 更新 `$remote_addr`,访问日志、WAF 和上游 `X-Real-IP` 使用这个单一客户端地址;`X-Forwarded-For` 保留原代理链并追加恢复后的客户端地址。旧版自定义主模板即使没有新占位符,也会在 `http {}` 中自动插入 Real-IP 指令;若模板已有 `real_ip_header`,该模板控制使用的头,设置仅追加可信来源 CIDR。数据库中缺少该配置项时使用 Cloudflare 默认网段;非法 JSON 或读取配置失败时按空列表关闭信任。 ### 7. 源站错误页 (Origin Error Page) diff --git a/internal/apps/openflare/config_version/logics.go b/internal/apps/openflare/config_version/logics.go index 7b15a9e7..12badae0 100644 --- a/internal/apps/openflare/config_version/logics.go +++ b/internal/apps/openflare/config_version/logics.go @@ -531,6 +531,7 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon appendIfChanged("OpenRestyCacheLockEnabled", strconv.FormatBool(left.CacheLockEnabled), strconv.FormatBool(right.CacheLockEnabled)) appendIfChanged("OpenRestyCacheLockTimeout", left.CacheLockTimeout, right.CacheLockTimeout) appendIfChanged("OpenRestyCacheUseStale", left.CacheUseStale, right.CacheUseStale) + appendIfChanged("OpenRestyTrustedProxyCIDRs", strings.Join(effectiveTrustedProxyCIDRs(left.TrustedProxyCIDRs), ","), strings.Join(effectiveTrustedProxyCIDRs(right.TrustedProxyCIDRs), ",")) appendIfChanged("OpenRestyDefaultLimitConnPerServer", strconv.Itoa(left.DefaultLimitConnPerServer), strconv.Itoa(right.DefaultLimitConnPerServer)) appendIfChanged("OpenRestyDefaultLimitConnPerIP", strconv.Itoa(left.DefaultLimitConnPerIP), strconv.Itoa(right.DefaultLimitConnPerIP)) appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate) @@ -612,6 +613,7 @@ func openRestyOptionKeys() []string { "OpenRestyCacheLockEnabled", "OpenRestyCacheLockTimeout", "OpenRestyCacheUseStale", + "OpenRestyTrustedProxyCIDRs", "OpenRestyDefaultLimitConnPerServer", "OpenRestyDefaultLimitConnPerIP", "OpenRestyDefaultLimitRate", @@ -625,3 +627,10 @@ func openRestyOptionKeys() []string { "SWOfflineDomains", } } + +func effectiveTrustedProxyCIDRs(cidrs []string) []string { + if cidrs == nil { + return openrestyrender.DefaultTrustedProxyCIDRs() + } + return cidrs +} diff --git a/internal/apps/openflare/config_version/snapshot.go b/internal/apps/openflare/config_version/snapshot.go index c4fa04e7..f635c5bb 100644 --- a/internal/apps/openflare/config_version/snapshot.go +++ b/internal/apps/openflare/config_version/snapshot.go @@ -17,6 +17,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/apps/openflare/waf" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/Rain-kl/Wavelet/pkg/protocol" openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" "gorm.io/gorm" @@ -101,6 +102,7 @@ type snapshotWAFDocument struct { } type openRestyConfigSnapshot struct { + TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"` DefaultServerReturnStatus int `json:"default_server_return_status"` WorkerProcesses string `json:"worker_processes"` WorkerConnections int `json:"worker_connections"` @@ -568,6 +570,7 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot { CacheLockTimeout: getStringConfig(model.ConfigKeyOpenRestyCacheLockTimeout, "5s"), CacheUseStale: getStringConfig(model.ConfigKeyOpenRestyCacheUseStale, "error timeout updating http_500 http_502 http_503 http_504"), MainConfigTemplate: getStringConfig(model.ConfigKeyOpenRestyMainConfigTemplate, model.DefaultOpenRestyMainConfigTemplate), + TrustedProxyCIDRs: getTrustedProxyCIDRsConfig(ctx), DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0), DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0), DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))), @@ -590,6 +593,26 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot { return snapshot } +func parseTrustedProxyCIDRs(value string) []string { + var cidrs []string + if err := json.Unmarshal([]byte(value), &cidrs); err != nil || cidrs == nil { + return []string{} + } + return cidrs +} + +func getTrustedProxyCIDRsConfig(ctx context.Context) []string { + config, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyOpenRestyTrustedProxyCIDRs) + if errors.Is(err, gorm.ErrRecordNotFound) { + return openrestyrender.DefaultTrustedProxyCIDRs() + } + if err != nil { + logger.ErrorF(ctx, "[OpenFlareConfig] read trusted proxy CIDRs failed: error=%v", err) + return []string{} + } + return parseTrustedProxyCIDRs(config.Value) +} + func parseOriginErrorPageStatusCodes(raw string) []string { const defaultTag = "500-599" trimmed := strings.TrimSpace(raw) diff --git a/internal/apps/openflare/config_version/trusted_proxy_snapshot_test.go b/internal/apps/openflare/config_version/trusted_proxy_snapshot_test.go new file mode 100644 index 00000000..3daf7daf --- /dev/null +++ b/internal/apps/openflare/config_version/trusted_proxy_snapshot_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package config_version + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/cache/ram" + openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" +) + +func TestTrustedProxyCIDRsSurviveSnapshotRendering(t *testing.T) { + snapshot := snapshotDocument{ + OpenRestyConfig: openRestyConfigSnapshot{ + MainConfigTemplate: model.DefaultOpenRestyMainConfigTemplate, + TrustedProxyCIDRs: parseTrustedProxyCIDRs(`["173.245.48.0/20","2001:db8:1234::/48"]`), + }, + } + data, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + rendered, err := renderSnapshotConfig(string(data), nil) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"real_ip_header CF-Connecting-IP;", "set_real_ip_from 173.245.48.0/20;", "set_real_ip_from 2001:db8:1234::/48;"} { + if !strings.Contains(rendered.MainConfig, expected) { + t.Errorf("rendered snapshot missing %q", expected) + } + } +} + +func TestTrustedProxyCIDRsSnapshotEmptyAndLegacyDefaults(t *testing.T) { + for _, testCase := range []struct { + name string + json string + want int + }{ + {name: "explicit empty list remains disabled", json: `{"openresty_config":{"trusted_proxy_cidrs":[]}}`, want: 0}, + {name: "legacy snapshot defaults to Cloudflare ranges", json: `{"openresty_config":{}}`, want: 22}, + } { + t.Run(testCase.name, func(t *testing.T) { + rendered, err := renderSnapshotConfig(testCase.json, nil) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(rendered.MainConfig, "set_real_ip_from "); got != testCase.want { + t.Fatalf("rendered trusted proxy range count = %d, want %d", got, testCase.want) + } + }) + } +} + +func TestTrustedProxyCIDRConfigMissingAndExplicitEmpty(t *testing.T) { + cleanup := setupOriginErrorPageSnapshotDB(t) + defer cleanup() + ctx := context.Background() + + missing := buildOpenRestyConfigSnapshot(ctx).TrustedProxyCIDRs + if len(missing) != 22 || !slices.Equal(missing, openrestyrender.DefaultTrustedProxyCIDRs()) { + t.Fatalf("missing database key defaults to %#v, want the 22 Cloudflare ranges", missing) + } + + if err := db.DB(ctx).Create(&model.SystemConfig{Key: model.ConfigKeyOpenRestyTrustedProxyCIDRs, Value: `[]`, Type: "business"}).Error; err != nil { + t.Fatal(err) + } + explicitEmpty := buildOpenRestyConfigSnapshot(ctx).TrustedProxyCIDRs + if explicitEmpty == nil || len(explicitEmpty) != 0 { + t.Fatalf("explicit [] must remain a non-nil empty list, got %#v", explicitEmpty) + } + payload, err := json.Marshal(snapshotDocument{OpenRestyConfig: buildOpenRestyConfigSnapshot(ctx)}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(payload), `"trusted_proxy_cidrs":[]`) { + t.Fatalf("explicit empty list was omitted from snapshot: %s", payload) + } + rendered, err := renderSnapshotConfig(string(payload), nil) + if err != nil { + t.Fatal(err) + } + if strings.Contains(rendered.MainConfig, "set_real_ip_from ") { + t.Fatal("explicit empty list still trusts proxy ranges") + } + + if err := db.DB(ctx).Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyOpenRestyTrustedProxyCIDRs).Update("value", "invalid").Error; err != nil { + t.Fatal(err) + } + ram.ResetForTest() + malformed := buildOpenRestyConfigSnapshot(ctx).TrustedProxyCIDRs + if malformed == nil || len(malformed) != 0 { + t.Fatalf("malformed configuration must fail closed, got %#v", malformed) + } +} + +func TestTrustedProxyCIDRDiffTreatsLegacyNilAsCloudflareDefaults(t *testing.T) { + diffs := diffOpenRestyOptionDetails(openRestyConfigSnapshot{}, openRestyConfigSnapshot{TrustedProxyCIDRs: []string{}}) + for _, diff := range diffs { + if diff.Key == "OpenRestyTrustedProxyCIDRs" { + if diff.PreviousValue == diff.CurrentValue { + t.Fatal("legacy nil and explicit empty list should have different effective values") + } + return + } + } + t.Fatal("trusted proxy CIDR change was not included in config diff") +} diff --git a/internal/apps/openflare/option/openresty_validators.go b/internal/apps/openflare/option/openresty_validators.go index d4b703ff..5ed3524f 100644 --- a/internal/apps/openflare/option/openresty_validators.go +++ b/internal/apps/openflare/option/openresty_validators.go @@ -6,6 +6,7 @@ package option import ( "encoding/json" "fmt" + "net/netip" "regexp" "strconv" "strings" @@ -17,6 +18,7 @@ import ( const ( maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB maxSWOfflineDomains = 1000 + maxTrustedProxyCIDRs = 256 ) var openRestyOptionValidators = map[string]func(key, value string) error{ @@ -57,6 +59,7 @@ var openRestyOptionValidators = map[string]func(key, value string) error{ model.ConfigKeyOpenRestyCacheKeyTemplate: validateOpenRestyCacheKeyTemplate, model.ConfigKeyOpenRestyCacheUseStale: validateOpenRestyCacheUseStale, model.ConfigKeyOpenRestyMainConfigTemplate: validateOpenRestyMainConfigTemplate, + model.ConfigKeyOpenRestyTrustedProxyCIDRs: validateOpenRestyTrustedProxyCIDRs, model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption, model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption, model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate, @@ -204,6 +207,23 @@ func validateOpenRestyMainConfigTemplate(key, value string) error { return nil } +func validateOpenRestyTrustedProxyCIDRs(key, value string) error { + var cidrs []string + if err := json.Unmarshal([]byte(value), &cidrs); err != nil || cidrs == nil { + return fmt.Errorf("%s 必须为 JSON 字符串数组", key) + } + if len(cidrs) > maxTrustedProxyCIDRs { + return fmt.Errorf("%s 最多允许 %d 个网段", key, maxTrustedProxyCIDRs) + } + for _, cidr := range cidrs { + prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr)) + if err != nil || prefix != prefix.Masked() { + return fmt.Errorf("%s 包含无效网段 %q", key, cidr) + } + } + return nil +} + func validateOpenRestyDefaultLimitRate(key, trimmed string) error { if trimmed == "" || trimmed == "0" { return nil diff --git a/internal/apps/openflare/option/openresty_validators_test.go b/internal/apps/openflare/option/openresty_validators_test.go index c8d9767e..a27ca4a7 100644 --- a/internal/apps/openflare/option/openresty_validators_test.go +++ b/internal/apps/openflare/option/openresty_validators_test.go @@ -70,6 +70,14 @@ func TestValidateOriginErrorPageStatusCodes(t *testing.T) { } } +func TestValidateOpenRestyTrustedProxyCIDRs(t *testing.T) { + require.NoError(t, validateOpenRestyOption(model.ConfigKeyOpenRestyTrustedProxyCIDRs, `[]`)) + require.NoError(t, validateOpenRestyOption(model.ConfigKeyOpenRestyTrustedProxyCIDRs, `["173.245.48.0/20","10.2.0.0/24"]`)) + for _, value := range []string{`null`, `10.2.0.0/24`, `["10.2.0.0/24;return 200"]`, `["]`} { + require.Error(t, validateOpenRestyOption(model.ConfigKeyOpenRestyTrustedProxyCIDRs, value), "value %q must be rejected", value) + } +} + func TestValidateOriginErrorPageHTML(t *testing.T) { t.Parallel() diff --git a/internal/infra/persistence/migrator/goose/postgres/202610070005_add_openresty_trusted_proxy_cidrs.sql b/internal/infra/persistence/migrator/goose/postgres/202610070005_add_openresty_trusted_proxy_cidrs.sql new file mode 100644 index 00000000..45408a66 --- /dev/null +++ b/internal/infra/persistence/migrator/goose/postgres/202610070005_add_openresty_trusted_proxy_cidrs.sql @@ -0,0 +1,7 @@ +-- +goose Up +INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) +VALUES ('openresty_trusted_proxy_cidrs', '["173.245.48.0/20","103.21.244.0/22","103.22.200.0/22","103.31.4.0/22","141.101.64.0/18","108.162.192.0/18","190.93.240.0/20","188.114.96.0/20","197.234.240.0/22","198.41.128.0/17","162.158.0.0/15","104.16.0.0/13","104.24.0.0/14","172.64.0.0/13","131.0.72.0/22","2400:cb00::/32","2606:4700::/32","2803:f800::/32","2405:b500::/32","2405:8100::/32","2a06:98c0::/29","2c0f:f248::/32"]', 'business', 0, '可信代理 IPv4/IPv6 CIDR JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DELETE FROM w_system_configs WHERE key = 'openresty_trusted_proxy_cidrs'; diff --git a/internal/infra/persistence/migrator/goose/sqlite/202610070005_add_openresty_trusted_proxy_cidrs.sql b/internal/infra/persistence/migrator/goose/sqlite/202610070005_add_openresty_trusted_proxy_cidrs.sql new file mode 100644 index 00000000..45408a66 --- /dev/null +++ b/internal/infra/persistence/migrator/goose/sqlite/202610070005_add_openresty_trusted_proxy_cidrs.sql @@ -0,0 +1,7 @@ +-- +goose Up +INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) +VALUES ('openresty_trusted_proxy_cidrs', '["173.245.48.0/20","103.21.244.0/22","103.22.200.0/22","103.31.4.0/22","141.101.64.0/18","108.162.192.0/18","190.93.240.0/20","188.114.96.0/20","197.234.240.0/22","198.41.128.0/17","162.158.0.0/15","104.16.0.0/13","104.24.0.0/14","172.64.0.0/13","131.0.72.0/22","2400:cb00::/32","2606:4700::/32","2803:f800::/32","2405:b500::/32","2405:8100::/32","2a06:98c0::/29","2c0f:f248::/32"]', 'business', 0, '可信代理 IPv4/IPv6 CIDR JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DELETE FROM w_system_configs WHERE key = 'openresty_trusted_proxy_cidrs'; diff --git a/internal/infra/persistence/migrator/migrator_test.go b/internal/infra/persistence/migrator/migrator_test.go index 8c805e34..5f196549 100644 --- a/internal/infra/persistence/migrator/migrator_test.go +++ b/internal/infra/persistence/migrator/migrator_test.go @@ -5,7 +5,9 @@ package migrator import ( "context" + "encoding/json" "io/fs" + "slices" "strings" "testing" @@ -13,6 +15,7 @@ import ( db "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/repository" + openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" "github.com/redis/go-redis/v9" @@ -24,7 +27,38 @@ import ( // (初始系统配置 + 各期配置迁移/新增 seed:of_options 迁移、文件白名单、磁盘缓存、 // 登录会话 TTL、升级源、存储、FRPS Web UI、Pages、OpenResty 限流、单 IP 限频、 // 错误页、SW 离线、日志保留期、指标保留期等);新增配置 seed 迁移时需同步更新本常量。 -const expectedMigratedSystemConfigCount = 96 +const expectedMigratedSystemConfigCount = 97 + +func TestTrustedProxyCIDRMigrationDefaultsMatchRenderer(t *testing.T) { + want := openrestyrender.DefaultTrustedProxyCIDRs() + if len(want) != 22 { + t.Fatalf("renderer default contains %d proxy ranges, want 22", len(want)) + } + for _, migrationPath := range []string{ + "goose/postgres/202610070005_add_openresty_trusted_proxy_cidrs.sql", + "goose/sqlite/202610070005_add_openresty_trusted_proxy_cidrs.sql", + } { + migration, err := fs.ReadFile(migrationFS, migrationPath) + if err != nil { + t.Fatal(err) + } + seed, ok := strings.CutPrefix(string(migration), "-- +goose Up\nINSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)\nVALUES ('openresty_trusted_proxy_cidrs', '") + if !ok { + t.Fatalf("%s does not contain the expected CIDR seed", migrationPath) + } + seed, _, ok = strings.Cut(seed, "', 'business'") + if !ok { + t.Fatalf("%s CIDR seed value is malformed", migrationPath) + } + var got []string + if err := json.Unmarshal([]byte(seed), &got); err != nil { + t.Fatalf("decode %s CIDR seed: %v", migrationPath, err) + } + if !slices.Equal(got, want) { + t.Errorf("%s CIDR seed differs from renderer default", migrationPath) + } + } +} func TestGooseMigrationVersionsAreUniquePerDialect(t *testing.T) { for _, dir := range []string{"goose/postgres", "goose/sqlite"} { @@ -88,6 +122,17 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) { if systemConfigCount != expectedMigratedSystemConfigCount { t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, expectedMigratedSystemConfigCount) } + var trustedProxyConfig model.SystemConfig + if err := sqliteDB.Where("key = ?", model.ConfigKeyOpenRestyTrustedProxyCIDRs).First(&trustedProxyConfig).Error; err != nil { + t.Fatalf("Migrate() trusted proxy CIDR config error = %v", err) + } + var trustedProxyCIDRs []string + if err := json.Unmarshal([]byte(trustedProxyConfig.Value), &trustedProxyCIDRs); err != nil { + t.Fatalf("decode trusted proxy CIDR config: %v", err) + } + if !slices.Equal(trustedProxyCIDRs, openrestyrender.DefaultTrustedProxyCIDRs()) { + t.Errorf("Migrate() trusted proxy CIDRs = %#v, want default Cloudflare ranges", trustedProxyCIDRs) + } var logMigrationConfig model.SystemConfig if err := sqliteDB.Where("key = ?", model.ConfigKeyLogDBMigration).First(&logMigrationConfig).Error; err != nil { diff --git a/internal/model/openflare_option.go b/internal/model/openflare_option.go index b889bd58..abfc0f2c 100644 --- a/internal/model/openflare_option.go +++ b/internal/model/openflare_option.go @@ -48,6 +48,7 @@ http { client_header_timeout {{OpenRestyClientHeaderTimeout}}; client_body_timeout {{OpenRestyClientBodyTimeout}}; client_max_body_size {{OpenRestyClientMaxBodySize}}; +{{OpenRestyRealIPDirectives}} large_client_header_buffers {{OpenRestyLargeClientHeaderBuffers}}; send_timeout {{OpenRestySendTimeout}}; proxy_connect_timeout {{OpenRestyProxyConnectTimeout}}; diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index aa3ec19a..dd6c5a84 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -103,6 +103,7 @@ const ( ConfigKeyOpenRestyCacheLockTimeout = "openresty_cache_lock_timeout" // 缓存锁超时 ConfigKeyOpenRestyCacheUseStale = "openresty_cache_use_stale" // 缓存失效策略 ConfigKeyOpenRestyMainConfigTemplate = "openresty_main_config_template" // 主配置模板 + ConfigKeyOpenRestyTrustedProxyCIDRs = "openresty_trusted_proxy_cidrs" // 可信代理 CIDR JSON 数组 ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接 ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接 ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽 diff --git a/pkg/render/openresty/render.go b/pkg/render/openresty/render.go index be41ec00..a8f7ee1e 100644 --- a/pkg/render/openresty/render.go +++ b/pkg/render/openresty/render.go @@ -13,6 +13,7 @@ import ( "encoding/pem" "errors" "fmt" + "net/netip" "net/url" "path" "regexp" @@ -170,7 +171,18 @@ func DedupeSupportFiles(files []SupportFile) []SupportFile { } func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot, limitReqRates []string) string { + trustedProxyCIDRs := cfg.TrustedProxyCIDRs + if trustedProxyCIDRs == nil { + trustedProxyCIDRs = DefaultTrustedProxyCIDRs() + } + trustedProxyDirectives := renderTrustedProxyDirectives(trustedProxyCIDRs, !hasNginxDirective(templateText, "real_ip_header")) + if !strings.Contains(templateText, RealIPDirectivesPlaceholder) && trustedProxyDirectives != "" { + // This required token expands to a complete map block in the http context. + // Injecting before it avoids depending on formatting or splitting directives. + templateText = strings.Replace(templateText, "{{OpenRestyConnectionUpgradeMap}}", trustedProxyDirectives+"{{OpenRestyConnectionUpgradeMap}}", 1) + } replacer := strings.NewReplacer( + RealIPDirectivesPlaceholder, trustedProxyDirectives, "{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses, "{{OpenRestyWorkerConnections}}", strconv.Itoa(cfg.WorkerConnections), "{{OpenRestyWorkerRlimitNofile}}", strconv.Itoa(cfg.WorkerRlimitNofile), @@ -205,6 +217,37 @@ func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot, limitReqR return replacer.Replace(templateText) } +func hasNginxDirective(templateText string, directive string) bool { + for _, line := range strings.Split(templateText, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") { + continue + } + if fields := strings.Fields(line); len(fields) > 0 && fields[0] == directive { + return true + } + } + return false +} + +func renderTrustedProxyDirectives(cidrs []string, includeHeader bool) string { + if len(cidrs) == 0 { + return "" + } + var builder strings.Builder + if includeHeader { + builder.WriteString(" real_ip_header CF-Connecting-IP;\n real_ip_recursive on;\n") + } + for _, cidr := range cidrs { + prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr)) + if err != nil { + continue + } + fmt.Fprintf(&builder, " set_real_ip_from %s;\n", prefix.Masked()) + } + return builder.String() +} + func renderTemplateDirective(enabled bool, statement string) string { if !enabled { return "" diff --git a/pkg/render/openresty/render_test.go b/pkg/render/openresty/render_test.go index ff4a398b..80c54eb7 100644 --- a/pkg/render/openresty/render_test.go +++ b/pkg/render/openresty/render_test.go @@ -9,6 +9,56 @@ import ( "testing" ) +func TestRenderMainConfigTrustedProxyCIDRs(t *testing.T) { + base := ConfigSnapshot{MainConfigTemplate: defaultMainConfigTemplate} + withDefaultTrust := RenderMainConfig(Document{OpenRestyConfig: base}) + if count := strings.Count(withDefaultTrust, "set_real_ip_from "); count != 22 { + t.Fatalf("default config trusts %d proxy ranges, want all 22 Cloudflare ranges", count) + } + if !strings.Contains(withDefaultTrust, "set_real_ip_from 173.245.48.0/20;") || !strings.Contains(withDefaultTrust, "set_real_ip_from 2c0f:f248::/32;") { + t.Fatal("default config is missing Cloudflare IPv4 or IPv6 ranges") + } + + base.TrustedProxyCIDRs = []string{} + withoutTrust := RenderMainConfig(Document{OpenRestyConfig: base}) + if strings.Contains(withoutTrust, "real_ip_header") || strings.Contains(withoutTrust, "set_real_ip_from") { + t.Fatal("explicit empty trusted-proxy list must disable forwarded-header trust") + } + + base.TrustedProxyCIDRs = []string{"173.245.48.0/20", "10.2.0.0/24", "2001:db8:1234::/48", "192.0.2.7/32"} + got := RenderMainConfig(Document{OpenRestyConfig: base}) + for _, expected := range []string{"real_ip_header CF-Connecting-IP;", "set_real_ip_from 173.245.48.0/20;", "set_real_ip_from 10.2.0.0/24;", "set_real_ip_from 2001:db8:1234::/48;", "set_real_ip_from 192.0.2.7/32;"} { + if !strings.Contains(got, expected) { + t.Errorf("rendered config missing %q", expected) + } + } +} + +func TestRenderMainConfigInjectsTrustedProxyForLegacyTemplate(t *testing.T) { + legacy := strings.ReplaceAll(defaultMainConfigTemplate, RealIPDirectivesPlaceholder+"\n", "") + got := RenderMainConfig(Document{OpenRestyConfig: ConfigSnapshot{MainConfigTemplate: legacy, TrustedProxyCIDRs: []string{"10.2.0.0/24"}}}) + if !strings.Contains(got, "real_ip_header CF-Connecting-IP;\n real_ip_recursive on;\n set_real_ip_from 10.2.0.0/24;") || !strings.Contains(got, "map $http_upgrade $connection_upgrade {") { + t.Fatalf("trusted proxy directives were not injected into legacy template:\n%s", got) + } + if strings.Contains(got, "access_log real_ip_header") { + t.Fatalf("trusted proxy directives corrupted access_log directive:\n%s", got) + } +} + +func TestRenderMainConfigPreservesExplicitRealIPHeader(t *testing.T) { + templateText := strings.Replace(defaultMainConfigTemplate, "{{OpenRestyConnectionUpgradeMap}}", "# real_ip_header in comment\nreal_ip_header X-Forwarded-For;\n{{OpenRestyConnectionUpgradeMap}}", 1) + got := RenderMainConfig(Document{OpenRestyConfig: ConfigSnapshot{ + MainConfigTemplate: templateText, + TrustedProxyCIDRs: []string{"192.0.2.7/32"}, + }}) + if !strings.Contains(got, "real_ip_header X-Forwarded-For;") || strings.Contains(got, "real_ip_header CF-Connecting-IP;") { + t.Fatalf("explicit template header selection was not preserved:\n%s", got) + } + if !strings.Contains(got, "set_real_ip_from 192.0.2.7/32;") { + t.Fatalf("trusted CIDR was not added to explicit template header:\n%s", got) + } +} + func TestRenderOpenRestyUsesDedicatedWAFIPGroupSharedDict(t *testing.T) { block := renderOpenRestyObservabilityTemplateBlock() if !strings.Contains(block, "lua_shared_dict openflare_waf_config 1m;") { diff --git a/pkg/render/openresty/types.go b/pkg/render/openresty/types.go index b969f28c..c4b2900f 100644 --- a/pkg/render/openresty/types.go +++ b/pkg/render/openresty/types.go @@ -24,6 +24,7 @@ const ( PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__" PagesDirPlaceholder = "__OPENFLARE_PAGES_DIR__" ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__" + RealIPDirectivesPlaceholder = "{{OpenRestyRealIPDirectives}}" SWDirPlaceholder = "__OPENFLARE_SW_DIR__" SourceConfigFileName = "openresty_config.json" @@ -102,6 +103,7 @@ http { gzip {{OpenRestyGzip}}; gzip_min_length {{OpenRestyGzipMinLength}}; gzip_comp_level {{OpenRestyGzipCompLevel}}; +{{OpenRestyRealIPDirectives}} gzip_vary on; gzip_types text/plain text/css text/xml application/javascript application/json application/xml application/rss+xml application/atom+xml image/svg+xml; {{OpenRestyResolverDirective}}{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}}; @@ -281,6 +283,7 @@ type WAFDocument struct { // ConfigSnapshot holds the full set of OpenResty tuning parameters that are // rendered into the nginx main configuration template. type ConfigSnapshot struct { + TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"` DefaultServerReturnStatus int `json:"default_server_return_status"` WorkerProcesses string `json:"worker_processes"` WorkerConnections int `json:"worker_connections"` @@ -335,6 +338,20 @@ type ConfigSnapshot struct { SWOfflineDomains []string `json:"sw_offline_domains,omitempty"` } +// DefaultTrustedProxyCIDRs returns Cloudflare's published IPv4 and IPv6 +// networks. Callers can override the list, including with an explicit empty +// slice to disable trusted-proxy processing. +func DefaultTrustedProxyCIDRs() []string { + return []string{ + "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", + "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", + "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", + "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22", + "2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", + "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32", + } +} + // Document is the top-level input structure for the OpenResty renderer, // combining routes, OpenResty tuning, and WAF configuration. type Document struct {