环境行: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.ts handleActionsRequest 全程无匿名判定 —— 唯一前置门是 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(dist index.js 约 line 8108)把 /actions//:action、/actions/:object/:action、/actions/:object/:action/:recordId 直接接到 dispatcher.dispatch("POST", ...),未经过 mountRouteOnServer 的 route.auth 401 门,也没有 rest-server 的 enforceAuth。/automation/* 同样如此。相较之下 @objectstack/rest rest-server.ts 的每个 /data、/meta handler 都先 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 执行上下文。
影响面与关联
建议方向(仅供参考,不在本单实现)
在 domains/actions.ts(及 automation 派发)进入派发前加 shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem }) → 401,与 /data、/meta、/ai、/security 保持同一基线;之后再走 requiredPermissions / ai.exposed。
环境行:
hotcrm@0899b4f + @objectstack 17.0.0-rc.2(平台源码/home/user/objectstack,dev server:objectstack dev,已装@objectstack/plugin-auth,登录/鉴权正常)现象(p0 越权 / 未认证写入)
同一个进程里,
@objectstack/rest注册的数据面路由(/data、/meta)对匿名请求一律 401UNAUTHENTICATED;但@objectstack/runtime的 dispatcher-plugin 注册的/actions/*和/automation/*路由没有任何匿名拒绝门,匿名调用者可以:isSystem: true执行(buildActionExecutionContext),即 RLS/FLS 绕过 的系统提权写入;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)明确写道:而 action body 做的正是对象写入(比
/data写更强,因为它系统提权绕过 RLS/FLS),却绕过了这条"永远拒绝匿名"的保证。复现(可直接粘贴执行,HOST=起的 dev server)
复现两次均一致(send_email 匿名连打两次各生成一条
sys_email,sent_by:'system',已用 admin 读回确认持久化;raw sqlite 亦可见)。期望 vs 实际
POST /api/v1/actions/...(和/automation/...)在派发之前即被 401 拒绝,与/data、/meta同一基线;requiredPermissions/ai.exposed等更细的授权在通过匿名门之后再判。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+):对比:
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 执行上下文。影响面与关联
requiredPermissions,故全部可被匿名调用。按验收纪律不在 hotcrm 重复立单,此处记录关联。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。