Skip to content

feat(objectql): mask password fields on the generic read path (#2036) - #3170

Merged
os-zhuang merged 3 commits into
mainfrom
claude/password-field-plaintext-leak-58c4gf
Jul 18, 2026
Merged

feat(objectql): mask password fields on the generic read path (#2036)#3170
os-zhuang merged 3 commits into
mainfrom
claude/password-field-plaintext-leak-58c4gf

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes#2036. A password-typed field declared on a non-auth object (e.g. showcase_field_zoo.f_password) round-tripped plaintext through the generic CRUD engine — it was neither one-way hashed nor masked-on-read the way secret is. Someone modeling a password field on a custom object reasonably expects credential-grade handling; today they silently got plaintext storage and plaintext reads, a runtime/security trap the static gates don't catch.

This implements the issue's leaning decision (option 1 + 3): mask on read like secret, plus a non-fatal author-time warning. The rationale is recorded in ADR-0100, which this PR also unifies to cover both credential channels (secret and password) — giving the previously-dangling "secret field channel" comment references a real home.

What changed

  • Mask on readpassword fields are masked to SECRET_MASK (••••••••) in find / findOne (and therefore $expand, which re-enters find). A new collectMaskedReadFields helper returns every secret field plus every generic password field; maskSecretFields consumes it. secret behavior is unchanged.
  • Plaintext at rest, by design — unlike secret, a password value is not encrypted and gets nosys_secret row; it's stored verbatim and needs no CryptoProvider. One-way hashing was rejected (option 2): it only makes sense for credential verification, which a non-auth object never does, and doing it in the engine would stand up a second, unmanaged credential store.
  • Echoed-mask write guard — because a read now returns the mask, the write path drops any masked field (secret or password) whose incoming value equals SECRET_MASK, so a form round-trip that echoes the mask is a no-op rather than overwriting the stored value.
  • managedBy: 'better-auth' exemption — the auth subsystem reads its identity rows through the engine, so masking a credential column there would break login. Better-auth objects are exempt. Today this is a safety net, not load-bearing: no shipped identity object even declares a password-typed field (sys_account.password is a hashed text column) — pinned by a platform-objects test so retyping it becomes a deliberate decision.
  • Author-time warningObjectSchema.create() emits a deduped console.warn when a password field is declared on a non-better-auth object, steering authors to Field.secret or the auth subsystem. A warning, not a build error — password now has a defined generic-path contract, and the field-zoo example intentionally exercises every field type, so a hard error would be self-inflicted breakage.
  • Docs — corrected the field-type gallery, which wrongly called password "one-way hashed" (flagged by the docs-drift check).

Tests

  • packages/objectql/src/secret-fields.test.ts — new password-masking block: plaintext at rest (no crypto, no sys_secret), masked on find/findOne, unset stays null, echoed-mask no-op, new value replaces, and the better-auth exemption.
  • packages/spec/src/data/object.test.ts — warns once for a generic password field, deduped, silent for better-auth and for secret.
  • packages/platform-objects/src/platform-objects.test.ts — pins that no shipped object declares a password-typed field, and that sys_account.password is text.
  • packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.tsf_password upgraded from present to masked (verified reading back SECRET_MASK over real HTTP).

Verification

All run green from a fresh worktree:

  • @objectstack/objectql — 12 tests (7 new)
  • @objectstack/spec — object.test.ts, 103 tests
  • @objectstack/platform-objects — 120 tests
  • @objectstack/dogfood — field-zoo HTTP round-trip, 46 tests (real REST → engine path)
  • Full dependency graph builds/typechecks (61 turbo tasks) and changed files lint clean.

Follow-up

aggregate() masks neither secret nor password (a pre-existing gap for secret). Post-hoc masking of aggregate output would corrupt group keys; the correct fix is to reject aggregations that reference a credential field. Noted in ADR-0100 and tracked in #3171.

🤖 Generated with Claude Code


Generated by Claude Code

A `password`-typed field on a non-auth object round-tripped plaintext
through the generic CRUD engine — neither hashed nor masked the way
`secret` is. Someone modeling a `password` field on a custom object
reasonably expects credential-grade handling; they silently got plaintext
storage and plaintext reads.
Mask `password` to SECRET_MASK on the generic read path (find/findOne, and
$expand which re-enters find), mirroring `secret`. Unlike `secret`, a
password value stays plaintext at rest — no encryption, no sys_secret row,
no CryptoProvider required. An echoed mask is dropped on write so a form
round-trip does not overwrite the stored value. Objects marked
`managedBy: 'better-auth'` are exempt so the auth subsystem's own reads
(which go through the engine) still see the stored value; a platform-objects
pin asserts no shipped identity object even declares a `password` field
today. `ObjectSchema.create()` now warns (non-fatally, deduped per object)
when a `password` field is declared on a non-auth object, steering authors
to `secret` or the auth subsystem.
Decision and rationale recorded in ADR-0100; the aggregate() masking gap
(pre-existing for `secret` too) is noted there as a follow-up.
- objectql: add collectMaskedReadFields; wire mask/echoed-mask paths
- spec: author-time warning in ObjectSchema.create(); correct field.zod docs
- tests: password-masking units, warning units, platform-objects pin,
dogfood field-zoo f_password upgraded present -> masked
- examples: field-zoo label 'Password (one-way hash)' -> '(masked on read)'
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJrKxe1jXGudFT3nUks1yN
@vercel

vercelBot commented Jul 18, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 18, 2026 6:28am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/objectql, @objectstack/platform-objects, packages/qa, @objectstack/spec.

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

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via packages/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx(via packages/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/cli.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via packages/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via packages/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx(via packages/qa, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx(via packages/qa)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql, @objectstack/platform-objects, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via packages/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/platform-objects, @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

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.

…not hashed
The field-type gallery said a `password` field is "stored as a one-way
hash" — inaccurate for a generic object, where (per ADR-0100 / this PR) the
value is plaintext at rest but masked to •••••••• on read. One-way hashing
is owned by the auth subsystem and applies only to its identity tables.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJrKxe1jXGudFT3nUks1yN
… password)
Reframe ADR-0100 as the single record for both credential-bearing field
types: it now documents the pre-existing `secret` channel (encrypted at
rest, masked on read, fail-closed) alongside the new `password` masking
decision (#2036), and gives the long-dangling "secret field channel"
comment references a real home. Renamed the file to reflect the unified
scope. All code references use the stable ADR-0100 number, unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJrKxe1jXGudFT3nUks1yN
@os-zhuang
os-zhuang marked this pull request as ready for review July 18, 2026 06:24
@os-zhuang
os-zhuang merged commit 5f5762d into mainJul 18, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/password-field-plaintext-leak-58c4gf branch July 18, 2026 06:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mtests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

password-typed field on non-auth objects returns plaintext (no hash, no mask)

2 participants

@os-zhuang@claude