/** * 导航鉴权绕过修复 · 未授权回归测试 * * 背景(2026-09-30 发现并修复的真实漏洞): * express.static 注册在首页鉴权路由之前,会把 GET / 当作目录请求直接返回 * public/index.html,导致九宫格首页完全绕过 nav_gate。 * 同时 /api/tools、/api/nav_bookmarks/* 也完全没鉴权,可被公网匿名读取甚至篡改。 * * 本脚本对该漏洞做回归:所有"未授权一律拒绝"、所有"公开路径仍公开"、 * 以及"带 token 能正确握手种 cookie"都要成立。 * * 用法(需目标服务已启动): * node dev_test_scripts/integration/test_nav_auth_bypass.js * node dev_test_scripts/integration/test_nav_auth_bypass.js http://localhost:8976 * * 说明:脚本不硬编码任何 token,token 在运行时从 data/short_link.db 里读, * 避免把真实凭据写进仓库(项目规则 13)。 */ const path = require('path') const Database = require('better-sqlite3') const BASE = String(process.argv[2] || 'http://localhost:8976').replace(/\/+$/, '') let passed = 0 let failed = 0 const check = (name, ok, detail) => { if (ok) { passed += 1; console.log(` ✔ ${name}`) } else { failed += 1; console.log(` ✘ ${name}${detail ? ' → ' + detail : ''}`) } } /** 发起请求;cookie 传 'nav_gate=1' 表示已过门禁 */ const req = async (method, urlPath, cookie) => { const headers = {} if (cookie) headers.Cookie = cookie const res = await fetch(BASE + urlPath, { method, headers, redirect: 'manual' }) const text = await res.text().catch(() => '') let json = null try { json = JSON.parse(text) } catch (_) { /* 非 JSON */ } return { status: res.status, text, json, location: res.headers.get('location'), setCookie: res.headers.get('set-cookie') } } const readTokenFromDb = () => { try { const db = new Database(path.join(process.cwd(), 'data', 'short_link.db'), { readonly: true }) const row = db.prepare("SELECT target_url FROM short_links WHERE target_url LIKE '%token=%' ORDER BY id DESC LIMIT 1").get() db.close() if (!row) return '' const m = /[?&]token=([^&]+)/.exec(String(row.target_url || '')) return m ? decodeURIComponent(m[1]) : '' } catch (e) { return '' } } ;(async () => { console.log(`=== 导航鉴权绕过 · 未授权回归测试 ===`) console.log(`目标:${BASE}\n`) // ---------- 1. 修复前被绕过的路径:未授权必须全部拒绝 ---------- console.log('[1] 未授权必须拒绝(修复前这里全部是 200 泄漏)') const root = await req('GET', '/') check('GET / 未授权 → 401', root.status === 401, `实际 ${root.status},body ${root.text.slice(0, 60)}`) const idx = await req('GET', '/index.html') check('GET /index.html 未授权 → 401', idx.status === 401, `实际 ${idx.status}`) const toolsDir = await req('GET', '/tools/') check('GET /tools/ 未授权 → 401', toolsDir.status === 401, `实际 ${toolsDir.status}`) const apiTools = await req('GET', '/api/tools') check('GET /api/tools 未授权 → 401', apiTools.status === 401, `实际 ${apiTools.status},${apiTools.text.slice(0, 60)}`) const nbState = await req('GET', '/api/nav_bookmarks/state') check('GET /api/nav_bookmarks/state 未授权 → 401', nbState.status === 401, `实际 ${nbState.status}`) const nbCats = await req('GET', '/api/nav_bookmarks/categories') check('GET /api/nav_bookmarks/categories 未授权 → 401', nbCats.status === 401, `实际 ${nbCats.status}`) const nbBooks = await req('GET', '/api/nav_bookmarks/bookmarks') check('GET /api/nav_bookmarks/bookmarks 未授权 → 401', nbBooks.status === 401, `实际 ${nbBooks.status}`) // 写接口必须同样被挡住(用不存在的 code 做 DELETE,即便漏挡也不会破坏真实数据) const nbDel = await req('DELETE', '/api/nav_bookmarks/categories/__auth_probe_not_exist__') check('DELETE /api/nav_bookmarks/categories/* 未授权 → 401', nbDel.status === 401, `实际 ${nbDel.status}`) // ---------- 2. 公开路径必须保持公开 ---------- console.log('\n[2] 公开路径保持公开(不能误伤)') const shortLink = await req('GET', '/to/__no_such_code__') check('GET /to/<不存在的短码> → 404(而不是 401,说明短链跳转层未被门禁挡)', shortLink.status === 404, `实际 ${shortLink.status},${shortLink.text.slice(0, 40)}`) const legacyShortLink = await req('GET', '/l/__no_such_code__') check('GET /l/<不存在的短码> → 404(旧前缀仍公开可用)', legacyShortLink.status === 404, `实际 ${legacyShortLink.status}`) // ---------- 3. 带 token 的握手必须真正种下 cookie ---------- console.log('\n[3] token 握手(修复前被 static 吞掉、cookie 永远种不下)') const token = readTokenFromDb() if (!token) { console.log(' ⚠ data/short_link.db 里没找到带 token 的短链接,跳过握手测试') } else { const hs = await req('GET', '/?token=' + encodeURIComponent(token)) check('GET /?token=<有效> → 302(进入握手流程,而非被 static 直接返页面)', hs.status === 302, `实际 ${hs.status},body ${hs.text.slice(0, 60)}`) check('握手响应种下 nav_gate cookie', !!(hs.setCookie && hs.setCookie.indexOf('nav_gate=1') >= 0), String(hs.setCookie || '').slice(0, 80)) const bad = await req('GET', '/?token=not_a_valid_jwt') check('GET /?token=<无效> → 401', bad.status === 401, `实际 ${bad.status}`) } // ---------- 4. 已过门禁(带 nav_gate)时必须正常放行 ---------- console.log('\n[4] 带 nav_gate 时必须正常放行') const okRoot = await req('GET', '/', 'nav_gate=1') check('GET / 带门禁 → 200', okRoot.status === 200, `实际 ${okRoot.status}`) check('GET / 带门禁返回的是九宫格首页', okRoot.text.indexOf("Yang's Toolbox") >= 0, okRoot.text.slice(0, 60)) const okTools = await req('GET', '/api/tools', 'nav_gate=1') check('GET /api/tools 带门禁 → 200', okTools.status === 200, `实际 ${okTools.status}`) const okNb = await req('GET', '/api/nav_bookmarks/state', 'nav_gate=1') check('GET /api/nav_bookmarks/state 带门禁 → 200', okNb.status === 200, `实际 ${okNb.status}`) console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`) process.exit(failed === 0 ? 0 : 1) })()