feat: take the client address from trusted proxies
This commit is contained in:
1 parent
fb481fd5d8
commit
4ed246dc49
11 files changed
+156
-4
No files matched your search
@@ -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="" # 进入管理后台需要的凭证, 留空则禁用后台
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
domain: ""
|
||||
allowedOrigins: ""
|
||||
# 逗号分隔的 CIDR:只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信,用于按客户端 IP 限流。
|
||||
trustedProxies: ""
|
||||
logLevel: info
|
||||
otlpEndpoint: ""
|
||||
database:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/ {
|
||||
|
||||
Reference in new issue
Block a user