Files
HomeDash/admin-server/security_test.go
HomeDash dea722f51c 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 打补丁(经代理必超时)。
2026-10-22 14:08:06 +08:00

302 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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