Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bound-body-by-bytes-received.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Measure `bodySizeLimit` on the bytes a server-function call actually sends, and settle an aborted upload whichever way its length was framed. Dispatch decided how to pay for the body from the declaration alone — `if (!(declared > 0))` — so any positive digit string skipped the counting read: `Content-Length: 10` on a 2 MiB body dispatched the whole 2 MiB past a 1 MiB cap, believing the same header, from the same untrusted producer, whose `-1` #3153 had already established must not be believed. That gate also carried the upload lifecycle, so the signal/reader coupling of #3218 was installed only for bodies that declared no length: an ordinary browser POST abandoned mid-upload never settled its handler and never cancelled its upload source (#3217/#3219). A declaration is now only ever grounds for refusing a payload the peer has itself announced as oversized, before a byte is read; every body a capped route accepts goes through the bounded read.
5 changes: 5 additions & 0 deletions .changeset/bound-the-no-js-flash-cookie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Close the no-JS flash cookie's four unbounded edges. The cookie is now named `__Host-flash` and written with `SameSite=Lax` and a 60-second lifetime through a single writer shared with `clearFlashCookie`, so a sibling subdomain cannot toss an outcome at the app, a cross-site post's outcome is never stored, an unread submission no longer rides every request to the origin for the life of the browser, and the clear is a cookie the browser accepts for that name. A falsy outcome is an outcome: the decode no longer discards a well-formed cookie whose result is `""`/`0`/`false`/`null` (or a thrown `Error("")`, flags and all), and the handler flashes a function that simply returns. The size ladder now bounds the call's `url` as its last rung, so a form action carrying long state can no longer produce a cookie past the browser's ceiling — discarded whole, silently, after the mutation committed — while claiming it degraded to fit. The flash decode applies the same prototype-key strip as the argument decode, and refuses a payload whose `url` is not a string rather than handing the render one. Finally, a browser form navigation refused BEFORE dispatch — a stale id after a deploy, a malformed multipart body, an upload past `bodySizeLimit`, an origin the gate cannot vouch for — is bounced back to the form with the refusal flashed instead of being stranded on `/_server/<id>`; nothing has committed at those exits, and every other caller keeps the status it always got.
5 changes: 5 additions & 0 deletions .changeset/contain-and-own-the-single-flight-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Hold the fold to its own containment rule, and to the response it hands back. A collector that returned a value the wire cannot carry escaped the try/catch that covers a collector which throws, because the slice is encoded later — destroying the committed mutation's own result and every other cache's slice, and answering `200` with no error header, so the caller retries a write that already happened. The fold also appended this request's `Set-Cookie` and gap-filled headers into whatever `transformFlightResult` returned without owning it first, though the thrown tail already owns it for exactly the reason named in its comment; an integration that memoizes its shell served one caller's session and redirect target to the next. The caller-supplied source list is now read as the set the protocol defines rather than a multiset, so a repeated id can no longer buy a revalidation pass per repetition, and a redirect's own `Location` is the flight target whether or not the request carried a `Referer` — `Referrer-Policy: no-referrer` no longer switches single-flight off for every redirecting mutation.
5 changes: 5 additions & 0 deletions .changeset/end-the-call-the-transport-opened.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Let the client transport finish a call it started, and read the body once. Each call minted an `AbortController` that was wired only into the async-iterator wrapper, so an ordinary result never fired it and never cancelled the reader — while `ChunkReader` held the body lock, leaving the application unable to reclaim it either; a peer that answers correctly and then holds the body open wedges six calls' worth of an origin's HTTP/1.1 budget while every call reports success. Cancelling one branch of a tee does not cancel the fetch, so the teardown only became possible once the transport stopped cloning: `extractBody` now consumes the response it is given. Separately, both "did the runtime write this answer?" guards tested the format header's presence while the decoder matches it by value, so a duplicated header or a tag from a newer build resolved any status — `500` included — as a void success; the guards now derive their predicate from `BodyFormat` itself.
5 changes: 5 additions & 0 deletions .changeset/hold-the-invocation-seams.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Close the roads that reached a server function body around the hooks an application hangs its policy on. A `GET()` grant was stored as a bare word against an id rather than against the function it was granted to, so a rebind in one interleaving handed GET — and with it the origin-gate exemption — to a function that never declared it, and `withMeta` could report a revocation the wire had not performed. A `wrapInvocation` option of `null` — the other spelling of absent, and the one `provideEvent` already treats as absent — removed the configured gate instead of falling back to it; a per-handler wrap never reached the calls a dispatched body made in process; `provideEvent`'s exactly-once contract was enforced on the HTTP tail and nowhere else, so a defective adapter was a clean 500 on the wire and a silent double-commit on the direct road; and `new fn()` entered the body past the apply trap entirely, with no request scope and no guard. `transformResult` now runs for a plain thrown value as its own documentation promises, so an error-mapping or audit policy sees the failures that happen to an application and not only the ones its author shaped by hand.
5 changes: 5 additions & 0 deletions .changeset/keep-negative-zero-off-the-fast-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Refuse `-0` on the JSON fast path. `isJSONSafe` turns away every number `JSON.stringify` cannot spell faithfully except this one: `JSON.stringify(-0)` is `"0"`, so a result of `-0` arrived as `+0` while the codec, which encodes it correctly, was never reached. The same quiet corruption the neighbouring branches already refuse.
5 changes: 5 additions & 0 deletions .changeset/own-decoded-promises.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Take ownership of every promise the decoder mints from a peer's bytes. The encode side has always kept a fallback owner on the promises it creates (`guardFailures`); the decode side owned none of them, and seroval's atomic promise node settles synchronously inside `fromCrossJSON`, so `createJSONDeserializer.abort`'s sweep — which already defused the constructor spelling — never saw it. A 115-byte argument body encoding a rejected promise therefore answered `200`, ran the function, and then ended the process on Node's default unhandled-rejection policy, from any client that can send a raw request. Ownership is now taken where promises are minted, which covers both spellings because both park the bare promise in the deserializer's `refs` map; `abort`'s half-guard is deleted as the second half of the same guard rather than kept as a second one.
5 changes: 5 additions & 0 deletions .changeset/scope-deferred-bodies-in-containers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Scope a deferred body wherever it sits in a directly-called result, not only at the top level. `scopeDeferredResult` looked at the returned value itself, so `return { rows: cursor() }` from an SSR-time call ran its generator under the render's ambient event: two concurrent direct calls read and wrote each other's `locals`, and the render's own, which is the failure the per-call `locals` copy exists to prevent. The descent now covers containers, so a generator or stream one level down carries the call's own request event like a top-level one.
5 changes: 5 additions & 0 deletions .changeset/strip-at-the-decode-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Apply the prototype-key strip at the decode boundary instead of at one of its callers. `__proto__` / `constructor` / `prototype` were removed from decoded POST arguments by a strip attached to the argument path in the server, so every other graph the same decoder produced — above all every response the client decodes, and the no-JS flash cookie — handed the key through as an own property. No hostile server is required: a function returning `JSON.parse` of stored user text emits it through the JSON fast path, and any recursive merge on the client then writes through to `Object.prototype`. The guard now lives in `extractBody`, the boundary all three roads share, so the argument leg's existing guarantee and the two that were missing are one implementation rather than copies to keep in sync.
5 changes: 5 additions & 0 deletions .changeset/widen-the-result-guard-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Let the result guard see the slots a value actually holds. Reachability on the result path was decided against a narrower idea of "holds" than the codec and the consumer use — plain prototype only, enumerable keys only — so a failure channel parked just outside it was sanitized by nobody while the identical channel one step inside was fully guarded. A rejecting promise under an `Object.assign(new Error(...), …)` carrier shipped its raw message and own properties to the client under a `200`; one on a non-enumerable own data property was never owned and ended the process behind a delivered response, and a stream there was never torn down. The walk now reaches both without invoking hidden accessors, which is the hazard the narrowing was introduced to remove.
108 changes: 91 additions & 17 deletions packages/web/serialization/src/serializer-decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,38 +300,112 @@ export function createJSONDeserializer(options?: JSONCodecOptions): <T>(node: Se
* chunks resolve through a map shared across calls, so all chunks from one
* stream must go through the same deserializer instance.
*/
/**
* Whether a ref in a deserializer's shared map is a value only a LATER chunk
* can settle. That map holds seroval's in-progress state between chunks: an
* open stream under `__SEROVAL_STREAM__`, and a pending promise as the
* `{p, s, f}` resolver triple (the promise under one id, its resolver under
* the special-reference id next to it). One predicate for both readers of
* that state — the sweep that settles them and the check that asks whether
* any exist — so "still waiting" is one definition, not two.
*/
function awaitsLaterChunk(value) {
if (value === null || typeof value !== "object") return false;
return (
!!value.__SEROVAL_STREAM__ ||
(typeof value.s === "function" && typeof value.f === "function" && value.p instanceof Promise)
);
}

export function createJSONDeserializer(options) {
const refs = new Map();
const resolved = resolveCodecOptions(options);
// How many `refs` entries `ownDecodedPromises` has already claimed.
// seroval never reassigns a ref id (a second write throws "Conflicted ref
// id"), so the map only ever grows and iterates in insertion order:
// skipping the entries already claimed makes the sweep amortized O(1) per
// decoded node rather than O(refs) on every chunk of a long stream.
let owned = 0;
/**
* Takes ownership of every promise this decoder has just minted.
*
* A decoded payload is a PEER's bytes, and a promise it decodes to is a
* rejection nobody is holding: the value goes on to be a server function
* argument, or a slot in a decoded result, and ordinary code does not
* await a slot it never expected to be a promise. Under Node's default
* policy that ends the process — so the decoder keeps a fallback owner on
* what it mints, exactly as the encode side does for the promises IT
* mints (`guardFailures`, server.js: "Keep a fallback owner on the
* promise WE minted"). This changes nothing for a real consumer: `p`
* still rejects for whoever awaits it; only the "nobody at all" case is
* covered.
*
* It runs at the MINT, not at `abort`, because seroval has two promise
* spellings and only one of them is still pending when a stream ends. The
* constructor pair (`{p, s, f}` under the special-reference id, the bare
* promise under its own) settles from a later chunk, so `abort` can still
* reach it. The ATOMIC promise node (seroval type 12) settles
* SYNCHRONOUSLY inside the `fromCrossJSON` call that reads it — by the
* time any later hook runs, the microtask queue has drained and Node has
* already reported the rejection. Both spellings put the bare promise in
* `refs`, so claiming promises here covers the pair as well and there is
* one guard rather than one per spelling.
*/
function ownDecodedPromises() {
if (refs.size === owned) return;
let index = 0;
for (const value of refs.values()) {
if (index++ < owned) continue;
if (value instanceof Promise) value.then(undefined, () => {});
}
owned = refs.size;
}
function deserializeJSONChunk(node) {
return fromCrossJSON(node, { refs, ...resolved });
try {
return fromCrossJSON(node, { refs, ...resolved });
} finally {
// `finally`: a chunk that throws part-way through (a malformed node, a
// depth-limit refusal) has already minted whatever it minted.
ownDecodedPromises();
}
}
/**
* Fails every value still waiting on chunks that will never arrive. The
* shared refs map holds seroval's in-progress state between chunks: open
* streams (`__SEROVAL_STREAM__`) and pending-promise resolvers (`{p, s, f}`
* — the promise under one id, its resolver under the special-reference id
* next to it). Both settle idempotently — throwing into a completed stream
* and rejecting a resolved promise are no-ops — so the sweep is safe to
* run on normal end-of-stream too. The defusing handler on `p` keeps a
* rejection nobody awaited (a pending promise the app never touched) from
* surfacing as an unhandled rejection.
* Fails every value still waiting on chunks that will never arrive. Both
* kinds settle idempotently — throwing into a completed stream and
* rejecting a resolved promise are no-ops — so the sweep is safe to run on
* normal end-of-stream too. Settling is all this does: the rejection it
* induces already has a fallback owner, put there by
* `ownDecodedPromises` when the chunk that minted the promise was read.
*/
deserializeJSONChunk.abort = function abort(error) {
for (const value of refs.values()) {
if (value === null || typeof value !== "object") continue;
if (!awaitsLaterChunk(value)) continue;
if (value.__SEROVAL_STREAM__) {
value.throw(error);
} else if (
typeof value.s === "function" &&
typeof value.f === "function" &&
value.p instanceof Promise
) {
value.p.then(undefined, () => {});
} else {
value.f(error);
}
}
};
/**
* Whether anything decoded so far can still be changed by a later chunk —
* the question a reader asks before it stops reading. Answered from the
* same refs the sweep above settles, so the two can never disagree about
* what "still waiting" means.
*
* Conservative by construction: a resolver stays in the map after it
* settles, so a stream or promise that is already done still answers
* true. That is the safe direction. Saying "nothing is waiting" is a
* licence to stop reading, and stopping early would strand a value that
* was still on its way; saying it late only means the reader waits for
* the peer's own end of body, which is what every reader did before.
*/
deserializeJSONChunk.pending = function pending() {
for (const value of refs.values()) {
if (awaitsLaterChunk(value)) return true;
}
return false;
};
return deserializeJSONChunk;
}
export function createJSONDataTable(options?: JSONCodecOptions): JSONDataTable;
Expand Down
40 changes: 40 additions & 0 deletions packages/web/serialization/src/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Feature,
Serializer,
getCrossReferenceHeader,
toCrossJSON,
toCrossJSONStream,
createPlugin as createPluginImpl,
OpaqueReference as OpaqueReferenceImpl
Expand Down Expand Up @@ -229,6 +230,45 @@ export function getLocalHeaderScript(id) {
*/
export function serializeJSON(value: unknown, options: JSONSerializeOptions): () => void;

/**
* Whether the codec has a node for `value` — the encoder's own answer,
* asked before anything is committed to a stream.
*
* `serializeJSON` reports an unsupported type through `onError`, which for
* a transport arrives after the head is on the wire: too late to answer
* differently. A caller that can still choose (single-flight's fold drops
* a slice the wire cannot carry rather than losing the whole envelope with
* it) asks here instead of re-deriving the supported set, which plugins
* make unknowable from outside anyway.
*
* Asked through the SYNCHRONOUS parser, so an async channel — a promise,
* stream or async iterable — parses to a node without being drained and
* the real encode still has it. Enumerable accessors are read, exactly as
* the encode reads them; a caller that must not mint values early walks
* its own containers and asks only about leaves.
* @internal
*/
export function canSerializeJSON(value: unknown, options?: JSONCodecOptions): boolean;

/**
* Whether the codec has a node for `value`, answered by the synchronous
* parser under the same plugins and feature policy `serializeJSON` uses.
*/
export function canSerializeJSON(value, options) {
const resolved = resolveCodecOptions(options);
try {
toCrossJSON(value, {
refs: new Map(),
...resolved,
disabledFeatures:
resolved.disabledFeatures | serializeOnlyDisabledFeatures(resolved.serializeErrorStacks)
});
return true;
} catch {
return false;
}
}

// ---- JSON codec (server function transports) ----
//
// Unlike hydration output (executable JS targeting a global), the JSON codec
Expand Down
Loading
Loading