Skip to content

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

Closed

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. added
    priority:p0Queue-jump: outranks batch and breaks the round — never exempts claiming or same-file serial
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 5, 2026
  2. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

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

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


    Generated by Claude Code

  3. self-assigned this
    on Aug 5, 2026
  4. 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

  5. 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

  6. 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

Labels

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch looppriority:p0Queue-jump: outranks batch and breaks the round — never exempts claiming or same-file serial

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions