Skip to content

fix: preserve own keys through serialization - #133

Merged
KyleJune merged 2 commits into
mainfrom
fix/decoder-own-proto-key
Sep 12, 2026
Merged

KyleJune merged 2 commits into
mainfrom
fix/decoder-own-proto-key

Conversation

@KyleJune

@KyleJune KyleJune commented Sep 11, 2026

Copy link
Copy Markdown
Member

Summary

Preserve untrusted own __proto__ keys throughout Juniper serialization. Browser-style assignment previously substituted the object's prototype in the decoders; the same assignment in the preprocessing functions could drop the key before encoding. All four object-copy functions now use defineOwnValue.

Changes

  • Keep commit 92abe27's decoder fix and extend it to processValue and processValueForStreaming.
  • Exercise hydration, settled data requests, stream initial data, and deferred stream values with the browser accessor installed during decoding, and during both encoding and decoding.
  • Assert the own key, original value, writable/configurable/enumerable behavior, unchanged prototype, no inherited isAdmin, and unaffected Object.prototype. The accessor runs only in a child process; sanitizer settings are unchanged.

Testing

  • Red before the encoder fix: deno task test --filter "decoding an own __proto__" _serialization.test.ts failed at assertEquals(paths, ...) in the encode-with-accessor case. All four paths had hasOwnProto: false and ownKeys: ["name"].
  • Green after the fix: the same command passed both accessor modes across all four paths.
  • Five focused mutations independently replaced defineOwnValue with assignment in the helper, processValue, processValueForStreaming, restoreValue, and restoreValueWithPendingPromises. Every run exited 1 at AssertionError: Values are not equal. Encoder mutations lost the own key; decoder mutations also produced protoIsObjectPrototype: false and inherited isAdmin: true. Restored source passes the full suite.
  • deno task check: passes, including doc-lint's nine entrypoints and their JSDoc examples.
  • deno task test --parallel --reporter=dot: 34 passed (388 steps), zero failures. The sandboxed run could not read esbuild temporary projects; the host run passed without code or sanitizer changes.
  • Raw deno doc --lint over all nine export-map entrypoints exits 1 with 25 existing diagnostics, identical on main and this branch. The repository's doc-lint script explicitly allows those external-type/private-member diagnostics. This is an outstanding exception to the requested raw-lint-clean gate, not a claimed raw-lint pass.

Follow-up

This is step 1 of the founder's tagged JSON decision. PR #131 will be rebased and widened after this PR merges. Merging and publication remain reserved for the founder.

Closes

Closes #132

Both loader-data decoders wrote object keys with `result[key] = value`.
In browsers, assigning to `__proto__` sets the object's prototype
instead of creating an own property, so a loader returning
`{"__proto__": {"isAdmin": true}}` reached the client with the key
gone and `isAdmin` inherited. Keys are now written with
Object.defineProperty on the page-load, data-request, streamed, and
deferred decode paths.

The regression test decodes in a child `deno eval` that installs the
browser `__proto__` accessor, since Deno assigns it as an ordinary
property and the test process must not mutate globals.

Closes #132

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KyleJune

Copy link
Copy Markdown
Member Author

Security review at 92abe278: merge

No findings. The fix closes juniper#132 on every decode path that builds an object from payload keys. Both new call sites are load-bearing: reverting either one turns its own path red, and no other path. The result keeps the default prototype, and normal data comes out the same as before.

Severity of what this closes (for the record)

  • Path: an app's loader returns an object whose keys come from unvalidated user input (for example JSON.parse(user.preferences) holding {"__proto__":{"isAdmin":true}}). On 0.11.5, restoreValue (src/_serialization.ts:354 on main) assigns result["__proto__"] = … in the victim's browser. The key disappears and isAdmin becomes inherited.
  • Precondition: the app has to ship raw user-keyed JSON to another user's browser, and then trust inherited properties in client code.
  • Impact: limited to that one object on the client. Object.prototype is not polluted and nothing changes on the server. Low for the framework.

