From 4ed246dc494afcdc6dd895a7aa415d508dfe0f0c Mon Sep 17 00:00:00 2001 From: DiheChen Date: Sun, 8 Nov 2026 21:43:35 +0800 Subject: [PATCH] feat: take the client address from trusted proxies --- .env.example | 1 + compose.yaml | 1 + helm/alisten/templates/serve.yaml | 2 + helm/alisten/values.yaml | 2 + internal/adapter/in/api/api.go | 8 +++- internal/adapter/in/api/clientip.go | 43 +++++++++++++++++ internal/adapter/in/api/clientip_test.go | 47 +++++++++++++++++++ internal/bootstrap/config.go | 29 +++++++++++- internal/bootstrap/config_test.go | 23 +++++++++ internal/bootstrap/public.go | 3 +- .../templates/alisten.d/proxy.conf.template | 1 + 11 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 internal/adapter/in/api/clientip.go create mode 100644 internal/adapter/in/api/clientip_test.go diff --git a/.env.example b/.env.example index c7d872d..ba247e9 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ ALISTEN_HOST=127.0.0.1 # 绑定的 ip ALISTEN_PORT=9876 # 监听的端口 ALISTEN_ALLOWED_ORIGINS="" # 逗号分隔的 Origin 允许列表,留空时仅允许 ALISTEN_PUBLIC_URL +ALISTEN_TRUSTED_PROXIES="" # 逗号分隔的 CIDR,来自这些地址的反向代理传来的 X-Forwarded-For 才被采信;留空时按连接的对端地址限流 ALISTEN_LOG_LEVEL=info # 日志等级, 可选值为 debug、info、warn、error ALISTEN_WORKER_TMP_SIZE="1g" # Worker 临时文件大小 ALISTEN_ADMIN_TOKEN="" # 进入管理后台需要的凭证, 留空则禁用后台 diff --git a/compose.yaml b/compose.yaml index 47fc73d..7eaa430 100644 --- a/compose.yaml +++ b/compose.yaml @@ -40,6 +40,7 @@ services: <<: *alisten-env ALISTEN_PUBLIC_URL: ${ALISTEN_PUBLIC_URL} ALISTEN_ALLOWED_ORIGINS: ${ALISTEN_ALLOWED_ORIGINS} + ALISTEN_TRUSTED_PROXIES: ${ALISTEN_TRUSTED_PROXIES} ALISTEN_ADMIN_TOKEN: ${ALISTEN_ADMIN_TOKEN} STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL} GOMAXPROCS: "2" diff --git a/helm/alisten/templates/serve.yaml b/helm/alisten/templates/serve.yaml index c08ef6d..08d9ca8 100644 --- a/helm/alisten/templates/serve.yaml +++ b/helm/alisten/templates/serve.yaml @@ -63,6 +63,8 @@ spec: value: {{ printf "https://%s" (include "alisten.domain" .) | quote }} - name: ALISTEN_ALLOWED_ORIGINS value: {{ .Values.allowedOrigins | quote }} + - name: ALISTEN_TRUSTED_PROXIES + value: {{ .Values.trustedProxies | quote }} - name: STORAGE_PUBLIC_URL value: {{ required "storage.publicUrl 不能为空" .Values.storage.publicUrl | quote }} {{- include "alisten.databaseEnv" . | nindent 12 }} diff --git a/helm/alisten/values.yaml b/helm/alisten/values.yaml index 13ac556..427e5a7 100644 --- a/helm/alisten/values.yaml +++ b/helm/alisten/values.yaml @@ -1,5 +1,7 @@ domain: "" allowedOrigins: "" +# 逗号分隔的 CIDR:只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信,用于按客户端 IP 限流。 +trustedProxies: "" logLevel: info otlpEndpoint: "" database: diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index fe94cd7..84981c5 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -8,6 +8,7 @@ import ( "io" "log/slog" "net/http" + "net/netip" "runtime/debug" "slices" "strings" @@ -47,6 +48,9 @@ type Services struct { // 浏览器访问对象存储时使用的地址前缀,结尾没有 /。 StorageURL string + + // 只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信。 + TrustedProxies []netip.Prefix } type server struct { @@ -65,6 +69,8 @@ type call struct { ticket string // 票据是常驻房间的 API Key。 bot bool + // 发出请求的客户端地址,按来源限流时使用。 + clientIP string } type handlerFunc func(ctx context.Context, c call) (any, error) @@ -180,7 +186,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque logCtx, err = ctx, s.panicked(ctx, r, value) } }() - c := call{r: r} + c := call{r: r, clientIP: clientIP(r, s.TrustedProxies)} switch auth { case protocol.AuthNone: case protocol.AuthMember: diff --git a/internal/adapter/in/api/clientip.go b/internal/adapter/in/api/clientip.go new file mode 100644 index 0000000..a05bee9 --- /dev/null +++ b/internal/adapter/in/api/clientip.go @@ -0,0 +1,43 @@ +package api + +import ( + "net/http" + "net/netip" + "slices" + "strings" +) + +// 只有对端是受信任的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理, +// 第一个不受信任的地址就是客户端;全部受信任时取最左端的地址。受信任的代理只会写入合法的地址, +// 遇到不合法的值说明已经到了客户端自己填写的部分,此时改用对端地址。 +func clientIP(r *http.Request, trusted []netip.Prefix) string { + peer, err := netip.ParseAddrPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + addr := peer.Addr().Unmap() + if !containsAddr(trusted, addr) { + return addr.String() + } + var hops []string + for _, v := range r.Header.Values("X-Forwarded-For") { + for hop := range strings.SplitSeq(v, ",") { + hops = append(hops, strings.TrimSpace(hop)) + } + } + for i := len(hops) - 1; i >= 0; i-- { + hop, err := netip.ParseAddr(hops[i]) + if err != nil { + break + } + hop = hop.Unmap() + if i == 0 || !containsAddr(trusted, hop) { + return hop.String() + } + } + return addr.String() +} + +func containsAddr(prefixes []netip.Prefix, addr netip.Addr) bool { + return slices.ContainsFunc(prefixes, func(p netip.Prefix) bool { return p.Contains(addr) }) +} diff --git a/internal/adapter/in/api/clientip_test.go b/internal/adapter/in/api/clientip_test.go new file mode 100644 index 0000000..66f5500 --- /dev/null +++ b/internal/adapter/in/api/clientip_test.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http/httptest" + "net/netip" + "testing" +) + +func TestClientIP(t *testing.T) { + trusted := []netip.Prefix{netip.MustParsePrefix("10.42.0.0/16"), netip.MustParsePrefix("127.0.0.1/32")} + cases := []struct { + name string + remote string + forwarded []string + want string + }{ + {"对端不受信任时不看 X-Forwarded-For", "203.0.113.7:4321", []string{"198.51.100.1"}, "203.0.113.7"}, + {"受信任的代理转发时取客户端地址", "10.42.0.153:4321", []string{"203.0.113.7"}, "203.0.113.7"}, + {"跳过右端受信任的代理", "127.0.0.1:4321", []string{"203.0.113.7, 10.42.0.1"}, "203.0.113.7"}, + {"客户端自己填在左边的地址不采用", "10.42.0.153:4321", []string{"198.51.100.1, 203.0.113.7"}, "203.0.113.7"}, + {"多个头按顺序拼接", "10.42.0.153:4321", []string{"198.51.100.1", "203.0.113.7"}, "203.0.113.7"}, + {"全部受信任时取最左端", "10.42.0.153:4321", []string{"10.42.0.1"}, "10.42.0.1"}, + {"没有 X-Forwarded-For 时取对端", "10.42.0.153:4321", nil, "10.42.0.153"}, + {"遇到不合法的地址就停下,取对端", "10.42.0.153:4321", []string{"203.0.113.7, unknown, 10.42.0.1"}, "10.42.0.153"}, + {"IPv4 映射的 IPv6 地址按 IPv4 处理", "[::ffff:10.42.0.153]:4321", []string{"::ffff:203.0.113.7"}, "203.0.113.7"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = tc.remote + for _, v := range tc.forwarded { + r.Header.Add("X-Forwarded-For", v) + } + if got := clientIP(r, trusted); got != tc.want { + t.Errorf("clientIP = %q,期望 %q", got, tc.want) + } + }) + } + t.Run("没有配置受信任的代理", func(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.42.0.153:4321" + r.Header.Set("X-Forwarded-For", "203.0.113.7") + if got := clientIP(r, nil); got != "10.42.0.153" { + t.Errorf("clientIP = %q,期望对端地址", got) + } + }) +} diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index 5e36268..04d63b5 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -30,8 +30,9 @@ type serveConfig struct { redis *goredis.Options storage storage.Config // 浏览器访问对象存储时使用的地址前缀,结尾没有 /。 - storageURL string - tracing bool + storageURL string + trustedProxies []netip.Prefix + tracing bool } func loadServeConfig() (serveConfig, error) { @@ -57,6 +58,10 @@ func loadServeConfig() (serveConfig, error) { return serveConfig{}, err } adminToken := os.Getenv("ALISTEN_ADMIN_TOKEN") + trustedProxies, err := parseTrustedProxies(os.Getenv("ALISTEN_TRUSTED_PROXIES")) + if err != nil { + return serveConfig{}, err + } database, err := loadDatabaseConfig() if err != nil { return serveConfig{}, err @@ -91,6 +96,7 @@ func loadServeConfig() (serveConfig, error) { redis: redis, storage: objects, storageURL: storageURL, + trustedProxies: trustedProxies, tracing: tracing, }, nil } @@ -139,6 +145,25 @@ func parseAllowedOrigins(value, fallback string) ([]string, error) { return origins, nil } +func parseTrustedProxies(value string) ([]netip.Prefix, error) { + if value == "" { + return nil, nil + } + var prefixes []netip.Prefix + for raw := range strings.SplitSeq(value, ",") { + item := strings.TrimSpace(raw) + if item == "" { + return nil, errors.New("ALISTEN_TRUSTED_PROXIES 中有空项") + } + prefix, err := netip.ParsePrefix(item) + if err != nil { + return nil, fmt.Errorf("ALISTEN_TRUSTED_PROXIES 中的 %q 不是 CIDR:%w", item, err) + } + prefixes = append(prefixes, prefix.Masked()) + } + return prefixes, nil +} + func canonicalPort(port string) bool { n, err := strconv.Atoi(port) return err == nil && n >= 1 && n <= 65535 && strconv.Itoa(n) == port diff --git a/internal/bootstrap/config_test.go b/internal/bootstrap/config_test.go index 0fc9390..62c4cfe 100644 --- a/internal/bootstrap/config_test.go +++ b/internal/bootstrap/config_test.go @@ -2,7 +2,9 @@ package bootstrap import ( "log/slog" + "net/netip" "os" + "slices" "testing" ) @@ -83,3 +85,24 @@ func TestLoadLogLevel(t *testing.T) { } func ptr(s string) *string { return &s } + +func TestParseTrustedProxies(t *testing.T) { + got, err := parseTrustedProxies("10.42.0.0/16, 127.0.0.1/32,::1/128,10.43.1.2/16") + want := []netip.Prefix{ + netip.MustParsePrefix("10.42.0.0/16"), + netip.MustParsePrefix("127.0.0.1/32"), + netip.MustParsePrefix("::1/128"), + netip.MustParsePrefix("10.43.0.0/16"), + } + if err != nil || !slices.Equal(got, want) { + t.Errorf("parseTrustedProxies = %v, %v,期望 %v", got, err, want) + } + if got, err := parseTrustedProxies(""); err != nil || got != nil { + t.Errorf("空值 = %v, %v,期望没有受信任的代理", got, err) + } + for _, bad := range []string{"10.42.0.1", "10.42.0.0/16,", "not-a-cidr"} { + if _, err := parseTrustedProxies(bad); err == nil { + t.Errorf("parseTrustedProxies(%q) 应报错", bad) + } + } +} diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index d20c71c..3e8d670 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -86,7 +86,8 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc Playback: media.NewPlayback(src.playback, store), Site: site.New(store), - StorageURL: cfg.storageURL, + StorageURL: cfg.storageURL, + TrustedProxies: cfg.trustedProxies, } httpLogger := base.With("component", "http") return public{ diff --git a/web/nginx/templates/alisten.d/proxy.conf.template b/web/nginx/templates/alisten.d/proxy.conf.template index 52842ff..d3718de 100644 --- a/web/nginx/templates/alisten.d/proxy.conf.template +++ b/web/nginx/templates/alisten.d/proxy.conf.template @@ -9,6 +9,7 @@ absolute_redirect off; location ^~ /api/ { # 不小于 serve 的请求体上限,请求体就不会写入临时文件,也就不会因此输出纯文本的 warn 日志。 client_body_buffer_size 64k; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://$serve; } location ^~ /media/ {