fix(runtime): deny anonymous callers on /actions and /automation (#5519) - #5569
Merged
Merged
Conversation
The dispatcher's `/actions/*` and `/automation/*` routes carried no anonymity check, while `@objectstack/rest`'s `/data` and the dispatcher's own `/meta`, `/ai` and `/security` have denied anonymous callers 401 UNAUTHENTICATED since #3963 made that a platform promise (the `api.requireAuth` opt-out is a tombstone). Two registration paths, one gated. `/actions` was the expensive half: a script action's body runs with `isSystem: true` forced on (buildActionExecutionContext), so an unauthenticated POST bought an RLS/FLS-bypassing SYSTEM write. The only gate ahead of it was ADR-0066 D4's requiredPermissions, which allows every action declaring none. Measured on a real showcase boot before the fix, with no credential: POST /actions/showcase_task/showcase_mark_done/:id -> 200 {ok:true} POST /automation/showcase_reassign_wizard/trigger -> 200 {runId: run_...} GET /automation -> 200 (full inventory) DELETE /automation/showcase_inquiry_janitor -> 200 {deleted:true} ...while /data on the same process answered 401. Both domains now call the shared `shouldDenyAnonymous` decision before anything dispatches, in the same envelope every other seam returns. Finer authorization is unchanged below the floor. Internal dispatch paths never enter these HTTP handlers (MCP run_action, the declarative endpoint executor, engine-internal triggers), and `isSystem` contexts pass untouched. Tests: a handler-level file pinning the decision and a real-socket integration file pinning that the MOUNTED routes reach the gated handler. Collateral in existing suites was triaged per case: route-behaviour tests that were only incidentally anonymous gained a session; the four whose SUBJECT was the anonymous path were replaced rather than re-spelled, because their assertions would otherwise have read off a call that never happens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 5, 2026
baozhoutao
marked this pull request as ready for review
August 5, 2026 18:50
baozhoutao
enabled auto-merge
August 5, 2026 18:50
This was referenced Aug 5, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5519
前提复核(先证后改,rule 6)
分诊席 15:54Z 核过一次;我在动手时刻的
origin/main(9894a72) 上重新复现,结论:前提成立,且比原单记录的更广。复现方式是真机等价的 harness:
bootStack(showcaseStack, { automation: true, extraPlugins: [ConnectorRest/OpenApi/Mcp] })—— 真 kernel、真 plugin-auth/plugin-security、真 Hono server、真端口、真 socket。全程不带任何凭证:POST /actions/showcase_task/showcase_mark_done/:id{"ok":true,"id":"1XIijpV1PUjdN7ZB"}ctx.api.object('showcase_task').update({done:true,progress:100})真的执行了(isSystem:true提权)POST /automation/showcase_reassign_wizard/trigger{"status":"paused","runId":"run_7de908b7-..."}GET /automationDELETE /automation/showcase_inquiry_janitor{"deleted":true}GET /data/showcase_task最后一行是本单判 p0 的核心:同一个进程里两套注册路径,只有 rest 那套设了门。
门的落点
两处,均只在
packages/runtime/**,复用@objectstack/core的shouldDenyAnonymous(本体语义不改):packages/runtime/src/domains/actions.ts—handleActionsRequest的第一条语句,在 405 之前。匿名调用者只得到 auth 基线,不泄露路由形状,与/data一致。packages/runtime/src/domains/automation.ts—handleAutomationRequest顶部,整域而非逐路由,且在isServiceServeable探测之前(否则 501 与 401 的差异会泄露该部署是否挂了 automation)。信封与
domains/ai.ts:127、domains/meta.ts:59、domains/security.ts:92完全一致:401 +UNAUTHENTICATED+Authentication is required to access this endpoint.requiredPermissions(ADR-0066 D4)、ai.exposed、ADR-0104 参数契约语义一动不动,只是排在匿名门之后。内部路径测绘(边界 1:内部/系统派发不许破)
测绘方法是枚举
handleActionsRequest/handleAutomationRequest的全部调用者,不是读注释:结论:这两个 handler 是纯 HTTP seam,没有任何内部派发路径经过它们,逐条钉住:
run_actionaction-execution.invokeBusinessAction(domains/mcp.ts直接接线)ai.exposed+requiredPermissionsbuildAutomationContext+IAutomationService.execute,挂在 transport 的 fallback seam,dispatch()刻意看不到(#5090)authRequired门(endpoint-policy.ts②),authRequired: false的公开 endpoint 仍然公开isSystem: trueisSystem永远不可由 wire 设置)上述每一条都有对应用例(见下),不是口头断言。
反向验证(方向先判后跑)
预判:摘掉门后,匿名用例转红,收到的状态码应为修复前的答案(已派发的 200、非 POST 的 405、gated action 的 403、无 automation 服务的 501);已认证/系统/
requiredPermissions/内部路径用例两种情况下都绿。实跑:
12 failed | 13 passed,红的收到 200 / 405 / 403 / 501 —— 与预判一致。7 failed | 2 passed,7 条全部收到 200 —— 每一条已挂载的路由在修复前都真的为匿名调用者派发了。一处偏差,如实申报:第一版 object-less(
global)用例复用了crm_contact上的声明,摘门后收到 404(ADR-0110 D3 的「无声明」出口)而非 200 —— 也就是说它的executeAction 未被调用断言是因为本来就不会派发而通过的,即 #5046 记录的「verdict 1 -> 0,断言因空而绿」。按 fixture 三分法这属于 replace wholesale:换成真正 object-less 的独立声明,并补一条已认证对照用例证明该 URL 确实会派发。改后摘门收到 200,断言才真正有牙。测试
新增两个文件,分层是刻意的:
domains/anonymous-gate-actions-automation.test.ts(25 例)—— 钉判定:匿名(两种 ec 形状)/ 已认证 / 系统 /requiredPermissions三态 / OPTIONS 预检不被吞 / 内部路径三条。dispatcher-plugin.anonymous-gate.integration.test.ts(9 例)—— 钉接线,LiteKernel + 真 HonoServer + 真 dispatcher plugin + 真 socket。这一层不可省:门在 domain handler 里,但路由是dispatcher-plugin.ts直接挂到 host server 上的另一条注册路径;handler 级用例无法回答「已挂载的路由是否走到门」。AGENTS.md 对此有明文,[17.0.0-rc.0] Global actions unreachable: registered under key 'global', REST fallback probes '*' — and handler failures return HTTP 200 {success:true,data:{success:false}} #3913 就是站着的反例(域内正确、wire 上够不到)。既有用例的三分法处置(52 例受影响,全部逐条判,无批量重拼):
{ request: {} }是能编译的最小上下文)。给它们一个 session,让每个文件继续测自己名字上的东西。#2701的「匿名 ->systemprincipal」在 HTTP 上已不可达,若只改期望值,断言会读一个从未发生的调用的undefined—— 空绿,比红更糟。改为:systemprincipal 形状用自调用isSystem上下文钉住(这是唯一仍能无userId到达 body 的形态),匿名那半则明写成 401 +executeAction未被调用。ADR-0066 D4那条 403 -> 401 是本次唯一可见的顺序变化,注释写明了原因(旧的 403 描述了错误的问题,且暗示 session 存在)。消费半径扫描(不按被改的包扫):枚举
/actions、/automation的全部 caller 与 fixture ——实跑记录
改动前基线已单独确认:把门改成
if (false)后,受影响的 6 个既有文件371 passed (371)—— 52 例失败全部归因于本次改动,main上无既有红。行为变化(升级须知,已写进 changeset)
之前拿到 200(或 gated action 的 403、405、501)的未认证调用,现在一律 401。若某部署依赖匿名调用 action / flow,受支持的替代是
authRequired: false的声明式 endpoint、public-form grant 或 share-link token —— 而不是匿名/actionsPOST。自限
packages/runtime/**;未触碰packages/rest/**(fix(rest): 缺表错误只在「缺的正是所请求对象」时才判为 unknown object (#5462) #5530 在飞)与packages/spec/**。shouldDenyAnonymous本体未改,只复用。content/docs/releases/。changeset:@objectstack/runtimepatch。给 review 的一个取舍(不阻塞本 PR)
packages/qa/dogfood/test/authz-conformance.matrix.ts把showcase-anonymous-deny-surfaces.dogfood.test.ts列为 #2567「匿名姿态跨面统一」这条主张的 proof 工件,而本单恰恰是这两个面破了该主张。真机证据我已取得(见上表),但按派发的文件面自限没有落成常驻用例。本 PR 用 runtime 内的真 socket 集成用例满足了「boot the real composition」的要求;是否再把/actions+/automation补进那个 proof 工件,建议单独立单处理。🤖 Generated with Claude Code
https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh
Generated by Claude Code