fix(openflare): trust Cloudflare client IP ranges by default

This commit is contained in:
ryan
2026-10-07 23:51:36 +08:00
parent 7483897d3c
commit 6f63715182
15 changed files with 354 additions and 1 deletions
@@ -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
}
@@ -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)
@@ -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")
}
@@ -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
@@ -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()
@@ -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';
@@ -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';
@@ -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 {
+1
View File
@@ -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}};
+1
View File
@@ -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" // 默认单请求带宽