diff --git a/.changeset/seroval-chunk-reader.md b/.changeset/seroval-chunk-reader.md new file mode 100644 index 000000000..fa52bdd96 --- /dev/null +++ b/.changeset/seroval-chunk-reader.md @@ -0,0 +1,10 @@ +--- +"@solidjs/start": patch +--- + +Make reading server function streams safer and faster. + +- A malformed chunk after the first one is now logged instead of causing an unhandled promise rejection. On hosts that do not catch unhandled rejections, one bad request could stop the server process. +- The stream is cancelled when a chunk cannot be read or parsed. +- Chunk headers are now checked strictly, and a server rejects chunks over 64MB that a client sends. +- Large payloads that arrive in many small pieces are read in linear time. A 16MB chunk read in 16KB pieces took about 2 seconds and now takes about 30ms. diff --git a/packages/start/src/fns/serialization.spec.ts b/packages/start/src/fns/serialization.spec.ts index c2dcaa76e..47f82dbd1 100644 --- a/packages/start/src/fns/serialization.spec.ts +++ b/packages/start/src/fns/serialization.spec.ts @@ -197,3 +197,133 @@ describe("custom seroval plugins", () => { expect(parsed.href).toBe("https://solidjs.com/"); }); }); + +function streamOf(pieces: (string | Uint8Array)[]) { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const piece of pieces) { + controller.enqueue(typeof piece === "string" ? encoder.encode(piece) : piece); + } + controller.close(); + }, + }); +} + +function frame(data: string) { + const size = new TextEncoder().encode(data).length; + return `;0x${size.toString(16).padStart(8, "0")};${data}`; +} + +/** Splits bytes into pieces of `size`, the way a network read can. */ +function split(text: string, size: number) { + const bytes = new TextEncoder().encode(text); + const pieces: Uint8Array[] = []; + for (let i = 0; i < bytes.length; i += size) { + pieces.push(bytes.subarray(i, i + size)); + } + return pieces; +} + +describe("SerovalChunkReader", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("writes a fixed-size header before each chunk", async () => { + const { serializeToJSONString } = await loadSerialization(true); + + const payload = await serializeToJSONString(1); + + expect(payload).toMatch(/^;0x[0-9a-f]{8};/); + expect(payload).toBe(frame(payload.slice(12))); + }); + + it("reads chunks split at any byte, including inside a character", async () => { + const { SerovalChunkReader } = await loadSerialization(true); + const body = frame("héllo wörld ✓") + frame("") + frame("second"); + + const reader = new SerovalChunkReader(streamOf(split(body, 1))); + const chunks: string[] = []; + await reader.drain(chunk => chunks.push(chunk)); + + expect(chunks).toEqual(["héllo wörld ✓", "", "second"]); + }); + + it("reads a large chunk in small pieces in linear time", async () => { + const { SerovalChunkReader } = await loadSerialization(true); + const data = "x".repeat(16 * 1024 * 1024); + + const start = performance.now(); + const result = await new SerovalChunkReader(streamOf(split(frame(data), 16 * 1024))).next(); + + expect(result.value).toHaveLength(data.length); + // Copying the whole buffer on every piece took about 2 seconds here. + expect(performance.now() - start).toBeLessThan(500); + }); + + it.each([ + ["a missing delimiter", "X0x00000003Yabc"], + ["a non-hex size", ";0x0000zz03;abc"], + ["a missing 0x prefix", ";0000000003;abc"], + ["a truncated header", ";0x0000"], + ["truncated data", ";0x000000ff;abc"], + ])("rejects %s", async (_, body) => { + const { SerovalChunkReader } = await loadSerialization(true); + + await expect(new SerovalChunkReader(streamOf([body])).next()).rejects.toThrow( + "Malformed server function stream.", + ); + }); + + it("rejects a chunk over the size limit before buffering it", async () => { + const { SerovalChunkReader } = await loadSerialization(true); + const reader = new SerovalChunkReader(streamOf([";0xffffffff;"]), { maxChunkSize: 1024 }); + + await expect(reader.next()).rejects.toThrow(/larger than the limit/); + }); + + it("reports a bad later chunk instead of leaving an unhandled rejection", async () => { + const { serializeToJSONString, deserializeJSONStream } = await loadSerialization(true); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const unhandled: unknown[] = []; + const onUnhandled = (error: unknown) => unhandled.push(error); + process.on("unhandledRejection", onUnhandled); + + try { + const first = await serializeToJSONString([1, 2]); + const value = await deserializeJSONStream(new Response(first + frame("{nope"))); + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(value).toEqual([1, 2]); + expect(unhandled).toEqual([]); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("server function stream"), + expect.any(SyntaxError), + ); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + it("cancels the body when the first chunk cannot be parsed", async () => { + const { deserializeJSONStream } = await loadSerialization(true); + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(frame("{nope"))); + }, + cancel() { + cancelled = true; + }, + }); + + await expect(deserializeJSONStream(new Response(body))).rejects.toThrow(SyntaxError); + expect(cancelled).toBe(true); + }); +}); diff --git a/packages/start/src/fns/serialization.ts b/packages/start/src/fns/serialization.ts index c7abc875e..2f692dfaa 100644 --- a/packages/start/src/fns/serialization.ts +++ b/packages/start/src/fns/serialization.ts @@ -85,17 +85,41 @@ const JS_SERIALIZE_DISABLED_FEATURES = import.meta.env.PROD ? Feature.ErrorProto * The format is as follows: * ;0xFFFFFFFF; */ -function createChunk(data: string): Uint8Array { - const encodeData = new TextEncoder().encode(data); - const bytes = encodeData.length; - const baseHex = bytes.toString(16); - const totalHex = "00000000".substring(0, 8 - baseHex.length) + baseHex; // 32-bit - const head = new TextEncoder().encode(`;0x${totalHex};`); - - const chunk = new Uint8Array(12 + bytes); - chunk.set(head); - chunk.set(encodeData, 12); - return chunk; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const HEADER_SIZE = 12; +const SEMICOLON = 0x3b; +const ZERO = 0x30; +const LOWER_X = 0x78; + +/** + * The largest chunk a server accepts from a client. The header can declare up + * to 4GB, and the reader buffers that much before it parses anything. + */ +export const MAX_REQUEST_CHUNK_SIZE = 64 * 1024 * 1024; + +function createChunkHeader(bytes: number): Uint8Array { + const hex = bytes.toString(16).padStart(8, "0"); + const head = new Uint8Array(HEADER_SIZE); + head[0] = SEMICOLON; + head[1] = ZERO; + head[2] = LOWER_X; + for (let i = 0; i < 8; i++) { + head[3 + i] = hex.charCodeAt(i); + } + head[11] = SEMICOLON; + return head; +} + +/** + * The header and the data are sent as separate pieces, so the encoded data is + * not copied into a second buffer. + */ +function enqueueChunk(controller: ReadableStreamDefaultController, data: string) { + const encoded = encoder.encode(data); + controller.enqueue(createChunkHeader(encoded.length)); + controller.enqueue(encoded); } export function serializeToJSStream(id: string, value: any) { @@ -106,9 +130,7 @@ export function serializeToJSStream(id: string, value: any) { disabledFeatures: JS_SERIALIZE_DISABLED_FEATURES, plugins: PLUGINS, onSerialize(data: string, initial: boolean) { - controller.enqueue( - createChunk(initial ? `(${getCrossReferenceHeader(id)},${data})` : data), - ); + enqueueChunk(controller, initial ? `(${getCrossReferenceHeader(id)},${data})` : data); }, onDone() { controller.close(); @@ -129,7 +151,7 @@ export function serializeToJSONStream(value: any) { depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT, plugins: PLUGINS, onParse(node) { - controller.enqueue(createChunk(JSON.stringify(node))); + enqueueChunk(controller, JSON.stringify(node)); }, onDone() { controller.close(); @@ -142,91 +164,152 @@ export function serializeToJSONStream(value: any) { }); } +function hexValue(code: number): number { + if (code >= 0x30 && code <= 0x39) return code - 0x30; + if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10; + if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10; + return -1; +} + +/** Returns the data size the header declares, or -1 when it is malformed. */ +function parseChunkHeader(head: Uint8Array): number { + if (head[0] !== SEMICOLON || head[1] !== ZERO || head[2] !== LOWER_X || head[11] !== SEMICOLON) { + return -1; + } + let size = 0; + for (let i = 3; i < 11; i++) { + const digit = hexValue(head[i]!); + if (digit === -1) { + return -1; + } + size = size * 16 + digit; + } + return size; +} + +function malformed(): Error { + return new Error("Malformed server function stream."); +} + +export interface SerovalChunkReaderOptions { + /** Rejects any chunk larger than this many bytes. */ + maxChunkSize?: number; +} + export class SerovalChunkReader { reader: ReadableStreamDefaultReader; - buffer: Uint8Array; - done: boolean; - constructor(stream: ReadableStream) { + done = false; + + private maxChunkSize: number; + /** Pieces read from the stream and not yet consumed, in order. */ + private pieces: Uint8Array[] = []; + private length = 0; + + constructor(stream: ReadableStream, options: SerovalChunkReaderOptions = {}) { this.reader = stream.getReader(); - this.buffer = new Uint8Array(0); - this.done = false; + this.maxChunkSize = options.maxChunkSize ?? Infinity; } - async readChunk() { - // if there's no chunk, read again - const chunk = await this.reader.read(); - if (!chunk.done) { - // repopulate the buffer - const newBuffer = new Uint8Array(this.buffer.length + chunk.value.length); - newBuffer.set(this.buffer); - newBuffer.set(chunk.value, this.buffer.length); - this.buffer = newBuffer; - } else { - this.done = true; + /** Reads until `size` bytes are buffered or the stream ends. */ + private async fill(size: number): Promise { + while (this.length < size && !this.done) { + const chunk = await this.reader.read(); + if (chunk.done) { + this.done = true; + } else if (chunk.value.length > 0) { + this.pieces.push(chunk.value); + this.length += chunk.value.length; + } } } - async next(): Promise<{ done: true; value: undefined } | { done: false; value: string }> { - // Check if the buffer is empty or incomplete - if (this.buffer.length < 12) { - // if we are already done... - if (this.done) { - // incomplete stream - if (this.buffer.length !== 0) { - throw new Error("Malformed server function stream."); - } - return { - done: true, - value: undefined, - }; + /** + * Removes the first `size` bytes from the buffer. Pieces are only joined + * when the bytes span more than one, so each byte is copied at most once. + */ + private take(size: number): Uint8Array { + const first = this.pieces[0]; + if (first && first.length >= size) { + if (first.length === size) { + this.pieces.shift(); + } else { + this.pieces[0] = first.subarray(size); } - // Otherwise, read a new chunk - await this.readChunk(); - return await this.next(); - } - // Read the "byte header" - // The byte header tells us how big the expected data is - // so we know how much data we should wait before we - // deserialize the data - const head = new TextDecoder().decode(this.buffer.subarray(1, 11)); - const bytes = Number.parseInt(head, 16); // ;0x00000000; - if (Number.isNaN(bytes)) { - throw new Error("Malformed server function stream."); + this.length -= size; + return first.subarray(0, size); } - // Check if the buffer has enough bytes to be parsed - while (bytes > this.buffer.length - 12) { - // If it's not enough, and the reader is done - // then the chunk is invalid. - if (this.done) { - throw new Error("Malformed server function stream."); + const result = new Uint8Array(size); + let offset = 0; + while (offset < size) { + const piece = this.pieces[0]!; + const count = Math.min(piece.length, size - offset); + result.set(piece.subarray(0, count), offset); + offset += count; + if (count === piece.length) { + this.pieces.shift(); + } else { + this.pieces[0] = piece.subarray(count); } - // Otherwise, we read more chunks - await this.readChunk(); } - // Extract the exact chunk as defined by the byte header - const partial = new TextDecoder().decode(this.buffer.subarray(12, 12 + bytes)); - // The rest goes to the buffer - this.buffer = this.buffer.subarray(12 + bytes); - - // Deserialize the chunk - return { - done: false, - value: partial, - }; + this.length -= size; + return result; + } + + async next(): Promise<{ done: true; value: undefined } | { done: false; value: string }> { + await this.fill(HEADER_SIZE); + if (this.length === 0) { + return { done: true, value: undefined }; + } + if (this.length < HEADER_SIZE) { + throw malformed(); + } + // The header gives the size of the data, so we know how much to wait for + // before decoding it. + const size = parseChunkHeader(this.take(HEADER_SIZE)); + if (size === -1) { + throw malformed(); + } + if (size > this.maxChunkSize) { + throw new Error( + `Server function stream chunk of ${size} bytes is larger than the limit of ${this.maxChunkSize} bytes.`, + ); + } + await this.fill(size); + if (this.length < size) { + throw malformed(); + } + return { done: false, value: decoder.decode(this.take(size)) }; } + /** Stops reading and releases the stream, such as after a parse error. */ + async cancel(reason?: unknown): Promise { + this.pieces = []; + this.length = 0; + this.done = true; + await this.reader.cancel(reason).catch(() => {}); + } + + /** Interprets every remaining chunk. On an error, the stream is cancelled. */ async drain(interpret: (chunk: string) => void) { - while (true) { - const result = await this.next(); - if (result.done) { - break; - } else { + try { + while (true) { + const result = await this.next(); + if (result.done) { + break; + } interpret(result.value); } + } catch (error) { + await this.cancel(error); + throw error; } } } +function reportDrainError(error: unknown): void { + console.error("[solid-start] failed to read the rest of a server function stream:", error); +} + export async function serializeToJSONString(value: any) { const response = new Response(serializeToJSONStream(value)); return await response.text(); @@ -237,12 +320,18 @@ export async function deserializeFromJSONString(json: string) { return await deserializeJSONStream(blob); } -export async function deserializeJSONStream(response: Response | Request) { +export async function deserializeJSONStream( + response: Response | Request, + options?: SerovalChunkReaderOptions, +) { if (!response.body) { throw new Error("missing body"); } - const reader = new SerovalChunkReader(response.body); - const result = await reader.next(); + const reader = new SerovalChunkReader(response.body, options); + const result = await reader.next().catch(async error => { + await reader.cancel(error); + throw error; + }); if (!result.done) { const refs = new Map(); @@ -256,9 +345,17 @@ export async function deserializeJSONStream(response: Response | Request) { return value; } - void reader.drain(interpretChunk); - - return interpretChunk(result.value); + let value: unknown; + try { + value = interpretChunk(result.value); + } catch (error) { + await reader.cancel(error); + throw error; + } + // Later chunks settle the promises and streams inside `value`. A bad chunk + // is reported here, because nothing else awaits this. + reader.drain(interpretChunk).catch(reportDrainError); + return value; } return undefined; } @@ -269,19 +366,21 @@ export async function deserializeJSStream(id: string, response: Request | Respon } const reader = new SerovalChunkReader(response.body); - const result = await reader.next(); + const result = await reader.next().catch(async error => { + await reader.cancel(error); + throw error; + }); if (!result.done) { - reader.drain(deserialize).then( - () => { + const value = deserialize(result.value); + reader + .drain(deserialize) + .catch(reportDrainError) + .finally(() => { // @ts-ignore delete $R[id]; - }, - () => { - // no-op - }, - ); - return deserialize(result.value); + }); + return value; } return undefined; } diff --git a/packages/start/src/fns/shared.ts b/packages/start/src/fns/shared.ts index e0b69468e..e0a1d7c2e 100644 --- a/packages/start/src/fns/shared.ts +++ b/packages/start/src/fns/shared.ts @@ -1,4 +1,8 @@ -import { deserializeJSONStream, deserializeJSStream } from "./serialization.ts"; +import { + deserializeJSONStream, + deserializeJSStream, + MAX_REQUEST_CHUNK_SIZE, +} from "./serialization.ts"; export const BODY_FORMAT_KEY = "X-Start-Type"; @@ -91,7 +95,11 @@ export async function extractBody(instance: string, client: boolean, source: Req if (client && import.meta.env.SEROVAL_MODE === "js") { return await deserializeJSStream(instance, clone); } - return await deserializeJSONStream(clone); + // A server reads chunks sent by any client, so their size is limited. + return await deserializeJSONStream( + clone, + client ? undefined : { maxChunkSize: MAX_REQUEST_CHUNK_SIZE }, + ); case startType === BodyFormat.String: return await clone.text(); case startType === BodyFormat.File: {