来自 #3946 留的那条尾巴:去查 expand 的"高级用法"(调用者可直传 Record<string, QueryAST>,每个子 AST 自带 object)是否是跨对象越权读通道。
先说 expand:干净的,而且防得相当到位 我的怀疑是错的,expandRelatedRecords(engine.ts:2361)四道防线都在:
目标对象不由调用者决定。 expand 的键 必须是父对象 schema 上真实存在的字段,且带 reference、类型是 lookup/master_detail/user;读的目标是 fieldDef.reference —— 从 schema 来的 。调用者子 AST 里那个 object 键根本没人读 (和 全仓扫查"body 展开在受信任值之后":runtime 的 /data/:object/query 也中了,其余 8 处已核实无问题 #3946 里排除 ast.object 的理由一样)。走引擎自己的 this.find (Security: $expand bypasses RLS/FLS on the referenced object (data leak on lookups) #2850 有意如此),所以被引用对象的 RLS + FLS 中间件照跑。id 谓词不会被顶掉 —— 子 AST 的 where 是用显式 $and 合并的,注释里还专门写了"浅展开会 clobber 掉 id 约束",正是本系列的那个坑。MAX_EXPAND_DEPTH = 3;__expandRead 豁免面很窄(只 find、只非 private 对象,只免掉对象级 CRUD/requiredPermissions 闸门,RLS 注入和 FLS 掩码照跑)。结论:expand 无需改动。
但第 4 条那句"__expandRead 是服务端设的,executionContext 从不由客户端构造"不成立 packages/metadata-protocol/src/protocol.ts:2661:
const options : any = { ...request . query } ; // … if ( request . context !== undefined ) { // ← 条件赋值 options . context = request . context ; } request.query 在每个入口上都是调用者的原始 bag —— REST 的 POST /data/:object/query 直接把 req.body 当 query 传进来(rest-server.ts:3733);context 在下面的 knownParams 集合里,所以它也不会 被扫进隐式过滤桶,原样留在 options 上;赋值是条件的 ,所以当没有服务端 context 解析出来时,调用者那个 context 就是这次操作的执行上下文。 而它承载的东西是全部:
// plugin-security/src/security-plugin.ts:722 if ( opCtx . context ?. isSystem ) { return next ( ) ; // 整条 RLS / FLS / CRUD 链跳过 } __expandRead: true 同理能拿到上面那个豁免。两者在读路径上都不会被 schema 剥掉 —— ExecutionContextSchema.parse 只在 engine.createContext() 里用,读写热路径不走它。
协议层已实测确认 用真实的 ObjectStackProtocolImplementation + 假引擎,按匿名形状调用:
await p . findData ( { object : 'invoice' , query : { context : { isSystem : true , userId : 'root' , __expandRead : true } } } ) ; // 修复前 engine.find 收到的 options.context: // { isSystem: true, userId: 'root', __expandRead: true } ← 原样 可达性:端到端匿名复现我没做成,如实说明 拦在中间的是路由层 enforceAuth —— 默认 requireAuth: true 会先 401 掉匿名数据请求。要走通需要部署显式设 requireAuth: false(一个有文档、REST 插件启动时还会告警的 opt-out)。
我试过在 examples/app-crm/objectstack.config.ts 上加 api: { requireAuth: false } 起服务器实打,没生效 —— dev 命令这条路径没把 stack 的 api 键读到(serve.ts:1724 读 (config as any).api,但 dev 不走那段),四个探测都仍是 401。所以:
已实测确认 :协议层会把请求体里的 context 原样喂给引擎(上面那段);已代码确认 :isSystem 短路整条中间件、读路径不做 schema 剥离;未实测 :匿名数据请求在真实部署上确实可达 —— 这一环依赖 requireAuth: false,我没能在 dev CLI 上把这个开关打开。所以这是一个被上层闸门挡住的 fail-open 默认 ,不是一个当场可打的洞。但协议层不该依赖上面那道闸门一直是开着的 —— 这正是它自己拥有的不变量。
修法 findData 在(条件)赋值之前 无条件 delete options.context。执行上下文只能来自 request.context。
已核对不会破坏任何合法调用方:import-runner 的 findArgsBase(rest/src/import-runner.ts:245)把 context 放在顶层 、不在 query 里;仓库里没有任何调用方从 query bag 里传 context。getData 等兄弟方法是从零构造 options 的,不受影响 —— 只有 findData 是"调用者 bag 就是 options bag"。
核对于 origin/main @ a225ef5 。
来自 #3946 留的那条尾巴:去查
expand的"高级用法"(调用者可直传Record<string, QueryAST>,每个子 AST 自带object)是否是跨对象越权读通道。先说 expand:干净的,而且防得相当到位
我的怀疑是错的,
expandRelatedRecords(engine.ts:2361)四道防线都在:reference、类型是lookup/master_detail/user;读的目标是fieldDef.reference—— 从 schema 来的。调用者子 AST 里那个object键根本没人读(和 全仓扫查"body 展开在受信任值之后":runtime 的 /data/:object/query 也中了,其余 8 处已核实无问题 #3946 里排除ast.object的理由一样)。this.find(Security: $expand bypasses RLS/FLS on the referenced object (data leak on lookups) #2850 有意如此),所以被引用对象的 RLS + FLS 中间件照跑。where是用显式$and合并的,注释里还专门写了"浅展开会 clobber 掉 id 约束",正是本系列的那个坑。MAX_EXPAND_DEPTH = 3;__expandRead豁免面很窄(只find、只非 private 对象,只免掉对象级 CRUD/requiredPermissions 闸门,RLS 注入和 FLS 掩码照跑)。结论:expand 无需改动。
但第 4 条那句"
__expandRead是服务端设的,executionContext从不由客户端构造"不成立packages/metadata-protocol/src/protocol.ts:2661:request.query在每个入口上都是调用者的原始 bag —— REST 的POST /data/:object/query直接把req.body当query传进来(rest-server.ts:3733);context在下面的knownParams集合里,所以它也不会被扫进隐式过滤桶,原样留在options上;context就是这次操作的执行上下文。而它承载的东西是全部:
__expandRead: true同理能拿到上面那个豁免。两者在读路径上都不会被 schema 剥掉 ——ExecutionContextSchema.parse只在engine.createContext()里用,读写热路径不走它。协议层已实测确认
用真实的
ObjectStackProtocolImplementation+ 假引擎,按匿名形状调用:可达性:端到端匿名复现我没做成,如实说明
拦在中间的是路由层
enforceAuth—— 默认requireAuth: true会先 401 掉匿名数据请求。要走通需要部署显式设requireAuth: false(一个有文档、REST 插件启动时还会告警的 opt-out)。我试过在
examples/app-crm/objectstack.config.ts上加api: { requireAuth: false }起服务器实打,没生效 ——dev命令这条路径没把 stack 的api键读到(serve.ts:1724读(config as any).api,但dev不走那段),四个探测都仍是 401。所以:context原样喂给引擎(上面那段);isSystem短路整条中间件、读路径不做 schema 剥离;requireAuth: false,我没能在 dev CLI 上把这个开关打开。所以这是一个被上层闸门挡住的 fail-open 默认,不是一个当场可打的洞。但协议层不该依赖上面那道闸门一直是开着的 —— 这正是它自己拥有的不变量。
修法
findData在(条件)赋值之前无条件delete options.context。执行上下文只能来自request.context。已核对不会破坏任何合法调用方:
import-runner的findArgsBase(rest/src/import-runner.ts:245)把context放在顶层、不在query里;仓库里没有任何调用方从 query bag 里传 context。getData等兄弟方法是从零构造 options 的,不受影响 —— 只有findData是"调用者 bag 就是 options bag"。核对于
origin/main@ a225ef5。