Skip to content

fix(metadata-protocol): findData must not take its execution context from the request (#3960) - #3961

Merged
os-zhuang merged 1 commit into
mainfrom
claude/expand-read-scope
Jul 29, 2026
Merged

fix(metadata-protocol): findData must not take its execution context from the request (#3960)#3961
os-zhuang merged 1 commit into
mainfrom
claude/expand-read-scope

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes#3960。查的是 #3946 留下的那条尾巴。

先给 expand 的结论:干净的,不用改

原本的怀疑是:expand 的"高级用法"允许调用者直传 Record<string, QueryAST>,每个子 AST 自带 object —— 会不会是跨对象越权读?不是,expandRelatedRecords 四道防线都在:

  1. 目标对象不由调用者决定 —— expand 的必须是父 schema 上真实存在、带 reference、类型为 lookup/master_detail/user 的字段;读的目标取 fieldDef.reference,从 schema 来。子 AST 里那个 object 键根本没人读(和 全仓扫查"body 展开在受信任值之后":runtime 的 /data/:object/query 也中了,其余 8 处已核实无问题 #3946 排除 ast.object 同理)。
  2. 走引擎自己的 this.find(Security: $expand bypasses RLS/FLS on the referenced object (data leak on lookups) #2850 有意如此)→ 被引用对象的 RLS + FLS 照跑。
  3. id 谓词顶不掉 —— 子 AST 的 where 用显式 $and 合并,注释里还专门写了"浅展开会 clobber 掉 id 约束"—— 正是本系列那个坑,这里早就防住了。
  4. 深度上限 3;__expandRead 豁免面很窄(只 find、只非 private,只免对象级 CRUD 闸门,RLS 注入和 FLS 掩码照跑)。

这条结论是承重的,所以值得写下来:下次再有人怀疑 expand,可以直接看这段。

但第 4 条依赖的那句前提不成立

__expandRead 的安全性写在注释里:"a server-set marker; executionContext is never client-built"。而 protocol.ts:2661:

constoptions: any={ ...request.query};if(request.context!==undefined){// ← 条件赋值options.context=request.context;}
  • request.query 在每个入口上都是调用者的原始 bag(REST POST /data/:object/query 直接把 req.bodyquery 传进来);
  • contextknownParams 里,所以也不会被扫进隐式过滤桶,原样留着;
  • 赋值是条件的 → 没有服务端 context 解析出来时,调用者那个 context 就是这次操作的执行上下文。

而它承载的是全部:

// plugin-security/src/security-plugin.ts:722if(opCtx.context?.isSystem)returnnext();// 整条 RLS / FLS / CRUD 链跳过

__expandRead: true 同样能拿到 #2850 的豁免。两者在读路径上都不会被 schema 剥掉 —— ExecutionContextSchema.parse 只在 engine.createContext() 里用,读写热路径不走它。

可达性:一半实测,一半没做成,如实说明

  • 已实测(协议层,真实 ObjectStackProtocolImplementation + 假引擎):按匿名形状调用,伪造的 { isSystem, userId, __expandRead }原样到达 engine.find;
  • 已代码确认:isSystem 短路整条中间件;读路径无 schema 剥离;
  • 未实测:匿名 HTTP 请求在真实部署上确实可达。这一环要求 requireAuth: false。我在 examples/app-crm 上加了 api: { requireAuth: false } 起服务器实打,没生效 —— dev 命令这条路径没读 stack 的 api 键(serve.ts:1724 读它,但 dev 不走那段),四个探测仍全是 401。

所以准确的说法是:一个被上层闸门挡住的 fail-open 默认,不是当场可打的洞。但协议层不该把这个不变量委托给上面那道闸门 —— 它自己就拥有这个不变量。

修法

赋值之前无条件 delete options.context。执行上下文只能来自 request.context

已核对不破坏任何合法调用方:仓库里唯一构造这些参数的地方(rest/src/import-runner.ts:245findArgsBase)把 context顶层、不在 query 里。getData 等兄弟方法从零构造 options,不受影响 —— 只有 findData 是"调用者 bag 就是 options bag"

验证

  • 新增 4 条测试。验过在未修代码上会红:4 条里 3 条失败,断言消息就是 expected { isSystem: true } to be undefined
  • metadata-protocol 99 条、rest 440 条、runtime 837 条、objectql 1163 条全绿;改动文件 ESLint 干净。

🤖 Generated with Claude Code

https://claude.ai/code/session_01TzLE9cw4gZKNyPN2ZP4iTt


Generated by Claude Code

…from the request (#3960)
From the #3946 sweep's leftover question — whether expand's advanced usage (a
caller-supplied Record<string, QueryAST> whose sub-ASTs carry their own `object`)
is a cross-object read channel. It is NOT: expandRelatedRecords takes its target
from the parent schema (the expand key must be a real `reference` field; the
sub-AST's `object` is never read), re-enters engine.find so the referenced
object's RLS + FLS both run, $and-merges a nested `where` instead of spreading it
over the id filter, and caps depth. No change needed there.
One layer down is the problem. findData built `{ ...request.query }` and then
assigned `context` from `request.context` CONDITIONALLY. `request.query` is the
caller's raw bag (the REST POST /data/:object/query route passes req.body
straight in), and `context` is in the known-params set so it was not swept into
the implicit-filter bucket — it survived, and became the execution context
whenever no server context resolved.
Everything hangs off that value: plugin-security opens with
`if (opCtx.context?.isSystem) return next()`, skipping the whole RLS/FLS/CRUD
chain, and `__expandRead` collects the #2850 CRUD-gate waiver. Neither is
schema-stripped on the read path — ExecutionContextSchema.parse runs only in
createContext.
enforceAuth is what kept it unreachable (anonymous data requests need
`requireAuth: false`), so this is a fail-open default, not a live exploit — and
not something the protocol should delegate upward. Any inbound `context` is now
dropped unconditionally.
Verified at the protocol layer: a forged { isSystem, userId, __expandRead }
reached engine.find verbatim before, is dropped after. The anonymous HTTP
reachability half is NOT verified — #3960 records what was and wasn't reproduced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzLE9cw4gZKNyPN2ZP4iTt
@vercel

vercelBot commented Jul 29, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredJul 29, 2026 11:50pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

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

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)

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.

@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Jul 29, 2026
@os-zhuang
os-zhuang merged commit a4a9944 into mainJul 29, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/expand-read-scope branch July 29, 2026 23:59
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.

findData 从请求体里取执行上下文 —— context.isSystem 能整条跳过安全中间件(expand 本身已核实是干净的)

2 participants

@os-zhuang@claude