Skip to content

Commit e534ea8

Browse files
fix: prevent infinite loop from unexpected server response (#1898) (#2083)
1 parent dc2a4d6 commit e534ea8

File tree

3 files changed

+97
-0
lines changed

3 files changed

+97
-0
lines changed

.changeset/thick-dingos-joke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": patch
3+
---
4+
5+
Fix a regression introduced in SolidStart v1.3.0 that could cause an infinite loop when a server function returns unexpected response (for example, S3/XML error responses).
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it, beforeEach, afterEach } from "vitest";
2+
import { deserializeJSONStream, deserializeJSStream } from "./serialization";
3+
4+
const encoder = new TextEncoder();
5+
6+
function makeChunk(dataStr: string, declaredBytes?: number): Uint8Array {
7+
const data = encoder.encode(dataStr);
8+
const bytes = declaredBytes ?? data.length;
9+
const baseHex = bytes.toString(16).padStart(8, "0");
10+
const head = encoder.encode(`;0x${baseHex};`);
11+
const chunk = new Uint8Array(head.length + data.length);
12+
chunk.set(head);
13+
chunk.set(data, head.length);
14+
return chunk;
15+
}
16+
17+
function streamFromChunks(chunks: Uint8Array[]) {
18+
return new ReadableStream<Uint8Array>({
19+
start(controller) {
20+
for (const c of chunks) controller.enqueue(c);
21+
controller.close();
22+
},
23+
});
24+
}
25+
26+
function responseWithChunks(chunks: Uint8Array[] | null) {
27+
if (chunks === null) return new Response(null);
28+
return new Response(streamFromChunks(chunks));
29+
}
30+
31+
const cases = [
32+
{ name: "deserializeJSONStream", call: (r: Response) => deserializeJSONStream(r) },
33+
{ name: "deserializeJSStream", call: (r: Response) => deserializeJSStream("server-fn:0", r) },
34+
];
35+
36+
describe("Serialization negative testing (unhappy paths)", () => {
37+
// TODO: Serialization drains remaining chunks in the background for performance and
38+
// its async errors aren't propagated to a designated error boundary.
39+
// This is a temporary catch-all to avoid unhandled rejections in this test suite until
40+
// we have a better solution for handling async errors in serialization.
41+
const _unhandledRejectionHandler = (reason: any, promise?: Promise<any>) => {
42+
// eslint-disable-next-line no-console
43+
console.error("Unhandled rejection (ignored) in serialization.test:", reason, promise);
44+
};
45+
46+
// Install immediately and retain for the duration of this test file.
47+
beforeEach(() => {
48+
process.on("unhandledRejection", _unhandledRejectionHandler);
49+
});
50+
51+
afterEach(async () => {
52+
// Wait for any pending microtasks to allow background processes to complete
53+
await new Promise(resolve => setTimeout(resolve, 0));
54+
process.off("unhandledRejection", _unhandledRejectionHandler);
55+
});
56+
for (const fn of cases) {
57+
it(`${fn.name} throws on missing body`, async () => {
58+
await expect(fn.call(responseWithChunks(null))).rejects.toThrow("missing body");
59+
});
60+
61+
it(`${fn.name} throws on plain XML response`, async () => {
62+
const xml = '<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>';
63+
const chunk = encoder.encode(xml);
64+
const resp = new Response(new ReadableStream({
65+
start(controller) {
66+
controller.enqueue(chunk);
67+
controller.close();
68+
},
69+
}));
70+
await expect(fn.call(resp)).rejects.toThrow();
71+
});
72+
73+
it(`${fn.name} throws Malformed server function stream when header larger than provided bytes`, async () => {
74+
const chunk = makeChunk("bad", 16); // declare more than actual
75+
await expect(fn.call(responseWithChunks([chunk]))).rejects.toThrow("Malformed server function stream.");
76+
});
77+
78+
it(`${fn.name} throws Malformed server function stream when header smaller than provided bytes`, async () => {
79+
const chunk = makeChunk("bad", 2); // declare less than actual
80+
await expect(fn.call(responseWithChunks([chunk]))).rejects.toThrow();
81+
});
82+
83+
it(`${fn.name} throws on valid header but invalid JSON body`, async () => {
84+
const chunk = makeChunk("not-a-json");
85+
await expect(fn.call(responseWithChunks([chunk]))).rejects.toThrow();
86+
});
87+
}
88+
});

packages/start/src/runtime/serialization.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ class SerovalChunkReader {
159159
// deserialize the data
160160
const head = new TextDecoder().decode(this.buffer.subarray(1, 11));
161161
const bytes = Number.parseInt(head, 16); // ;0x00000000;
162+
if (Number.isNaN(bytes)) {
163+
throw new Error("Malformed server function stream header.");
164+
}
165+
162166
// Check if the buffer has enough bytes to be parsed
163167
while (bytes > this.buffer.length - 12) {
164168
// If it's not enough, and the reader is done

0 commit comments

Comments
 (0)