diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index fe94cd7..84a18fd 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -65,6 +65,8 @@ type call struct { ticket string // 票据是常驻房间的 API Key。 bot bool + // 发出请求的客户端地址,按来源限流时使用。 + clientIP string } type handlerFunc func(ctx context.Context, c call) (any, error) @@ -180,7 +182,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)} 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..42a36a5 --- /dev/null +++ b/internal/adapter/in/api/clientip.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http" + "net/netip" + "strings" +) + +// 只有对端是回环、私有网段或链路本地地址的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理, +// 第一个不受信任的地址就是客户端;全部受信任时取最左端的地址。受信任的代理只会写入合法的地址, +// 遇到不合法的值说明已经到了客户端自己填写的部分,此时改用对端地址。 +func clientIP(r *http.Request) string { + peer, err := netip.ParseAddrPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + addr := peer.Addr().Unmap() + if !trustedProxy(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 || !trustedProxy(hop) { + return hop.String() + } + } + return addr.String() +} + +func trustedProxy(addr netip.Addr) bool { + return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() +} diff --git a/internal/adapter/in/api/clientip_test.go b/internal/adapter/in/api/clientip_test.go new file mode 100644 index 0000000..510fc8a --- /dev/null +++ b/internal/adapter/in/api/clientip_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/http/httptest" + "testing" +) + +func TestClientIP(t *testing.T) { + 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"}, "203.0.113.7"}, + {"IPv6 回环对端同样可信", "[::1]:4321", []string{"203.0.113.7"}, "203.0.113.7"}, + {"链路本地对端同样可信", "[fe80::1]:4321", []string{"203.0.113.7"}, "203.0.113.7"}, + {"链式代理跳过右端的私有地址", "127.0.0.1:4321", []string{"203.0.113.7, 10.42.0.1, 192.168.1.2"}, "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{"192.168.1.5, 10.42.0.1"}, "192.168.1.5"}, + {"没有 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"}, + {"IPv4 映射的 IPv6 私有跳按 IPv4 处理", "10.42.0.153:4321", []string{"203.0.113.7, ::ffff:10.42.0.1"}, "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); got != tc.want { + t.Errorf("clientIP = %q,期望 %q", got, tc.want) + } + }) + } +} 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/ {