Skip to content

[17.0-rc2验收] 安全:REST /actions 与 /automation 派发路由缺少匿名拒绝门 —— 未认证调用者可触发 system 提权的 RLS/FLS 绕过写入 #5519

Description

@yinlianghui

环境行: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/* 路由没有任何匿名拒绝门,匿名调用者可以:

  1. 直接调用任意 script 型 action → 其 body 以 isSystem: true 执行(buildActionExecutionContext),即 RLS/FLS 绕过 的系统提权写入;
  2. 直接调用任意 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。

落点分析(读到的源码位置)

  1. 门缺失点: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 没有这一步。

  2. 路由注册点: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 那套设了门。

  3. 提权确认: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。

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions