假设 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 适配层和存储。

附上源码位置和测试命令。同事问“这个路由不是已经鉴权了吗”时,这份笔记可以直接回答:检查了什么,以及工作区从哪一步开始没有被继续使用。