Decode paths examined (at 92abe278)

  • restoreValue, src/_serialization.ts:367: now defineOwnValue. It covers page load (v2), data requests, and deferred values once they resolve.
  • restoreValueWithPendingPromises, :643-647: now defineOwnValue. It covers the first chunk of a streamed response.
  • Raw decode: cbor2 2.3.0 builds string-keyed maps with Object.fromEntries (lib/container.js, function F). It never hits the setter.
  • The remaining obj[key] = sites run on the server or use keys the server controls:
    • processValue :297 and processValueForStreaming :480 are encoders, and they run on the server. On Deno 2.9.6, o["__proto__"] = x creates an own property (checked with deno eval).
    • serializeAllContext :811 uses context registry names as keys.
    • _server.tsx:267 builds publicEnv from the allowlist of env keys.
  • Read-only: deserializeError / built-in error deserializers (:227-243, :960+) read fields and never copy data onto an object. deserializeAllContext looks values up by registry name.
  • Client code: src/_client.tsx and src/client.tsx never use Object.assign, spread, or keyed assignment on decoded data.
  • constructor / prototype: not affected. On an ordinary object, assigning either creates an own property. __proto__ is the only accessor on Object.prototype.

Resulting objects

  • A decoded {"__proto__": x} is an own data property (enumerable, writable, configurable) on an ordinary object whose prototype is Object.prototype, not a null-prototype object. The new test asserts protoIsObjectPrototype: true and isAdmin: false.
  • Normal data: I built the same entries (integer keys, constructor) with defineProperty and with plain assignment. Key order and descriptors match exactly (deno eval), so there is no behavior change.

Mutation drill (child deno eval with the browser __proto__ accessor installed)

  • M0, unmutated: ok | 2 passed (56 steps) | 0 failed.
  • M1, :367 back to result[key] = restoreValue(val): red on page load, data request, and deferred data. Each shows hasOwnProto: false, isAdmin: true, protoIsObjectPrototype: false, ownKeys: ["name"]. streamed data stays green.
  • M2, :643 back to result[key] = restoreValueWithPendingPromises(…): red on streamed data only, with the same four fields wrong. The other three paths stay green.

Interaction with #131 (v3 tagged-JSON payload)

Merge order

#133 first. It is the security fix, it is independent, and as a fix: it releases as a 0.11.x patch. That gets the fix to users without waiting on the v3 format change. Then #131:

Notes (not findings)

  • v2 coverage after the merge: fix: preserve own keys through serialization #133's page-load leg will exercise v3, because serializeHydrationData emits v3. The v2 decode shares restoreValue, which the data-request leg still pins (M3).
  • Registered custom-type and error deserializers: they receive the raw decoded object. What they do with __proto__ keys belongs to the application, as before.
  • Threat model: the udibo threat model gets its row when udibo adopts this release. No §5 row covers client-side decode integrity yet. T9/T10 cover the hydration payload only as a script-breakout channel.

@KyleJune KyleJune changed the title fix: keep decoded __proto__ keys as own properties fix: preserve own keys through serialization Sep 12, 2026
@KyleJune

Copy link
Copy Markdown
Member Author

fix: preserve own keys before serialization — Extended 92abe27 to both preprocessing functions. Both browser-accessor modes cover all four paths; all five focused assignment mutations fail at the intended assertion. Full check and test gates pass (34 suites, 388 steps). Raw deno doc reports the same 25 known diagnostics as main; the PR body records that outstanding raw-lint limitation. Commit 20a2355 is pushed. No merge performed.

@KyleJune
KyleJune merged commit 6237634 into main Sep 12, 2026
11 checks passed
@KyleJune
KyleJune deleted the fix/decoder-own-proto-key branch September 12, 2026 19:36
KyleJune pushed a commit that referenced this pull request Sep 12, 2026
## [0.11.6](0.11.5...0.11.6) (2026-09-12)

### Bug Fixes

* preserve own keys through serialization ([#133](#133)) ([6237634](6237634)), closes [#132](#132)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.11.6 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decoders assign a __proto__ key with =, so in browsers it becomes the object's prototype

1 participant