feat: take the client address from trusted proxies

This commit is contained in:
DiheChen committed 2026-11-08 21:43:35 +08:00
1 parent fb481fd5d8
commit 4ed246dc49
11 files changed
+156 -4

No files matched your search

+1
View File
@@ -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="" # 进入管理后台需要的凭证, 留空则禁用后台
+1
View File
@@ -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"
+2
View File
@@ -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 }}
+2
View File
@@ -1,5 +1,7 @@
domain: ""
allowedOrigins: ""
# 逗号分隔的 CIDR:只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信,用于按客户端 IP 限流。
trustedProxies: ""
logLevel: info
otlpEndpoint: ""
database:
+7 -1
View File
@@ -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:
+43
View File
@@ -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) })
}
+47
View File
@@ -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)
}
})
}
+27 -2
View File
@@ -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
+23
View File
@@ -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)
}
}
}
+2 -1
View File
@@ -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{
@@ -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/ {