fix(security): 管理 API 默认拒绝 + 堵住渗透测试报告的 5 个攻击面

2026-10-22 渗透测试报告(report.md)实测确认:统一入口的管理 API 在公网
完全未鉴权,可匿名改路由表、拆登录墙、改门户口令、写门户 HTML、读全部
浏览历史、做内网 SSRF 探测。根因有两层,本次一并修复。

## 根因 1:requireAdminToken 默认放行(代码层)

    if !requireTokenForWrite { return true }

线上没有 NAVI_ADMIN_REQUIRE_TOKEN,于是所有"调了鉴权"的 handler 实际全部
放行——形同虚设。现在改为 fail-closed:

  - 配了 NAVI_ADMIN_TOKEN        → 校验 Bearer token
  - 未配且显式 NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1 → 放行(仅本机调试)
  - 其它                          → 401 拒绝

## 根因 2:login 域没有任何 auth_request(部署层)

kind=auth 的 server 块全站 requireLogin=false(它本身就是登录服务,这点
合理),但 location / 把 /api/、/admin/ 原样反代到 127.0.0.1:3005。两层
都没管 → 管理 API 裸奔。

新增 Route.AdminGuard:开启后 server 级挂 auth_request,只白名单放行登录
流程(/_portal/login|logout|verify、/login、/portal-page、favicon),
其余 /api/、/admin/ 一律要求已登录。

## 同时修掉的 4 个具体问题

1. /api/sync-info 明文回显 syncToken → 不再回显,且要求鉴权。
   泄露该 token 等于浏览器历史的全读写权。
2. /api/history GET 无鉴权 → 与 DELETE 分支一致,要求管理员令牌。
3. /api/frameable 未鉴权 SSRF → 加鉴权 + 内网/回环黑名单。除 IP 字面量外
   还对域名解析结果做校验,防止 localtest.me 之类指向 127.0.0.1 的绕过。
4. dashy 域 bypassPaths 里的 "/api/" 过宽 → 收窄到 "/api/history/"
   (本意只放行浏览记录上报,实际把整个管理 API 暴露了)。

## 回归护栏

新增 admin-server/security_test.go(10 条)与 main_test_setup_test.go。
每条断言都是"改回旧行为就会失败",已用 3 次变异测试验证:
  - 恢复默认放行        → TestAdminAPIFailsClosedByDefault FAIL
  - sync-info 回显 token → TestSyncInfoDoesNotLeakToken FAIL
  - 移除内网黑名单      → TestFrameableBlocksInternalTargets FAIL
  - /api/ 豁免认证      → TestAuthKindWithAdminGuardProtectsAPI FAIL
  - 不给 server 级 auth → 同上 FAIL

测试 56 项全绿。

## 顺带

searxng-settings.yml:停用 360search / ddgw / wikidata / wikipedia
(各自的实测理由见文件内注释),request_timeout 8.0→4.0。
Dockerfile:给 duckduckgo_web 引擎硬编码的 timeout=2 打补丁(经代理必超时)。
This commit is contained in:
HomeDash
2026-10-22 14:08:06 +08:00
parent 96e771d546
commit dea722f51c
6 changed files with 519 additions and 24 deletions

View File

@ -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 ──

View File

@ -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/",
}

View File

@ -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 <token>), "+
"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 读取",
})
}

View File

@ -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())
}

View File

@ -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 ""
}

View File

@ -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