Skip to content

dispatcher 的 /auth 域内置 mock 登录:无 auth 服务时任意邮箱+任意密码都返回 200 + 伪造 session token —— 在发行装配里,不是 dev-only #4113

Closed

Description

@os-zhuang

按 Prime Directive #10 记录,#4093 A 档准备工作中发现。这条比 #4093 讨论的所有 plugin-dev stub 都严重,因为它不在 dev 插件里,而在 packages/runtime 的 dispatcher 里。

现象

packages/runtime/src/domains/auth.ts:

const authService = await deps.getService(CoreServiceName.enum.auth);
if (authService && typeof authService.handler === 'function') {
    return { handled: true, result: await authService.handler(...) };
}
// 2. Mock fallback for MSW/test environments when no auth service is registered
return mockAuthFallback(normalizedPath, method, body);

mockAuthFallback 对 POST /api/v1/auth/sign-in/email(以及 login / sign-up/email / register):

const id = `mock_${randomUUID()}`;
return { handled: true, response: { status: 200, body: {
  user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, … },
  session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`,
             expiresAt: new Date(Date.now() + 86_400_000).toISOString() },
}}};

密码根本没有被读取。任意邮箱 + 任意密码(或不带密码)→ 200 + 一个看起来完全正常的 user + session token + 24 小时有效期。

触发条件(不是 dev-only)

只要 auth 槽位里没有带 .handler 方法的服务。具体地:

严重性

不是越权:mock 发出的 token 没有任何 session 存储支撑,身份解析走的是 authService.api.getSession()(packages/runtime/src/security/resolve-execution-context.ts:104-127),没有 auth 服务就返回 undefined → 请求保持匿名 → shouldDenyAnonymous 照常拒绝数据访问。所以拿着 mock_token_* 读不到任何数据。

但它是对客户端撒谎,而且是最不该撒谎的那件事:

  1. 客户端(console / SDK / agent)拿到 200 + session token,会认为登录成功;随后每个请求却是匿名的 —— "登录成功了但什么都读不到",而且没有任何一层告诉它为什么。
  2. POST /auth/sign-in/email 用任意密码返回 200,是任何安全扫描/渗透测试都会直接标红的形状,无论后端是否真的放行。
  3. 它违反的正是 ADR-0076 D12 从 降级 analytics shim 把 ExecutionContext 丢在门口 —— /analytics/query 在没装 service-analytics 的装配里不注入 RLS/租户谓词,契约字段 where 也被静默忽略 #3891 学到的那条:"A fallback may degrade features, never security semantics" —— 而且 降级 analytics shim 把 ExecutionContext 丢在门口 —— /analytics/query 在没装 service-analytics 的装配里不注入 RLS/租户谓词,契约字段 where 也被静默忽略 #3891 的对象(analytics shim)只是丢了 RLS 作用域,这一条是伪造认证成功。
  4. 理由写的是"for MSW/test environments",却没有任何环境判据 —— 只判"auth 槽位空不空",然后跟着发行版一起进生产。

依赖面:查下来没有真实消费者

  • 本仓:只有两处测试它自己的用例(http-dispatcher.test.ts:907、domain-handler-registry.test.ts:528-531,后者断言 token 匹配 /^mock_token_/)。
  • ../objectui:登录相关测试全部在 HTTP 客户端层自己 mock(packages/auth/src/__tests__/createAuthClient.test.ts 等),不经过 dispatcher 的这个 fallback。

也就是说,那句 "MSW/browser-only environments" 的理由,我在两个仓库里都找不到实际依赖它的消费者 —— 只有为它自己写的测试在支撑它。

建议

按 #3891 的先例走:退役这个 mock,空槽答 404。

  • /auth/* 没有 auth 服务时 404,与 analytics 空槽同形 —— 这是诚实信号("没装 auth 插件"),而不是伪造一次成功登录。
  • discovery 已经在正确地报了:routes.auth 按 auth 服务是否注册来广告,空槽时就是 unavailable。目前的矛盾是 discovery 说没有、/auth/* 却答 200 —— 一个 declared ≠ enforced 的反向实例。
  • 那两处测试改成钉住新的诚实行为(404),而不是钉住 mock。
  • 如果真有浏览器纯前端 mock 的需求,那应该是 MSW handler 或显式 opt-in 的 mock 插件(OS_ALLOW_* 级别的显式开关),不是"槽位空了就自动伪造"。

顺带:plugin-dev 的 auth dev stub 在这条修掉之前删也没有意义(它本来就没被 /auth 域调用)。#4093 A 档里的 auth 一项因此挂在这条上,我在那边也标了。

关联:#4093、#4058、#4082、#3891、#3369、#4024、ADR-0076 D12。

Activity

  1. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    结论收敛为:删除,不做开关

    原文给了两个选项(退役 / 或改成显式 opt-in 的 mock 插件)。基于此后落地的东西,第二个选项应当排除,本 issue 的处置就是删掉相关代码:

    一个重要更新:#4143(源自 #4127)已经缩小了它的射程

    原文说它"判据只有 auth 槽位有没有服务"—— 当时更糟。#4143 之前,分支 1 探测的是 authService.handler(request, response),没有任何实现有这个方法(契约声明的是 handleRequest(request)),所以分支 1 永远为假:即使注册了真实 auth 服务,/auth/* 拿到的也是 mock 的 mock_<uuid> session,而不是真实认证。#4143 按契约修好了探测,真实服务现在会赢。

    所以现在 mock 的射程精确地就是"auth 槽位为空",而这恰好是删除正确、且 404 诚实的那种情况 —— discovery 在该情况下本来就报 auth: unavailable 且不广告 routes.auth。

    删除清单(核实于 dd5daac)

    packages/runtime/src/domains/auth.ts 共 141 行,其中约 **95 行(2/3)**是 mock 及其专属助手:

    删除 行 说明
    mockAuthFallback() 90–141 伪造登录本体
    randomUUID() 15–39 只被 mock 用(96、111 两处),删掉后是死代码 —— 一个为铸造假 session id 而存在的 CSPRNG 助手
    文件头注释 3–8 把 mock 描述成本域用途的一部分
    http-dispatcher.test.ts describe('handleAuth mock fallback (MSW/test mode)') 整块 6 条测试
    domain-handler-registry.test.ts 641–646 /auth mock fallback serves sign-up…(断言 token 匹配 /^mock_token_/)

    handleAuthRequest 保留分支 1(真实服务),空槽改为 { handled: false }。删除后该文件约 45 行。

    一处留给维护者的判断:空槽应答用 handled: false → 宿主 404(与 discovery 的 auth: unavailable + 不广告路由一致,同 #4000 后的 /analytics),还是像 /storage、/i18n 那样给显式 501。我倾向 404 —— 路由既没被广告也没被挂载,404 就是那个事实;但 501 在"这个服务端没实现认证"的表达上更明确。

    plugin-dev 的 auth stub 不在清单里 —— #4137 随整张表已经删掉了。


    Generated by Claude Code

  2. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    已由 #4179(5b08389)修复并关闭 —— 删除,不加开关。

    空槽(以及占着槽位但不实现契约 handleRequest 的 wrong-shaped occupant)现在答:

    501 Auth service not available — register @objectstack/plugin-auth to enable authentication
    

    为什么是 501 不是 404:/i18n 是形状上最近的先例(核心能力、dispatcher 自有域、背后可选插件、空槽时 discovery 同样不广告路由),它选的就是 501。路由确实挂着,缺的是背后的实现 —— 这正是 501 陈述的事实,404 会把它描述成"路径不存在",把运维引向一个不存在的路由 bug。而且它保住了 mock 当初唯一正确的那个观察:那段注释说自己存在是为了让登录流程"不要 404" —— 担忧是对的,只是用撒谎回答;501 在不撒谎的前提下回答同一个担忧,并点名补救办法。

    顺带清掉两处 mock 的残留:

    • randomUUID 助手 —— 一个纯粹为铸造 mock session id 而存在的 CSPRNG(注释原话 "these ids feed mock session tokens"),mock 一走即死代码。domains/auth.ts 141 → 81 行。
    • route-envelope 账本的 auth.ts: handBuilt: 4 豁免降为 0。那 4 个 better-auth 形状的 body 是 mock 的、不是桥接的(真实服务返回它自己的 Response,在这个文件里一个 body 都不拼)—— CI 直接以 found 0, declared 4 拦下,棘轮按设计生效。
    • 文档:content/docs/permissions/authentication.mdx 原本有一整节把 6 个伪造端点当特性列表,现已改写为 501 说明 + 两条真实出路(在 HTTP 客户端层 mock,或加载 AuthPlugin —— 它不需要 HTTP 服务器)。

    ADR-0115 收尾更新里"#4113 是那类假实现仅存的地方"一句已同步收口 —— 这一类到此平台级关闭,不再有编造型 fallback 幸存。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions