Skip to content

fix(plugin-security): 约束 self-delegation position anchor 防横向可见性越权(#830 follow-up) - #2945

Merged
os-zhuang merged 1 commit into
mainfrom
claude/authz-secfix-self-deleg-anchor
Jul 15, 2026
Merged

fix(plugin-security): 约束 self-delegation position anchor 防横向可见性越权(#830 follow-up)#2945
os-zhuang merged 1 commit into
mainfrom
claude/authz-secfix-self-deleg-anchor

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

漏洞(MEDIUM,横向可见性越权 / lateral escalation)

cloud#830(C1 position-anchor,已合并)把 sys_user_position.business_unit_id 从「reserved」变成可见性 load-bearing:它是 readScope 的深度锚定,unit/unit_and_below 的持有者可见的 owner 集合以该 BU 为根(unit_and_below 覆盖整棵子树)。

委派管理员的自助代理(D3 self-delegation)路径 assertSelfDelegationpackages/plugins/plugin-security/src/delegated-admin-gate.ts)此前对 business_unit_id没有任何 subtree/来源约束。后果:

  • Alice 只需直接持有一个 delegatable: true、带 unit/unit_and_below readScope 的非 admin-scope position P(锚定在低层 BU)。
  • 她把 P 代理给同谋 Bob,并把 business_unit_id 设成任意高层 / 祖先 BU
  • Bob 遂获得该 BU(及 unit_and_below 整棵子树)成员记录的可见性——超出 Alice 自己的范围
  • 互相代理可双向获得。

resolver 的「锚定只收窄不放宽」断言在 anchor 是成员 BU 祖先时不成立——而 D3 路径正是唯一没堵住这条的地方。主攻击面(直接自写 sys_user_position、自我代理、委派管理员路径的 anchor 落自己 subtree)此前均已封死。

修法选择与理由

采用任务的首选方案(约束而非禁止),理由:产品语义上 D3 代理本就允许携带 anchor(例如把「华东销售经理」职务代理给某人、锚定在华东某子单元)——一刀切禁止 anchor 会砍掉合法用法。改为强制收窄不放宽

self-delegation 的 business_unit_id 必须落在委派人自己对该 position 的有效 anchor 之内。

「有效 anchor」= 委派人自己直接持有该 position 的行的 anchor 子树;若该直接持有行本身无 anchor,则回退到委派人成员 BU 的子树(无锚持有解析到本人成员 BU)。这与 D12 委派管理员路径「assignments 必须落自己 subtree」(assertAssignmentWrite,L392-402)同精神、复用同款 subtree 逻辑

  • Fail-closed:无法证明落在委派人范围内的 anchor(范围不可解析)一律拒。
  • 无 anchor 的代理行保持原行为:代理人解析到自己的成员 BU,不构成放宽。

实现:activeHoldings 现返回每个持有行的 businessUnitId;新增 resolveSubtreeById / delegatorAnchorSubtreeresolveSubtree 重构复用之,行为不变);assertSelfDelegation 新增步骤 4b anchor 收窄校验。

测试矩阵(实测全绿,401 passed)

场景期望结果
anchor = 委派人自己 anchor(east)放行
anchor 落委派人子树内(east_sales,收窄)放行
anchor = 祖先 BU(hq,放宽)(never widen)
anchor = 无关兄弟 BU(west)(outside your own effective anchor)
无 anchor 的 D3 行原行为,放行
互相代理跨范围(双向)两个方向都
委派人无锚持有 → 用成员 BU 兜底:成员 BU 及以下放行、其父拒收窄成立
fail-closed:委派人无可解析范围(cannot be validated)
经代理获得的持有行不能再作源做带锚再代理(chains cut,原有 D3 校验先命中)
委派管理员路径(非 delegation write)不受影响原行为✅(既有测试)
直接自写 sys_user_position 仍封禁原行为✅(既有测试)

pnpm --filter @objectstack/plugin-security test → 18 files / 401 tests passed;tsc --noEmit 0 error;build 通过。

是否彻底堵住

load-bearing 的 unit_and_below 情形:Bob 的 owner 集合 ⊆ Alice 的 owner 集合,「锚定只收窄不放宽」不变量在 D3 路径重建。祖先 / 任意高层 / 无关 BU 的锚定被 fail-closed 拒绝,主向量关闭。

