fix(security): enforce static readonly fields on the UPDATE write path (#2948) - #2957
Merged
Conversation
#2948) A field's static `readonly: true` was never enforced server-side on update: the record validator only skipped read-only columns from validation, and only the conditional `readonlyWhen` variant was stripped from the write payload. A non-system update could therefore overwrite any readonly column — audit stamps, provenance, or other system-computed values. (#2946 already closed the cross-tenant organization_id face; this is the broader in-tenant integrity face.) engine.update now strips caller-supplied writes to statically-readonly fields for non-system contexts, on both the single-id and multi-row paths (strips, does not reject — symmetric with readonlyWhen). Two guards keep legit writes intact: - caller-supplied only: the strip runs against a snapshot of the keys the caller sent BEFORE hooks/middleware ran, so audit-hook stamps (updated_by/ updated_at) and write-middleware stamps survive; only a client that forged a readonly field has it dropped. - system-context exempt: isSystem writes (import, seed replay, approvals, lifecycle) legitimately set readonly columns and skip the strip. Adds a stripReadonlyFields helper + unit tests and an engine-level integration test (user forge stripped, server stamp survives, system write allowed). objectql suite: 852 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 15, 2026 10:13
This was referenced Jul 15, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Jul 17, 2026
…ix its declared contract (objectstack-ai#3003) objectstack-ai#3003 reproduced the pre-objectstack-ai#2948 gap in production terms: approval/status/amount columns "protected" only by static `readonly: true` were forged with one direct same-session REST PATCH on a draft record, self-approving a 4-stage approval on the released 15.0.0 (RECORD_LOCKED only guards pending flows, and the strip that closes this — stripReadonlyFields, objectstack-ai#2957 — landed after that release cut). The engine fix is already on main; what was still missing is everything that keeps the contract from silently regressing or being misread again: - dogfood proof (showcase-static-readonly.dogfood.test.ts, @proof: readonly-static-write): over real HTTP on the showcase app, a non-admin owner's PATCH forging `lead_score` returns 200 with the persisted value kept while sibling editable fields land, an all-forged payload is a no-op, and INSERT may still seed the column (the documented readonlyWhen-symmetric exemption). - authz conformance row `readonly-static-write` (+ HIGH_RISK) so the proof is mandatory — deleting it fails CI, not review. - ADR-0054 proof-registry class bound to `field/readonly`, so the liveness ledger's `live` classification must carry the proof. - contract text: FieldSchema.readonly described itself as "Read-only in UI" — exactly the misreading objectstack-ai#3003 documents. The Zod description, the field liveness ledger (readonly + readonlyWhen), and the hand-written field docs now state the server-side strip (non-system UPDATE, insert exempt). Closes objectstack-ai#3003. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKR6MTrGunV4p4AUfbKMDU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
目的
关闭 #2948 —— 字段的静态
readonly: true在服务端 update 时从未被强制:record-validator 只是把 readonly 列从校验里跳过,引擎也只剥离条件式readonlyWhen,从不剥离静态readonly。因此一个非系统(用户上下文)的 update 可以覆盖任意 readonly 列——审计戳、provenance、系统计算值等。改动
engine.update现在对非 system 上下文剥离调用方提供的静态-readonly字段写入,覆盖单条 + 多行两条路径(剥离而非拒绝,与readonlyWhen对称)。两道护栏保证合法写不受影响:updated_by/updated_at)与写 middleware 的戳存活;只有客户端显式伪造的 readonly 字段被丢弃。isSystem写(import / seed replay / approvals / lifecycle hook)合法地设置 readonly 列,跳过剥离。新增
stripReadonlyFieldshelper(rule-validator.ts)+ 单元测试,以及引擎级集成测试(用户伪造被剥离、服务端戳存活、system 写放行)。关键设计点(评审重点)
#2946的 org_id 不可变拒绝跑在 plugin-security middleware(包住整个 body),在本剥离点之前触发——本剥离不会把那处 reject 降级为静默 strip。sys_stamp_audit_updatehook 在beforeUpdate(剥离点之前)写updated_by;因它不在 caller-supplied 快照里,故被保留。可接受边界:客户端显式伪造updated_by时该请求不更新「最后修改人」——安全。验证
@objectstack/objectql全量:66 files / 852 tests passed ✓rule-validator.test.ts/plugin.integration.test.ts)passed ✓changeset:
@objectstack/objectql: minor(安全加固,非破坏——单组织及任何不伪造 readonly 列的写零变化)。关联:#2946(org_id 跨租户面)· #2937 · ADR-0092(身份表写守卫)· tracking #2920。
🤖 Generated with Claude Code
https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
Generated by Claude Code