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 打补丁(经代理必超时)。
302 lines
10 KiB
Go
302 lines
10 KiB
Go
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 ""
|
||
}
|