假设 Alice 只加入了工作区 alpha。她请求 /workspaces/alpha/documents/doc-b/,却拿到了 beta 的文档。服务端认出了 Alice,也检查了工作区成员身份,两步都通过了。请求究竟从哪里走偏?
下面用虚构的文档和身份做一个教学案例。运行示例需要 Node.js 22 或更新版本,读代码的方法也适用于其他语言和框架。这次要查的事很具体:成员检查针对哪个工作区,数据查询又被限制在哪个工作区?
先把请求和访问规则放在一起
把方法、路径、身份和预期结果记在同一处:
GET /workspaces/alpha/documents/doc-b/
演示身份:alice,只是 alpha 的成员
存储数据:doc-b 属于 beta
预期:404,响应中没有文档
错误结果:200,返回 Beta release notes
案例采用一条简单规则:工作区成员可以读这个工作区的全部文档,没有单独分享,也没有管理员例外。对于 alpha 的成员,不属于 alpha 的文档与不存在的文档返回相同状态码和正文;如果 URL 直接指向用户未加入的工作区,就在调用文档存储前返回 403。
接下来要追两件事:已认证的用户是否属于 URL 中的工作区,返回的文档是否也属于这个工作区。第一项检查通过后,数据查询仍然需要落实第二项。
排查真实问题时,再补上应用构建或源码版本,确认手头路由与处理那次请求的代码一致。本地复现使用合成数据即可。
先找路由注册,再进处理函数
把 request-flow.mjs 和 request-flow.test.mjs 下载到同一个目录。在编辑器里搜路径中有辨识度的词,比如 workspaces、documents。如果装了 ripgrep,可以这样缩小范围:
rg -n 'workspaces|documents' request-flow.mjs
rg -n 'middleware|runMiddleware|requireWorkspaceMember|findById|findInWorkspace' request-flow.mjs
打开 createApp。路由对象把方法、路径匹配、中间件和处理函数放在了一起:
const route = {
method: 'GET',
pattern: /^\/workspaces\/([a-z0-9-]+)\/documents\/([a-z0-9-]+)\/$/,
middleware: [authenticate, requireWorkspaceMember],
handler: lookup === 'unscoped' ? getDocumentUnsafe : getDocument,
};
继续往下读几行。handle 匹配 new URL(request.url).pathname,检查请求方法,再把两个捕获段分别赋给 workspaceId 和 documentId。这两个值都不取自查询参数。示例路径只接受小写字母、数字和连字符,还要求末尾有上面那条斜杠;这里只实现了教程需要的一条路由。
真实框架里,完整路径可能分散在父路由 /workspaces/:workspaceId 与子路由 /documents/:documentId 中。要把挂载位置一起读到,并确认父级参数怎样传给子级。只搜到处理函数,还不知道调用它之前套了哪些中间件。
按实际执行顺序读中间件
runMiddleware 用 reduceRight 一层层包装处理函数。数组是 [authenticate, requireWorkspaceMember],所以先执行身份认证,再检查成员身份;每一层调用 next() 后,才会进入下一层,最后到达处理函数。return next() 又把结果逐层交回 handle;中间抛出的 HttpError 会由它的 catch 转成响应。
Request → 匹配路径和方法 → authenticate
→ requireWorkspaceMember → 查询文档 → JSON Response
↘ 抛错 → handle 的 catch → 错误 Response
authenticate 用请求的 Authorization 头查一张固定映射表,再把结果放进 ctx.user。demo-alice、demo-bob 是公开的测试输入,用来代替已验证的会话身份。借此可以追到案例里的用户来源;真实系统怎么验证会话或令牌,仍要进入对应实现去读。
接着看成员检查:
async function requireWorkspaceMember(ctx, next) {
const { workspaceId } = ctx.params;
const allowed = ctx.user.workspaceIds.includes(workspaceId);
ctx.trace.push(`membership:${workspaceId}:${allowed ? 'allowed' : 'denied'}`);
if (!allowed) throw new HttpError(403, 'Forbidden');
return next();
}
Alice 请求的路径带着 alpha,因此 allowed 为真。这个函数既没检查 documentId,也没读取文档。笔记应当准确写成“Alice 可以进入 alpha 的路由”。只记“已鉴权”,很容易把后面的文档归属也当成已经查过了。
顺手记下请求会在哪里停住。演示身份缺失或未知时,返回 401,不会进入成员检查;用户不是目标工作区的成员时,返回 403,不会进入文档处理函数。如果某个中间件先调用 next()、之后才决定是否放行,处理函数可能早已执行,所以 next() 的位置也得读。
分开记录用户、请求的工作区和文档归属
针对这条出错请求,四行就能把容易混淆的值拆开:
| 要追的值 | 来源 | 本次请求中的值 | 接下来去哪里找 |
|---|---|---|---|
| 用户与成员列表 | authenticate 读取演示身份映射 |
Alice,['alpha'] |
requireWorkspaceMember |
| 请求的工作区 | 路由捕获的第一段 | alpha |
成员检查,再到存储调用的实参 |
| 请求的文档 | 路由捕获的第二段 | doc-b |
文档查询 |
| 文档实际归属 | 存储的记录 | beta |
查询条件与响应正文 |
每读到一个来源和使用位置,就在旁边补文件名、行号。换成大项目,这些值可能属于不同包里的不同类型,但仍应分清:URL 表达用户想请求什么,身份数据说明用户加入了哪里,存储记录才说明文档属于哪里。
两个 ID 一起进了处理函数。现在看它们是否一起传给了存储层。
处理函数再短,也要点开数据查询
故意保留错误的处理函数是这样的:
async function getDocumentUnsafe(ctx) {
ctx.trace.push(`lookup:unscoped:${ctx.params.documentId}`);
const document = await ctx.store.findById(ctx.params.documentId);
if (!document) throw new HttpError(404, 'Not found');
return json(200, document);
}
传给存储层的只有 documentId。进入 createStore 里的 findById,能看到条件仅为 document.id === documentId,没有比较工作区,序列化响应之前也没有后续归属检查。前面的中间件准许的是 alpha,这里却可能取出任意工作区的记录。
触发问题不需要并发,也不用伪造身份。Alice 继续用自己的身份和获准访问的 alpha 路径,只把文档 ID 换成案例里已知的 doc-b。检查与读取针对了不同的资源范围。
在真实项目里,光凭函数名或少一个参数,还不能下同样结论:数据访问层可能自己加租户条件,也可能使用带范围的数据库会话,或者依赖数据库行级访问策略。要确认实际选中了哪个实现,以及相关配置。这个例子的查询全在内存谓词里,可以直接读完。
让反例走完整条处理链
在两个下载文件所在的目录运行:
node request-flow.mjs
node --test request-flow.test.mjs
无需安装依赖、开账号或启动服务。makeRequest 构造 Web Request,测试把它传给应用的 handle,再检查返回的 Web Response。example.invalid 只作为内存里解析的 URL;代码不调用 fetch,也不监听端口。
演示程序会打印下面三条响应,每条下面还会列出经过的步骤和存储调用记录:
normal: 200 {"id":"doc-a","workspaceId":"alpha","title":"Alpha release notes"}
reproduce: 200 {"id":"doc-b","workspaceId":"beta","title":"Beta release notes"}
fixed: 404 {"error":"Not found"}
错误请求经过的步骤是:
request:GET -> route:alpha:doc-b -> authenticate:alice -> membership:alpha:allowed -> lookup:unscoped:doc-b -> response:200
trace 数组是专门加在教学代码里的记录,不会放进 HTTP 响应。测试还检查了真正返回的文档,以及存储层是否收到 findById('doc-b')。把这三处放在一起,就能从“经过哪些分支”一直核对到“最终拿回什么数据”。
名字以 reproduce: 开头的测试,在旧实现返回 beta 文档时才通过。它通过说明错误确实被复现了。另一项测试则要求修正后的实现对同一请求返回 404,两者的目的要分清。
把已经检查过的工作区带进查询
修正后的处理函数保留了两个路由值:
async function getDocument(ctx) {
const { workspaceId, documentId } = ctx.params;
ctx.trace.push(`lookup:scoped:${workspaceId}:${documentId}`);
const document = await ctx.store.findInWorkspace(workspaceId, documentId);
if (!document) throw new HttpError(404, 'Not found');
return json(200, document);
}
findInWorkspace 同时要求 document.workspaceId === workspaceId 和 document.id === documentId。成员中间件刚刚检查过的,正是这个 workspaceId。示例没有在两步之间修改 ctx.params,文档与成员关系在运行期间也保持不变。
请求 /workspaces/alpha/documents/doc-b/ 时,现在查询不到匹配记录。文档不存在,或者属于其他工作区,都会得到 404 和 {"error":"Not found"}。相同的状态码与正文不会直接告诉调用方是哪一种情况;测试没有验证耗时等其他侧信道。
先全局读取、再检查 document.workspaceId,也能阻止这一次响应泄露。把工作区限制放进查询,则能在调用处明确读取范围,并让当前处理函数拿不到其他工作区的记录。不过,本例的 Array.find 仍会查看记录字段来判断是否匹配;调用记录展示的是查询参数,不能用来推算数据库 I/O 或证明物理隔离。
换成 SQL 或 ORM 后,还要看实际查询条件、参数绑定,并用真实数据访问层验证。成员资格撤销、文档移动、跨工作区分享和其他路由,都需要按各自规则继续检查。
同时验证正常访问和提前结束的位置
测试文件共有十项测试:一项确认旧错误可以复现,九项检查修正后的应用。有些测试会遍历多个输入:
| 请求或条件 | 预期响应 | 还要检查什么 |
|---|---|---|
Alice 读 alpha/doc-a;Bob 读 beta/doc-b |
200,正确文档 |
两个 ID 都传入 findInWorkspace |
Alice 读 alpha/doc-b |
404,没有文档 |
查询仍被限制在 alpha |
Alice 读 alpha/missing |
与上一行状态码、正文相同 | 没有匹配的文档 |
| 演示身份缺失或未知 | 401 |
不进入成员检查,不调用存储层 |
Alice 读 beta/doc-b |
403 |
不调用存储层 |
Alice 读 alpha/doc-b?workspaceId=beta |
404 |
查询参数不能替换路由里的工作区 |
| 未知路径;已知路径但方法错误 | 404;带 Allow: GET 的 405 |
不调用存储层 |
| 存储层抛出意外异常 | 500,通用错误正文 |
异常走到统一响应处理 |
表里的缩写路径展开后是 /workspaces/{workspaceId}/documents/{documentId}/,查询串接在末尾斜杠之后。两次正常读取,能挡住“把所有请求都拒绝掉”的假修复;没有存储调用的断言,则能发现先读文档、最后才拒绝的实现。只看最终状态码,会漏掉这些区别。
这些测试验证的是这条路由在进程内的请求处理、中间件顺序、查询参数和 JSON 响应,没有覆盖 HTTP 传输、反向代理、真实身份验证或数据库。应用到自己的项目时,从真正接收 HTTP 的适配层开始,确认它选择的就是已经追过的处理函数和中间件。
留下别人能复查的阅读笔记
围绕那条请求,把值经过的关键位置写下来:
触发:Alice + GET /workspaces/alpha/documents/doc-b/
路由:createApp 捕获 alpha 与 doc-b。
成员检查:requireWorkspaceMember 允许 Alice 访问 alpha。
旧查询:getDocumentUnsafe 只把 doc-b 传给 findById。
实际结果:200,返回记录的 workspaceId 是 beta。
修复:getDocument 把 alpha 和 doc-b 一起传给 findInWorkspace。
回归:同一请求变为 404;两条合法成员请求仍返回 200。
验证范围:内存请求链;未验证真实 HTTP 适配层和存储。
附上源码位置和测试命令。同事问“这个路由不是已经鉴权了吗”时,这份笔记可以直接回答:检查了什么,以及工作区从哪一步开始没有被继续使用。