Repository navigation
Description
环境行:hotcrm@0899b4f + @objectstack 17.0.0-rc.2(平台源码 /home/user/objectstack,dev server:objectstack dev,已装 @objectstack/plugin-auth,登录/鉴权正常)
现象(p0 越权 / 未认证写入)
同一个进程里,@objectstack/rest 注册的数据面路由(/data、/meta)对匿名请求一律 401 UNAUTHENTICATED;但 @objectstack/runtime 的 dispatcher-plugin 注册的 /actions/* 和 /automation/* 路由没有任何匿名拒绝门,匿名调用者可以:
- 直接调用任意 script 型 action → 其 body 以
isSystem: true执行(buildActionExecutionContext),即 RLS/FLS 绕过 的系统提权写入; - 直接调用任意 flow 型 action / 直接
POST /automation/:name/trigger→ 启动 flow run。
action 的唯一前置门是 ADR-0066 D4 的 actionPermissionError,它在 action 未声明 requiredPermissions 时对所有调用者(含匿名)放行。绝大多数 action 默认就是未声明的(HotCRM 全部 13 个 action 无一声明 requiredPermissions),因此整条 CRM action 写入面对匿名开放。
这与平台自己的承诺矛盾。packages/spec/src/stack.zod.ts 的 api.requireAuth 退役墓碑(#3963)明确写道:
"Anonymous access to object data is now always denied. … A stack that mounts no auth at all now fails at boot rather than silently serving object data to anonymous callers."
而 action body 做的正是对象写入(比 /data 写更强,因为它系统提权绕过 RLS/FLS),却绕过了这条"永远拒绝匿名"的保证。
复现(可直接粘贴执行,HOST=起的 dev server)
# 前置:随便拿一个 crm_contact 的 id(用 admin token 读一次即可),记为 CID
# ── 1. 匿名 script action:成功 200,真实落库,行归属 system ──
curl -s -X POST http://HOST/api/v1/actions/crm_contact/send_email/$CID \
-H 'Content-Type: application/json' \
-d '{"params":{"subject":"ANON-INJECTED","body":"written with no auth"}}'
# → 200 {"success":true,"data":{"emailId":"...","activityId":"..."}}
# 之后用 admin 读该 sys_email:sent_by = "system", from_address = "noreply@hotcrm.local"(即 send_email 的匿名分支)
# ── 1b. 匿名 mark_primary 同样 200 并落库(is_primary=true)──
curl -s -X POST http://HOST/api/v1/actions/crm_contact/mark_primary/$CID \
-H 'Content-Type: application/json' -d '{}'
# → 200 {"success":true,"data":{"ok":true,"id":"...","is_primary":true}}
# ── 2. 匿名 flow action / automation:200,启动 run ──
curl -s -X POST http://HOST/api/v1/actions/crm_case/escalate_case/$CASEID \
-H 'Content-Type: application/json' -d '{"params":{"reason":"anon"}}'
# → 200 {"success":true,"data":{"status":"paused","runId":"run_..."}}
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
http://HOST/api/v1/automation/lead_conversion/trigger \
-H 'Content-Type: application/json' -d '{"recordId":"x"}'
# → 200
# ── 对照:同样匿名身份在数据面被拒 ──
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://HOST/api/v1/data/crm_contact \
-H 'Content-Type: application/json' -d '{"first_name":"x","last_name":"y","crm_account":"'$ACCT'"}'
# → 401 (message: "Authentication is required to access this endpoint.")
curl -s -o /dev/null -w '%{http_code}\n' http://HOST/api/v1/meta/objects
# → 401复现两次均一致(send_email 匿名连打两次各生成一条 sys_email,sent_by:'system',已用 admin 读回确认持久化;raw sqlite 亦可见)。
期望 vs 实际
- 期望:匿名请求
POST /api/v1/actions/...(和/automation/...)在派发之前即被 401 拒绝,与/data、/meta同一基线;requiredPermissions/ai.exposed等更细的授权在通过匿名门之后再判。 - 实际:匿名请求直达 action/flow 派发;script body 以
isSystem:true系统提权执行并绕过 RLS/FLS 落库;automation trigger 直接启动 run。
落点分析(读到的源码位置)
-
门缺失点:
packages/runtime/src/domains/actions.tshandleActionsRequest全程无匿名判定 —— 唯一前置门是actionExec.actionPermissionError(...)(约 line 217)。而packages/runtime/src/action-execution.ts的actionPermissionError(line 300+):const required = Array.isArray(actionDef?.requiredPermissions) ? actionDef.requiredPermissions : []; if (required.length === 0) return null; // ← 未声明 = 对所有人(含匿名)放行 if (ec?.isSystem) return null;
对比:
domains/ai.ts:127、domains/meta.ts:59、domains/security.ts:92都在处理前调用shouldDenyAnonymous({ userId, isSystem })。domains/actions.ts没有这一步。 -
路由注册点:
packages/runtime/src/dispatcher-plugin.ts的registerActionRoutes(distindex.js约 line 8108)把/actions//:action、/actions/:object/:action、/actions/:object/:action/:recordId直接接到dispatcher.dispatch("POST", ...),未经过mountRouteOnServer的route.auth401 门,也没有 rest-server 的enforceAuth。/automation/*同样如此。相较之下@objectstack/restrest-server.ts的每个/data、/metahandler 都先if (this.enforceAuth(req, res, context)) return;(内部shouldDenyAnonymous)。同一进程两套注册路径,只有 rest 那套设了门。 -
提权确认:
packages/runtime/src/security/resolve-execution-context.ts文档保证 "Anonymous requests yield{ isSystem: false, positions: [], permissions: [] }" —— 匿名本身不是 system;但 action body 一旦进入,buildActionExecutionContext强制isSystem:true(dist line ~1698),所以匿名触发即拿到系统提权、RLS/FLS 绕过的 body 执行上下文。
影响面与关联
- 影响 HotCRM 业务面:HotCRM 全部 13 个 action(mark_primary / send_email / log_call·log_meeting·schedule_meeting × 5 对象 / convert_lead / schedule_followup / create_campaign / clone_opportunity / mass_update_stage / generate_quote / escalate_case / close_case / enroll_leads)均未声明
requiredPermissions,故全部可被匿名调用。按验收纪律不在 hotcrm 重复立单,此处记录关联。 - 关联(非重复):Security: MCP action surface must gate on
ai.exposed— action bodies run trusted (unbounded RLS/FLS), so invoke-time is the only agent boundary (#2849) #2849 处理的是 MCP action 面按ai.exposed的 invoke-time 门,与本单是同一"body 系统提权、invoke-time 才是边界"的安全模型,但覆盖的是不同的传输面(MCP);本单是 REST/actions+/automation缺少更底层的匿名基线门。Security: AI ToolExecutionContext contract documentssystem-level as the missing-actor default — a contract-level fall-open across all data tools #2991(已闭)是 AI tool 上下文system默认 actor 的 fall-open,亦不同面。两仓 open/closed 检索未见覆盖 REST /actions 匿名调用的现单。
建议方向(仅供参考,不在本单实现)
在 domains/actions.ts(及 automation 派发)进入派发前加 shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem }) → 401,与 /data、/meta、/ai、/security 保持同一基线;之后再走 requiredPermissions / ai.exposed。
Activity
os-zhuang commentedon Aug 5, 2026
分诊:
pm:queue+domain:cli+priority:p0。packages/runtime/src/domains/actions.ts(及 automation 派发面)进派发前加shouldDenyAnonymous前置门,与/data、/meta、/ai、/security对齐同一基线 ——packages/runtime按域表归domain:cli。actions.ts无shouldDenyAnonymous,缺口仍在;该文件近日合并(fix(runtime):ctx.user.name交付真实 display name —— 三条 dispatch 路径统一 user 形状 (#5372) #5518,ctx.user.name 显示名)与本缺口无关。三仓检索无在飞 PR 覆盖。isSystem: true的 RLS/FLS 绕过写入并已实测落库,且与 把 public 从"全局开关的副产品"升级为声明式能力,然后删掉 api.requireAuth 开关 #3963 墓碑「匿名访问一律拒绝」的平台承诺直接矛盾 —— 属安全基线缺口,非行为偏好。ai.exposed— action bodies run trusted (unbounded RLS/FLS), so invoke-time is the only agent boundary (#2849) #2849(MCP 面 invoke-time 门)、Security: AI ToolExecutionContext contract documentssystem-level as the missing-actor default — a contract-level fall-open across all data tools #2991(closed,AI tool 上下文 fall-open)为同一安全模型的不同传输面,正文区分成立。本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
baozhoutao commentedon Aug 5, 2026
认领(PM 会话
session_016FNvXhtSdnEGEfLEsMmvxh,cli 车道,p0 + v17-rc2 优先级即时派发)claude/issue-5519-actions-anonymous-gate../objectstack-5519(os-dev 自建)packages/runtime/src/domains/actions.ts(匿名门主落点)、automation 派发同族域文件、packages/runtime/src/security/(仅复用shouldDenyAnonymous,不改其语义)+ 新增测试。不触碰packages/rest/**(fix(rest): 缺表错误只在「缺的正是所请求对象」时才判为 unknown object (#5462) #5530 在飞,rest-server.ts 面隔离)。shouldDenyAnonymous前置 401 门,与/data、/meta、/ai、/security同一基线;requiredPermissions/ai.exposed细粒度授权在匿名门之后原样保留。Generated by Claude Code
baozhoutao commentedon Aug 5, 2026
开工跟进(os-dev,承接 PM 会话
session_016FNvXhtSdnEGEfLEsMmvxh的派发,见上条认领评论)claude/issue-5519-actions-anonymous-gate../objectstack-5519(基于当前origin/main)origin/main上复核前提(harness 集成测试复现「匿名 → 200」现状)②在handleActionsRequest与 automation 派发进派发前加shouldDenyAnonymous401 前置门 ③测绘复用同一派发入口的内部/系统路径,逐条钉住不回归 ④反向验证(先判方向后实跑)。packages/runtime/**;不触碰packages/rest/**(fix(rest): 缺表错误只在「缺的正是所请求对象」时才判为 unknown object (#5462) #5530 在飞)与packages/spec/**;shouldDenyAnonymous本体语义不改,只复用。Generated by Claude Code
baozhoutao commentedon Aug 5, 2026
ACCEPT → PR #5569(PM 会话
session_016FNvXhtSdnEGEfLEsMmvxh,cli 车道,p0 闭环)验收依据:
/data401 对照成立。/ai、/meta、/security三处现有基线逐字一致;requiredPermissions/ai.exposed/ ADR-0104 契约语义原样排在门后。run_action、声明式 endpoint 执行器(自带authRequired门)、引擎内部触发均不经过这两个 HTTP seam;isSystem照常通过——「内部/系统派发不回归」有 socket 级用例钉住。??别名读法 (#5017) #5046 形状)被自查抓出并按 fixture 三分法 replace wholesale——这类自我纠错正是三分法要的产出。52 例既有用例逐条三分,无批量重拼;基线单独确认无既有红。Open question 采纳 A:本 PR 按 p0 窗口只在 runtime 内交付防线(真 socket 用例已守住),proof 工件补齐已单独立单 #5570;偶发 400 HookConditionError 观察已按「复现单」形状立 #5571(机制未确认,标题只陈述症状)。两单均无标签待分诊。
CI 核后转 ready 挂 auto-merge,跟到 MERGED。落地后解锁 #5138(同
action-execution.ts面串行)。Generated by Claude Code
fix(runtime): deny anonymous callers on /actions and /automation (obj…
test(dogfood): cover /actions and /automation in the anonymous-deny p…
fix(runtime): refuse a non-string descriptor filter instead of servin…
fix(services): a paused run's variable snapshot is readable on run-de…
fix(runtime): converge /automation run-state reads on the sys_automat…
feat!: retire GET /api/v1/automation for GET /api/v1/meta/flow; ListA…