diff --git a/Dockerfile b/Dockerfile index 718bb0b..08d2089 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,12 +31,20 @@ RUN apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update \ python3 python3-pip python3-venv git ca-certificates \ && rm -rf /var/lib/apt/lists/* +# 打补丁:上游 duckduckgo_web 引擎抓取首页链接时把 timeout 硬编码成 2 秒 +# (searx/engines/duckduckgo_web.py 的 _fetch_first_page_link)。经代理出网时这 +# 段握手常需 2~3 秒,于是必然超时 -> 连续失败 -> 引擎被挂起,表现为「秒回 0 条 +# 结果」。放宽到 8 秒。上游改动导致下面 test/grep 失配时构建会失败,属预期。 RUN git clone --depth 1 https://github.com/searxng/searxng.git /src/searxng \ && python3 -m venv /opt/searxng-venv \ && /opt/searxng-venv/bin/pip install --upgrade pip setuptools wheel msgspec \ && cd /src/searxng \ && /opt/searxng-venv/bin/pip install --no-cache-dir -r requirements.txt \ && /opt/searxng-venv/bin/pip install --no-cache-dir --no-build-isolation . \ + && DDG_WEB=/opt/searxng-venv/lib/python3.11/site-packages/searx/engines/duckduckgo_web.py \ + && test "$(grep -c '^ timeout=2,$' $DDG_WEB)" = "1" \ + && sed -i 's/^ timeout=2,$/ timeout=8,/' $DDG_WEB \ + && grep -q '^ timeout=8,$' $DDG_WEB \ && rm -rf /src/searxng # ── Stage 3: browser extension packages ── diff --git a/admin-server/gateway.go b/admin-server/gateway.go index 0878bf4..2ddc355 100644 --- a/admin-server/gateway.go +++ b/admin-server/gateway.go @@ -24,7 +24,14 @@ type Route struct { MaxBodyMB int `yaml:"maxBodyMB,omitempty" json:"maxBodyMB,omitempty"` BypassPaths []string `yaml:"bypassPaths,omitempty" json:"bypassPaths,omitempty"` InjectBeian bool `yaml:"injectBeian,omitempty" json:"injectBeian,omitempty"` - Note string `yaml:"note,omitempty" json:"note,omitempty"` + // AdminGuard 为该 server 块加上「管理接口需登录」防护: + // 只放行 adminPublicPaths(登录/登出/校验等),其余 /api/、/admin/ 都过 auth_request。 + // + // 2026-10-22 渗透测试:login 域(kind=auth)全站 requireLogin=false 是合理的 + // (它就是登录服务),但它同时把 /api/ 与 /admin/ 原样反代到 3005,而 admin-server + // 当时默认不校验 token,导致管理 API 可被匿名调用。此开关把该域收紧。 + AdminGuard bool `yaml:"adminGuard,omitempty" json:"adminGuard,omitempty"` + Note string `yaml:"note,omitempty" json:"note,omitempty"` NavEnabled bool `yaml:"navEnabled,omitempty" json:"navEnabled,omitempty"` NavSection string `yaml:"navSection,omitempty" json:"navSection,omitempty"` @@ -303,6 +310,37 @@ func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error { b.WriteString(" auth_request /_auth;\n error_page 401 = @login;\n") fmt.Fprintf(b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL) } + // 管理 API 的兜底防护(安全纵深)。 + // + // 2026-10-22 渗透测试:login 域(kind=auth)本身是登录服务,所以全站 + // requireLogin=false 是**合理**的,但它同时也把 /api/、/admin/ 原样 + // 反代到 127.0.0.1:3005,而 admin-server 当年默认不校验 token ⇒ 管理 API + // 可匿名改路由表、拆登录墙、改门户口令。 + // + // admin-server 现在已改「默认拒绝」,这里再加一层:只放行真正需要匿名的 + // 登录端点,其余 /api/、/admin/ 一律要求先拿到管理员会话。 + if r.AdminGuard { + // server 级 auth_request 必须先声明:/api/、/admin/ 的 location 不带 + // auth_request off,正是为了继承它。 + if strings.TrimSpace(cfg.AuthURL) != "" { + b.WriteString(" auth_request /_auth;\n error_page 401 = @login;\n") + } + for _, p := range adminPublicPaths { + writeProxyLocation(b, r, p, true) + } + for _, p := range adminProtectedPaths { + writeProxyLocation(b, r, p, false) + } + if strings.TrimSpace(cfg.AuthURL) != "" { + fmt.Fprintf(b, " location = /_auth { internal; proxy_pass %s; proxy_pass_request_body off;\n", cfg.AuthURL) + b.WriteString(" proxy_set_header Content-Length \"\";\n") + b.WriteString(" proxy_set_header Cookie $http_cookie;\n") + b.WriteString(" proxy_set_header X-Original-URI $request_uri;\n }\n") + } + if strings.TrimSpace(cfg.LoginURL) != "" { + fmt.Fprintf(b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL) + } + } for _, p := range r.BypassPaths { writeProxyLocation(b, r, p, true) } @@ -423,3 +461,27 @@ func RenderGatewayConf(cfg *GatewayConfig) (string, error) { func gatewayFileFor() string { return filepath.Join(gatewayOutDir, "gateway.conf") } + +// adminPublicPaths 是登录域上**必须匿名可达**的端点。 +// +// 这些是登录流程本身:没有它们就没人能登录(拿到会话), +// 属于「先有鸡还是先有蛋」的白名单。除此之外的 /api/、/admin/ 一律要求已登录。 +var adminPublicPaths = []string{ + "/_portal/login", // 提交账号口令 + "/_portal/logout", // 退出 + "/_portal/verify", // 会话校验(登录页/网关内部调用) + "/login", // 登录页本身 + "/portal-page", // 门户首页渲染 + "/favicon.ico", + "/favicon.svg", +} + +// adminProtectedPaths 是需要「已登录」才能访问的管理面。 +// +// 这几条正是渗透测试里被匿名利用的入口:改路由表、改门口气令、 +// 写门户 HTML、读浏览历史、SSRF 探测。 +var adminProtectedPaths = []string{ + "/api/", + "/admin/", + "/admin/api/", +} diff --git a/admin-server/main.go b/admin-server/main.go index e8fccfc..81e0d8c 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -7,6 +7,7 @@ import ( "html" "io" "log" + "net" "net/http" "net/url" "os" @@ -23,13 +24,26 @@ import ( var ( confFile = envOr("DASHY_CONF", "/app/data/conf.yml") adminToken = os.Getenv("NAVI_ADMIN_TOKEN") - // requireTokenForWrite:是否需要额外的 Bearer token。 - // - // /admin/ 在生产里整体位于外层统一认证反向代理(portal-nginx 的 auth_request) - // 之后,再叠一层 token 是重复的,而且会让 admin UI 的写操作静默 401。 - // 所以默认**不校验**;只有当你把容器端口直接暴露给不受信的网段、 - // 而没有外层认证时才设 NAVI_ADMIN_REQUIRE_TOKEN=1。 - requireTokenForWrite = isTruthy(os.Getenv("NAVI_ADMIN_REQUIRE_TOKEN")) +// requireTokenForWrite:是否需要 Bearer token 校验。 +// +// 历史背景:早期假定 /admin/ 与 /api/ 整体位于外层统一认证之后,因此默认不校验, +// 只把 token 当成「直接暴露容器时」的附加开关。2026-10-22 的渗透测试证明这个假设 +// 在线上不成立 —— login 域(kind=auth)的 server 块没有任何 auth_request, +// 管理 API 因此对公网完全开放,可匿名改路由表、拆登录墙、改门户口令。 +// +// 现在的策略是「默认拒绝」: +// - 配了 NAVI_ADMIN_TOKEN → 校验 Bearer token(推荐,线上应始终如此) +// - 未配 token 且显式设 NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1 → 放行(仅限本机调试) +// - 其它情况 → 拒绝 +// 注意 NAVI_ADMIN_REQUIRE_TOKEN 仍被识别,但已不再是「总开关」: +// 即使没设它,只要有 token 就会校验,避免回到裸奔状态。 +requireTokenForWrite = isTruthy(os.Getenv("NAVI_ADMIN_REQUIRE_TOKEN")) +// allowUnauthenticated:在既没有 token、也没有要求 token 时是否放行。 +// 默认 false(拒绝)。只有明确设置 NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1 才放行。 +allowUnauthenticated = isTruthy(os.Getenv("NAVI_ADMIN_ALLOW_UNAUTHENTICATED")) +// allowLoopbackFrameable:是否允许 /api/frameable 探测回环/私网地址。 +// 默认 false(防 SSRF)。仅测试需要打开(httptest 服务器就在 127.0.0.1)。 +allowLoopbackFrameable = isTruthy(os.Getenv("NAVI_ALLOW_LOOPBACK_FRAMEABLE")) // 浏览器插件同步用的令牌(供安装引导页展示,方便用户拷贝) syncToken = os.Getenv("NAVI_SYNC_TOKEN") // history-server 地址(管理后台转发历史记录管理请求用) @@ -277,15 +291,26 @@ func isTruthy(v string) bool { return false } -// requireAdminToken 仅在显式开启时才校验。 +// requireAdminToken 对需要管理权限的接口做鉴权。 +// +// 默认拒绝(fail-closed):以前默认放行,前提是「外层一定有认证」, +// 2026-10-22 渗透测试证明线上 login 域没有 auth_request,管理 API 直接对公网裸奔。 +// +// 判定顺序: +// 1. 配了 adminToken → 必须带对的 Bearer token(不管开关怎么设) +// 2. 没配 token 但显式允许匿名(NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1)→ 放行 +// 3. 其它 → 拒绝 func requireAdminToken(w http.ResponseWriter, r *http.Request) bool { - if !requireTokenForWrite { + if adminToken != "" { + return requireToken(w, r, adminToken) + } + if allowUnauthenticated { return true } - if adminToken == "" { - return true // 开了开关却没配 token,等同于不校验 - } - return requireToken(w, r, adminToken) + jsonErr(w, http.StatusUnauthorized, + "management API is locked: set NAVI_ADMIN_TOKEN (and send Authorization: Bearer ), "+ + "or set NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1 for local-only use") + return false } func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool { @@ -402,6 +427,11 @@ func relayHistory(w http.ResponseWriter, res *http.Response) { // handleHistoryList 列出历史记录(按访问次数降序)。 func handleHistoryList(w http.ResponseWriter, r *http.Request) { + // 2026-10-22 渗透测试:该 GET 原本无鉴权,匿名可导出全部浏览历史 + // (含设备名、内网服务清单)。与 DELETE 分支保持一致,都要管理员令牌。 + if !requireAdminToken(w, r) { + return + } res, err := historyProxy(http.MethodGet, "/", syncToken) if err != nil { jsonErr(w, 502, "history-server unreachable: "+err.Error()) @@ -496,6 +526,14 @@ func frameableVerdict(target, cookie string) frameVerdict { // /extension/install.html 这类站内相对地址天然同源,可直接内嵌 return frameVerdict{Frameable: true, Conclusive: true} } + // 2026-10-22 渗透测试:本接口原本可对任意地址发起请求并回显状态码/响应头, + // 构成未鉴权 SSRF(实测可探测 127.0.0.1:3005 / 3000 / 192.168.2.60:8081 等)。 + // 这里对回环与私网地址直接拒绝:本功能的真实用途是判断**公网应用**能否被 + // iframe 内嵌,内网探测没有正当场景。 + if !allowLoopbackFrameable && isPrivateOrLoopbackURL(u) { + return frameVerdict{Frameable: false, Conclusive: false, + Reason: "不探测回环/内网地址"} + } req, err := http.NewRequest(http.MethodGet, target, nil) if err != nil { return frameVerdict{Frameable: false, Conclusive: true, Reason: "地址无效"} @@ -530,7 +568,43 @@ func frameableVerdict(target, cookie string) frameVerdict { return frameVerdict{Frameable: true, Conclusive: true} } +// isPrivateOrLoopbackURL 判断目标是否指向回环/链路本地/私网地址。 +// +// 用于 SSRF 防护:/api/frameable 只应用来判定**公网应用**是否可被 iframe 内嵌, +// 没有探测内网的必要。除直接 IP 字面量外,也对解析后的地址做同样的检查, +// 防止用域名绕过(如 localtest.me 之类指向 127.0.0.1 的域名)。 +func isPrivateOrLoopbackURL(u *url.URL) bool { + host := u.Hostname() + if host == "" { + return true + } + // 先看字面量(含 IPv6 字面量),命中即拒 + if ip := net.ParseIP(host); ip != nil { + return isPrivateOrLoopbackIP(ip) + } + // 再把域名解析一遍:解析到私网地址同样拒绝 + ips, err := net.LookupIP(host) + if err != nil || len(ips) == 0 { + return false // 解析不了就交给后续请求自己失败 + } + for _, ip := range ips { + if isPrivateOrLoopbackIP(ip) { + return true + } + } + return false +} + +func isPrivateOrLoopbackIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() +} + func handleFrameable(w http.ResponseWriter, r *http.Request) { + // 该接口会由服务端主动发起请求,必须限管理员:否则就是公开 SSRF 探针。 + if !requireAdminToken(w, r) { + return + } target := r.URL.Query().Get("url") if target == "" { jsonErr(w, 400, "缺少 url 参数") @@ -806,18 +880,23 @@ func handleDeleteItem(w http.ResponseWriter, r *http.Request) { // handleSyncInfo 把浏览器插件需要的接入信息给已登录用户。 // -// 为何可以在这里返回令牌:/admin/ 整体在门户登录墙之内; -// 而 /api/history/sync 是被门户放行的(否则插件无法上报), -// 所以这个 token 仍然是有意义的凭据,并且不能挂在 /api/ 下暴露。 +// 2026-10-22 渗透测试:该接口在公网匿名可达,且**明文回显 syncToken**, +// 使任何人可伪造/读取全部浏览历史。现在: +// - 必须写权限鉴权(requireAdminToken); +// - 不再回显令牌本体,只告诉调用方「是否已配置」,需要时从环境变量自取。 func handleSyncInfo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonErr(w, 405, "method not allowed") return } + if !requireAdminToken(w, r) { + return + } jsonResp(w, map[string]any{ - "syncToken": syncToken, + // syncToken 本体不再回显:泄露后等同于浏览器历史的全读写权。 "hasToken": syncToken != "", - "requireAdminToken": requireTokenForWrite, + "requireAdminToken": adminToken != "", + "tokenHint": "sync token 不再回显;需要时从部署环境变量 NAVI_SYNC_TOKEN 读取", }) } diff --git a/admin-server/main_test_setup_test.go b/admin-server/main_test_setup_test.go new file mode 100644 index 0000000..91d9725 --- /dev/null +++ b/admin-server/main_test_setup_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + "testing" +) + +// TestMain 统一设置测试期的安全开关。 +// +// 背景:2026-10-22 的渗透测试后,管理 API 改成「默认拒绝」, +// /api/frameable 也默认拒绝探测回环地址。这两条都是给线上用的正确默认值, +// 但会让测试(在 127.0.0.1 的 httptest 服务器上跑、且不带头部的请求)全部 401。 +// +// 因此测试进程显式打开这两个仅用于本地调试的开关: +// - NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1:无 token 时放行,便于直接测 handler +// - NAVI_ALLOW_LOOPBACK_FRAMEABLE=1:允许探测 127.0.0.1 的 httptest 服务器 +// +// 注意这里同时把 NAVI_ADMIN_TOKEN 清空,确保测的是「无 token」这条路径; +// 生产部署必须配置真实 token,绝不设这两个开关。 +func TestMain(m *testing.M) { + _ = os.Unsetenv("NAVI_ADMIN_TOKEN") + _ = os.Unsetenv("NAVI_ADMIN_REQUIRE_TOKEN") + _ = os.Setenv("NAVI_ADMIN_ALLOW_UNAUTHENTICATED", "1") + _ = os.Setenv("NAVI_ALLOW_LOOPBACK_FRAMEABLE", "1") + + // 变量在包初始化时已从环境读取,这里同步覆盖一次,确保生效。 + adminToken = "" + requireTokenForWrite = false + allowUnauthenticated = true + allowLoopbackFrameable = true + + os.Exit(m.Run()) +} diff --git a/admin-server/security_test.go b/admin-server/security_test.go new file mode 100644 index 0000000..04febdc --- /dev/null +++ b/admin-server/security_test.go @@ -0,0 +1,301 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// 本文件是 2026-10-22 渗透测试报告的回归护栏。 +// +// 被审计出的核心问题:管理 API 的鉴权是「默认放行」—— +// requireAdminToken 在 NAVI_ADMIN_REQUIRE_TOKEN 未设置时直接 return true。 +// 而线上 login 域(kind=auth)的外层 server 块没有任何 auth_request, +// 于是全部管理接口(改路由表、改门口气令、写门户 HTML、读浏览历史)对公网裸奔。 +// +// 下面这些用例把「默认拒绝」钉死:任何把默认值改回放行的改动都会让它们失败。 + +// withSecurityDefaults 临时恢复「生产默认」(无 token、不允许匿名、不允许探测回环)。 +func withSecurityDefaults(t *testing.T) { + t.Helper() + oldAdmin, oldAllow, oldLoop := adminToken, allowUnauthenticated, allowLoopbackFrameable + adminToken = "" + allowUnauthenticated = false + allowLoopbackFrameable = false + t.Cleanup(func() { + adminToken, allowUnauthenticated, allowLoopbackFrameable = oldAdmin, oldAllow, oldLoop + }) +} + +// TestAdminAPIFailsClosedByDefault 钉死最关键的回归点: +// 未配置 NAVI_ADMIN_TOKEN 时,管理接口必须拒绝,不能放行。 +func TestAdminAPIFailsClosedByDefault(t *testing.T) { + withSecurityDefaults(t) + + rec := httptest.NewRecorder() + if requireAdminToken(rec, httptest.NewRequest(http.MethodPost, "/api/gateway/routes", nil)) { + t.Fatal("未配置 token 时 requireAdminToken 必须返回 false(fail-closed)," + + "否则管理 API 会对公网裸奔") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("拒绝时应返回 401,实际 %d", rec.Code) + } +} + +// TestAdminAPIRejectsWithoutTokenEvenIfFlagUnset 覆盖原缺陷的准确形态: +// 即使 NAVI_ADMIN_REQUIRE_TOKEN 没设置,只要配了 token 就必须校验。 +func TestAdminAPIRejectsWithoutTokenEvenIfFlagUnset(t *testing.T) { + withSecurityDefaults(t) + adminToken = "expected-secret" + + // 无 Authorization 头 + rec := httptest.NewRecorder() + if requireAdminToken(rec, httptest.NewRequest(http.MethodGet, "/api/history", nil)) { + t.Fatal("配了 token 就必须校验,不能因为开关未设置而放行") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("应返回 401,实际 %d", rec.Code) + } + + // 错误 token + req := httptest.NewRequest(http.MethodGet, "/api/history", nil) + req.Header.Set("Authorization", "Bearer wrong") + rec = httptest.NewRecorder() + if requireAdminToken(rec, req) { + t.Fatal("错误 token 必须被拒绝") + } + + // 正确 token + req = httptest.NewRequest(http.MethodGet, "/api/history", nil) + req.Header.Set("Authorization", "Bearer expected-secret") + rec = httptest.NewRecorder() + if !requireAdminToken(rec, req) { + t.Fatalf("正确 token 应放行,实际 %d: %s", rec.Code, rec.Body.String()) + } +} + +// TestSyncInfoDoesNotLeakToken 钉死 §2① 的令牌泄露: +// /api/sync-info 不得回显 syncToken 本体。 +func TestSyncInfoDoesNotLeakToken(t *testing.T) { + old := syncToken + syncToken = "0499c44917c9c0396167d7f9192c2a7597c8d160e0ba42db" + t.Cleanup(func() { syncToken = old }) + + allowUnauthenticated = true // 放行鉴权,专测「即使能进去也不该回显令牌」 + t.Cleanup(func() { allowUnauthenticated = false }) + + rec := httptest.NewRecorder() + handleSyncInfo(rec, httptest.NewRequest(http.MethodGet, "/api/sync-info", nil)) + + body := rec.Body.String() + if strings.Contains(body, syncToken) { + t.Fatalf("sync-info 不得回显 syncToken 本体,实际响应: %s", body) + } + var got map[string]any + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("响应不是合法 JSON: %v", err) + } + if _, exists := got["syncToken"]; exists { + t.Fatal("响应里不应再有 syncToken 字段") + } +} + +// TestSyncInfoRequiresAdmin 钉死:sync-info 自身也必须鉴权。 +func TestSyncInfoRequiresAdmin(t *testing.T) { + withSecurityDefaults(t) + rec := httptest.NewRecorder() + handleSyncInfo(rec, httptest.NewRequest(http.MethodGet, "/api/sync-info", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("未鉴权访问 sync-info 应 401,实际 %d", rec.Code) + } +} + +// TestHistoryListRequiresAdmin 钉死 §4:浏览历史 GET 不得匿名可读。 +func TestHistoryListRequiresAdmin(t *testing.T) { + withSecurityDefaults(t) + rec := httptest.NewRecorder() + handleHistoryList(rec, httptest.NewRequest(http.MethodGet, "/api/history", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("未鉴权读取浏览历史应 401,实际 %d", rec.Code) + } +} + +// TestFrameableRequiresAdmin 钉死 §3:SSRF 探测接口必须鉴权。 +func TestFrameableRequiresAdmin(t *testing.T) { + withSecurityDefaults(t) + req := httptest.NewRequest(http.MethodGet, "/api/frameable?url=http://example.com/", nil) + rec := httptest.NewRecorder() + handleFrameable(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("未鉴权调用 frameable 应 401,实际 %d", rec.Code) + } +} + +// TestFrameableBlocksInternalTargets 钉死 §3 的内网探测能力: +// 即使管理员在用,也不允许把本服务当成内网扫描器。 +func TestFrameableBlocksInternalTargets(t *testing.T) { + withSecurityDefaults(t) + allowUnauthenticated = true + + targets := []string{ + "http://127.0.0.1:3005/api/schema", + "http://127.0.0.1:3000/", + "http://localhost:6379/", + "http://192.168.2.60:8081/", + "http://192.168.2.30:80/", + "http://10.0.0.1/", + "http://172.16.0.1/", + "http://169.254.169.254/latest/meta-data/", // 云元数据,经典 SSRF 目标 + "http://[::1]:3005/", + } + for _, target := range targets { + got := frameableVerdict(target, "") + if got.Conclusive { + t.Errorf("%s: 内网/回环目标应被判为不可确定(拒绝探测),实际 %+v", target, got) + } + if !strings.Contains(got.Reason, "回环") && !strings.Contains(got.Reason, "内网") { + t.Errorf("%s: 拒绝原因应说明是内网地址,实际 %q", target, got.Reason) + } + } +} + +// TestIsPrivateOrLoopbackURL 单测地址分类,覆盖用域名指向内网的绕过方式。 +func TestIsPrivateOrLoopbackURL(t *testing.T) { + internal := []string{ + "http://127.0.0.1/", "http://127.0.0.1:8080/x", "http://localhost/", + "http://0.0.0.0/", "http://[::1]/", "http://10.1.2.3/", + "http://192.168.2.106/", "http://172.20.0.1/", "http://169.254.1.1/", + "http://localhost.localdomain/", + } + for _, raw := range internal { + u := mustParseURL(t, raw) + if !isPrivateOrLoopbackURL(u) { + t.Errorf("%s 应被判为内网/回环", raw) + } + } + // 公网字面量(不做网络解析,避免测试依赖 DNS) + u := mustParseURL(t, "http://8.8.8.8/") + if isPrivateOrLoopbackURL(u) { + t.Error("8.8.8.8 不应被判为内网") + } +} + +// mustParseURL 是测试辅助:解析失败直接 Fatal。 +func mustParseURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("解析 %q 失败: %v", raw, err) + } + return u +} + +// TestAuthKindWithAdminGuardProtectsAPI 钉死部署层防护: +// login 域一旦开启 AdminGuard,/api/ 与 /admin/ 必须经过 auth_request, +// 而登录流程本身(/_portal/login 等)仍要匿名可达。 +// +// 这正是渗透测试 §2 的根因:auth 域全站 requireLogin=false, +// 而 /api/、/admin/ 被原样反代到 3005,外层没有任何认证。 +func TestAuthKindWithAdminGuardProtectsAPI(t *testing.T) { + out, err := RenderGatewayConf(&GatewayConfig{ + ListenPort: 8081, + AuthURL: "http://127.0.0.1:3005/_portal/verify", + LoginURL: "https://login.example.com/login", + Routes: []Route{ + {Host: "login.example.com", Kind: "auth", AdminGuard: true}, + }, + }) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + + // 必须声明 auth_request 端点 + if !contains(out, "location = /_auth {") { + t.Fatalf("AdminGuard 应生成 /_auth 内部端点:\n%s", out) + } + + // 取 login.example.com 的 server 块,单独断言 + block := serverBlockFor(t, out, "login.example.com") + + // /api/ 与 /admin/ 必须不被 "auth_request off" 豁免。 + // + // nginx 的 auth_request 是继承式:server 级写一次,所有 location 默认继承; + // 只有显式写 auth_request off 的 location 才豁免。因此「受保护」的正确断言是 + // 「该 location 里没有 auth_request off」,而不是「该 location 里出现 auth_request」。 + if !contains(block, "auth_request /_auth;") { + t.Fatalf("AdminGuard 应在 server 级声明 auth_request /_auth;,实际:\n%s", block) + } + for _, p := range []string{"/api/", "/admin/"} { + loc := locationBlockFor(t, block, p) + if contains(loc, "auth_request off;") { + t.Errorf("location %s 不得用 auth_request off 豁免认证,实际:\n%s", p, loc) + } + } + + // 登录端点必须显式豁免(否则没人能登录) + for _, p := range []string{"/_portal/login", "/_portal/verify"} { + loc := locationBlockFor(t, block, p) + if !contains(loc, "auth_request off;") { + t.Errorf("登录端点 %s 必须 auth_request off,实际:\n%s", p, loc) + } + } +} + +// TestAuthKindWithoutGuardKeepsLegacyBehavior 记录默认行为, +// 避免「默默改变既有部署」——只有显式开启 adminGuard 才收紧。 +func TestAuthKindWithoutGuardKeepsLegacyBehavior(t *testing.T) { + out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{ + {Host: "login.example.com", Kind: "auth"}, + }}) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + if contains(out, "auth_request /_auth;") { + t.Fatalf("未开启 adminGuard 时不应生成 auth_request:\n%s", out) + } +} + +// serverBlockFor 截取指定 host 的 server 块文本。 +func serverBlockFor(t *testing.T, conf, host string) string { + t.Helper() + idx := strings.Index(conf, "server_name "+host+";") + if idx < 0 { + t.Fatalf("找不到 server_name %s:\n%s", host, conf) + } + // 往前回到 "server {" + start := strings.LastIndex(conf[:idx], "server {") + if start < 0 { + start = idx + } + return conf[start:] +} + +// locationBlockFor 截取某个 location 头之后的片段(到下一个 location 或块尾)。 +func locationBlockFor(t *testing.T, block, path string) string { + t.Helper() + markers := []string{ + "location " + path + " {", + "location ^~ " + path + " {", + "location = " + path + " {", + } + for _, m := range markers { + if i := strings.Index(block, m); i >= 0 { + rest := block[i:] + // 截到下一个 location 或 server 结束 + end := len(rest) + for _, stop := range []string{"\n location ", "\n}"} { + if j := strings.Index(rest[len(m):], stop); j >= 0 { + if cand := len(m) + j; cand < end { + end = cand + } + } + } + return rest[:end] + } + } + t.Fatalf("找不到 location %s,块内容:\n%s", path, block) + return "" +} diff --git a/searxng-settings.yml b/searxng-settings.yml index b79e272..316acc4 100644 --- a/searxng-settings.yml +++ b/searxng-settings.yml @@ -19,10 +19,12 @@ ui: redis: url: false outgoing: - # 走代理时握手与回源更慢。默认 3.0 / 4.0 会把一批引擎直接判为超时: - # max_request_timeout 会覆盖单引擎 timeout,360search 配了 20s 仍会在 4s 被砍。 - request_timeout: 8.0 - max_request_timeout: 15.0 + # 走代理时握手与回源更慢。默认 3.0 / 4.0 会把一批引擎直接判为超时, + # 但调太大同样有害:SearXNG 会等所有引擎超时才返回,单个卡住的引擎 + # 就能把整次搜索拖到 request_timeout 那么长(实测 8.0 时出现 19s 长尾)。 + # 4.0/6.0 是折中:长尾收敛到 4s 左右,代价是慢引擎当次少结果。 + request_timeout: 4.0 + max_request_timeout: 6.0 pool_connections: 100 pool_maxsize: 20 # SearXNG curl_cffi 只认这个配置(不走 http_proxy 环境变量) @@ -40,9 +42,19 @@ engines: # 表现为「Bing 结果异常少」。直接指向 cn.bing.com 可绕开重定向。 base_url: https://cn.bing.com - name: duckduckgo + # html 端点(no-JS)实测被 DDG 反爬拦死:10/10 返回挑战页(202 + anomaly), + # 高密度请求后连 duckduckgo.com 主站也进入挑战态。改用下面的 + # duckduckgo web(走 links.duckduckgo.com/d.js JSON API)。 + disabled: true + - name: duckduckgo web + # 自带 firefox TLS 指纹 + default_headers=False,不受 no-JS 挑战页影响。 + # 注意它依赖 Dockerfile 里对 _fetch_first_page_link 超时的补丁(上游硬编码 + # timeout=2,经代理必超时 -> 引擎被挂起 -> 秒回 0 条)。 disabled: false - name: 360search - disabled: false + # 实测单条查询 13~15s,且运行时 timeout=20s(高于全局),是长尾延迟的 + # 主要来源。需要恢复时改回 false 并重启 searxng。 + disabled: true - name: wikipedia disabled: false - name: github