a3e8911deb
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
115 lines
6.2 KiB
JavaScript
115 lines
6.2 KiB
JavaScript
/**
|
||
* 导航鉴权绕过修复 · 未授权回归测试
|
||
*
|
||
* 背景(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)
|
||
})() |