You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit a0176ef
Browse filesBrowse the repository at this point in the historyBrowse files
fix(core): mask credential-class fields on every write response (#21816)
Fixes#21787
Clause-②: no
## What changed
Credential-class field values are now masked on every write response, as
on reads.
- The engine masks credential-class fields (`secret`, and `password`
outside the exempt `managedBy` bucket, ADR-0100) on its read path only.
Its write results keep the stored row whole by design, so privileged
server-side writers can read back what they wrote. The rule for what an
external caller receives therefore sits at the write mouths, the same
boundary that already omits `internal: true` fields.
- `omitInternalFieldsFromWriteResponse` (`@objectstack/core`) is the one
helper every generic write mouth already calls: protocol `*Data` faces,
the REST cross-object batch, and the MCP stdio bridge. It now applies
the credential mask first (`maskCredentialFieldsInWriteResponse`, new
export) and then omits `internal` fields, in the same order the engine's
read path uses. The mask reads the same `isMaskedOnReadFieldType`
declaration in `@objectstack/spec/data` that the engine's read mask
reads, so the two cannot drift. It never adds a key, so a field already
removed by field-level security stays absent.
- `callData`'s fallback create and update arms (`@objectstack/runtime`,
used when no protocol service is registered) answered without that
helper. They now call it too.
- Unchanged: engine-level write results, and the echoed-mask write
guard. A client that saves a masked value back leaves the stored
credential as it was. The dogfood test pins this.
## Tests
All runs below are from head `91d3806ab5` or from a commit whose tree
matches it for the files each run covers.
- New `packages/core/src/utils/internal-write-response.test.ts`: the
collector, the mask, the order (mask, then omit), the better-auth
exemption, and a field removed upstream staying absent. 5/5 pass.
- The three existing write-mouth tripwires now also check
credential-class stored values: a `password` plaintext and a `secret:`
handle ref on every stored row, in the protocol, REST and MCP suites.
Each tripwire enumerates its whole surface, so a new write mouth must
register there. Protocol 18/18, REST 14/14, MCP 14/14 (MCP adds a test
that the update echo does not return the caller's own credential in
clear).
- New
`packages/runtime/src/action-execution-calldata-write-response.test.ts`:
the fallback create and update arms. 3/3 pass.
- New
`packages/qa/dogfood/test/write-response-credential-mask.dogfood.test.ts`:
a real boot with a synthetic object holding one `password` and one
`secret` field. Before anything else, the test proves the plaintext and
the handle ref are really stored. It then checks single create, single
update, createMany, updateMany, the per-object batch, the cross-object
batch, and the masked-echo round trip. 8/8 pass.
- Full package suites: core 2225/2225, metadata-protocol 5249 passed and
19 skipped, rest 5074 passed and 327 skipped, mcp 390/390. In runtime,
the `action-execution*`, `http-dispatcher.mcp*` and `domains/mcp` files
pass (157/157). Neighbouring credential and `internal` dogfood suites
pass (97/97 across 8 files).
- Typecheck is clean for core, runtime, metadata-protocol, rest, mcp and
dogfood. A `--listFiles` count confirms the new and edited test files
are inside the compiled programs.
**Ablation**, run through `scripts/ablation-replace.mjs` with a dist
preflight. The single call that applies the credential mask inside the
shared helper was replaced. `@objectstack/core` was rebuilt, and the
preflight showed the marker present in `dist/`.
| Suite | Result with the mask call removed |
|:---|:---|
| core | 2 red |
| protocol tripwire | 8 red (every write face plus the negative control)
|
| REST tripwire | 8 red, including both batch routes |
| MCP tripwire | 3 red |
| runtime fallback | 2 red |
| dogfood | 7 red: every write door, while the arming test stayed green
|
The restore was proven: blob equal to HEAD, `git diff HEAD` empty, and
the core rebuild showed the marker absent from `dist/` with a clean
tree. A first ablation attempt used a preflight marker that the pristine
build also emits, so its preflight reading was void. That run is not
counted; the run above uses a unique marker.
**Gates:** `node scripts/pm/dispatch-gates.mjs --ran` reconciles 78
derived families: 77 run with exit 0, 1 NOT MEASURED.
`check:dual-build-cjs-loads` exited 3 with PREREQUISITE NOT MET because
unrelated packages had no `dist/`. `check:engine-double-contract` asked
for the new pinned double to be recorded, and
`scripts/engine-double-contract.pinned.json` is updated.
**Declared narrowing, left to CI:** the full runtime suite; the
workspace type-check lanes; `main` was not merged back in before
opening.
## Acceptance notes
- **Placement:** the fix is in the shared write-response helper, not the
engine's `maskSecretFields`. That is the boundary the existing ruling
set for the sibling `internal` guarantee: engine write results stay
whole for privileged callers, and every external write mouth applies the
response rules. Masking inside the engine would also mask results that
server-side writers read back.
- **Naming:** `omitInternalFieldsFromWriteResponse` now also masks
credentials, so its name describes less than it does. A rename touches
every write mouth and every tripwire, so it is left out of this PR.
Carrier: none.
- **ADR-0100:** the ADR describes the mask on the read path only.
Whether it should gain a line recording the write-response half is a
maintainer call, because `docs/adr/**` is governed. This PR does not
touch it.
- **Not examined:** outbound record surfaces that are not write
responses, such as event payloads, were not checked against this rule.
---
_Generated by [Claude
Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Credential-class field values are now masked on every write response, as on reads.
7
+
8
+
Clause-②: no
9
+
10
+
- A `secret` field, and a `password` field on an object that is not `managedBy: 'better-auth'`, already read back as `SECRET_MASK` (`null` when unset) on the generic read path (ADR-0100). Every write response that returns a record (REST, batch and MCP) now answers the same way.
11
+
- The shared write-response helper every write door already calls (`omitInternalFieldsFromWriteResponse`, `@objectstack/core`) now applies the credential mask before it omits `internal: true` fields. New exports beside it: `maskCredentialFieldsInWriteResponse` and `collectCredentialWriteResponseFields`, which read the same `isMaskedOnReadFieldType` declaration as the engine's read mask.
12
+
-`callData`'s fallback create and update arms (`@objectstack/runtime`, used when no protocol service is registered) now pass their response record through the same helper.
13
+
- Unchanged: the engine's own write results still return the stored row whole to privileged server-side callers, and the echoed-mask write guard still treats a `SECRET_MASK` value as "leave unchanged", so a client that saves back a write response does not overwrite the stored credential.
0 commit comments