存疑点 / reviewer 注意

  1. unit(单层,非 _and_below)readScope 下的子孙 anchor:本修复用 subtree 包含作边界(与 D12 admin 路径一致)。若 P 的 readScope 是纯 unit,Alice 锚定 east 只看 east 一层,而允许 Bob 锚定 east 的子孙 east_sales 在严格意义上让 Bob 看到 Alice 看不到的一层(横向而非放宽)。这与 D12 委派管理员路径既有的同一边界取舍一致(该路径也用 subtree),且主向量(祖先/高层放宽)已完全关闭。若要对纯 unit 也精确收敛,需在 gate 里解析 position 所分发各 set 的 per-object readScope——依赖 cloud enterprise resolver 语义,超出本 follow-up 范围,建议单独 issue 跟踪。
  2. anchor 是 BU id(lookup)而非 name:故新增 resolveSubtreeById(既有 resolveSubtree 按 name 解析根后同样走子树遍历,已重构复用)。
  3. cloud resolver(security-enterprise/hierarchy/resolver.tsanchoredBusinessUnits)不在本仓库 checkout 内,anchor→owner 语义依 ADR-0090 Addendum 与任务描述推断;建议 reviewer 对照 cloud#830 确认「无 anchor = 解析到持有者成员 BU」这一回退语义无误(本修复的成员 BU 兜底依赖它)。

Reviewer checklist

  • 确认 anchor 收窄边界(subtree 包含)符合 cloud#830 的 anchor→owner 语义
  • 确认「无 anchor D3 = 原行为」是期望的产品语义(未收紧无锚代理)
  • 确认成员 BU 兜底(无锚直接持有)符合 resolver 的无锚回退
  • 确认存疑点 1(纯 unit 子孙 anchor)取舍可接受,或决定单开 issue
  • fail-closed 分支(范围不可解析→拒)无误伤合法路径

关联

  • 起因:cloud#830(C1 position-anchor,已合并)
  • 跟踪:framework#2920(本横向越权向量)

🤖 Generated with Claude Code

https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs

…follow-up)
cloud#830 (C1 position-anchor) 把 sys_user_position.business_unit_id 变成可见性
load-bearing(readScope 的深度锚定)。自助代理(D3)路径 assertSelfDelegation 对该
anchor 无任何 subtree/来源约束:持有一个 delegatable、非 admin-scope、锚定在低层 BU
的 position 的用户,可把它代理给同谋并把 business_unit_id 设成任意高层/祖先 BU,从而
泄露该 BU 整棵子树的成员记录可见性——超出委派人自身范围;互相代理可双向获得。
修复:self-delegation 的 business_unit_id 必须落在委派人自己对该 position 的有效
anchor 之内(自己直接持有行 anchor 的子树,或持有行无 anchor 时其成员 BU 的子树)——
与 D12 委派管理员「assignments 必须落自己 subtree」同精神。fail-closed:无法证明落在
委派人范围内的 anchor 一律拒。无 anchor 的代理行保持原行为(代理人解析到自己的成员
BU,非放宽)。「锚定只收窄不放宽」不变量在 D3 路径重新成立。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 15, 2026 3:49am

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/cli.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-security)
  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 15, 2026 03:45
@os-zhuang
os-zhuang merged commit 698454e into mainJul 15, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/authz-secfix-self-deleg-anchor branch July 15, 2026 03:54
os-zhuang added a commit that referenced this pull request Jul 30, 2026
…malized (objectui#2945) (#4107)
ViewFilterRuleSchema.operator is z.preprocess(normalizeFilterOperator, …),
so a stored `notEquals`/`gt`/`isNull` is folded to canonical during
save-time validation — and the result was then discarded, because
saveMetaItem persists the authored body verbatim (deliberately:
parsed.data strips the Studio-only aux fields that ride along with an
overlay, ADR-0005 §Validation).
So every save minted new legacy-alias rows. VIEW_FILTER_OPERATOR_ALIASES
documents itself as "a migration bridge [that] may be dropped in a future
major", but with new alias rows arriving continuously there is no point at
which the last one is behind you — a migration rewriting existing rows
would be obsolete at the next personalization PUT. This is prerequisite 2
of the consolidation blocked in objectui#2945.
graftNormalizedOperators walks the authored body and parsed.data in
lockstep BY STRUCTURE and copies exactly one thing: an `operator` whose
parsed value differs. No path list — ViewFilterRule[] has five declared
sites today and the walk covers all of them plus any added later;
enumerating them would reproduce here the duplication #2945 exists to
remove. Guarded on both sides being strings, so a `$`-token
FilterCondition cannot be reshaped. Nothing added, removed, reordered or
defaulted: the unary {field, operator} form does not acquire a `value`
even though the schema's own output would. Returns by identity when
nothing changed.
Behaviour change, stated plainly: a GET after a PUT now returns the
canonical spelling, not the one the author sent. That is the spelling the
spec defines and every renderer accepts (objectui#2974, objectui#2989
pinned all three objectui translation tables to the full vocabulary).
Existing rows are untouched — this stops the bleeding, it is not the
migration.
11 new tests, one driving EVERY alias the spec still folds through the
real ViewMetadataSchema; package suite 110 tests / 18 files green.
Refs objectstack-ai/objectui#2945, objectstack-ai/objectui#2901
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude