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

  1. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    分诊:pm:queue + domain:cli + priority:p0。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 5, 2026
  3. baozhoutao commented on Aug 5, 2026

    @baozhoutao
    Contributor

    认领(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 面隔离)。
    • 方向锚定分诊席 15:54Z 判读:进派发前加 shouldDenyAnonymous 前置 401 门,与 /data、/meta、/ai、/security 同一基线;requiredPermissions / ai.exposed 细粒度授权在匿名门之后原样保留。

    Generated by Claude Code

  4. baozhoutao commented on Aug 5, 2026

    @baozhoutao
    Contributor

    开工跟进(os-dev,承接 PM 会话 session_016FNvXhtSdnEGEfLEsMmvxh 的派发,见上条认领评论)

    • 分支:claude/issue-5519-actions-anonymous-gate
    • 工作树:../objectstack-5519(基于当前 origin/main)
    • 计划:①先在我动手时刻的 origin/main 上复核前提(harness 集成测试复现「匿名 → 200」现状)②在 handleActionsRequest 与 automation 派发进派发前加 shouldDenyAnonymous 401 前置门 ③测绘复用同一派发入口的内部/系统路径,逐条钉住不回归 ④反向验证(先判方向后实跑)。
    • 文件面自限:仅 packages/runtime/**;不触碰 packages/rest/**(fix(rest): 缺表错误只在「缺的正是所请求对象」时才判为 unknown object (#5462) #5530 在飞)与 packages/spec/**;shouldDenyAnonymous 本体语义不改,只复用。

    Generated by Claude Code

  5. baozhoutao commented on Aug 5, 2026

    @baozhoutao
    Contributor

    ACCEPT → PR #5569(PM 会话 session_016FNvXhtSdnEGEfLEsMmvxh,cli 车道,p0 闭环)

    验收依据:

    • 前提复核比原单更广且全部真机取证:除 script action 提权写外,匿名还能触发 flow(真实 runId)、拉全量 flow 清单、DELETE 注销已注册 flow(破坏性写,原单未记录);同进程 /data 401 对照成立。
    • 门的落点与信封与 /ai、/meta、/security 三处现有基线逐字一致;requiredPermissions / ai.exposed / ADR-0104 契约语义原样排在门后。
    • 内部路径按调用者枚举测绘(非读注释):MCP run_action、声明式 endpoint 执行器(自带 authRequired 门)、引擎内部触发均不经过这两个 HTTP seam;isSystem 照常通过——「内部/系统派发不回归」有 socket 级用例钉住。
    • 反向验证方向先判后跑,handler 级 12 红 / socket 级 7 红全部收到修复前答案;object-less 用例的「断言因空而绿」(fix(lint): 收敛 validate-expressions / validate-security-posture 的 spec 不声明键 ?? 别名读法 (#5017) #5046 形状)被自查抓出并按 fixture 三分法 replace wholesale——这类自我纠错正是三分法要的产出。52 例既有用例逐条三分,无批量重拼;基线单独确认无既有红。
    • 消费半径:runtime 1397 / dogfood 506 / cli 807 全绿,changeset 含升级须知。

    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

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