Skip to content
File

Blob: src/workerd/api/tests/deserialize-hardening-test.js

javascript214 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4// Regression tests for deserialization hardening. These tests verify that
5// crafted V8 structured clone payloads targeting various host object
6// deserializers produce graceful DataCloneError exceptions rather than
7// crashing the process via KJ_ASSERT / KJ_ASSERT_NONNULL -> abort().
8//
9// Each test sends a crafted serializedBody via Fetcher::queue() that encodes
10// a malformed host object. The host object tag dispatches to the target
11// deserializer, which should throw DataCloneError.
12//
13// V8 wire format reference:
14// 0xff 0x0f - V8 structured clone header (version 15)
15// 0x5c - kHostObject (delegates to ReadHostObject)
16// Then a varint uint32 workerd tag dispatches to the specific deserializer.
17//
18// Workerd serialization tags (from worker-interface.capnp):
19// 4 = HEADERS, 5 = REQUEST, 6 = RESPONSE, 7 = DOM_EXCEPTION (legacy)
20 
21import assert from 'node:assert';
22 
23// Helper: encode a uint32 as a varint (LEB128)
24function varint(n) {
25 const bytes = [];
26 do {
27 let b = n & 0x7f;
28 n >>>= 7;
29 if (n > 0) b |= 0x80;
30 bytes.push(b);
31 } while (n > 0);
32 return bytes;
33}
34 
35// V8 wire format header
36const V8_HEADER = [0xff, 0x0f];
37const HOST_OBJECT = [0x5c];
38 
39// V8 value tags
40const kOneByteString = 0x22;
41const kInt32 = 0x49;
42const kBeginJSObject = 0x6f;
43const kEndJSObject = 0x7b;
44 
45// Workerd serialization tags
46const TAG_HEADERS = 4;
47const TAG_REQUEST = 5;
48const TAG_RESPONSE = 6;
49const TAG_DOM_EXCEPTION_LEGACY = 7;
50 
51function makePayload(...parts) {
52 return new Uint8Array(parts.flat()).buffer;
53}
54 
55// Encode a one-byte string in V8 wire format: kOneByteString + varint(len) + bytes
56function v8String(s) {
57 const bytes = Array.from(s, (c) => c.charCodeAt(0));
58 return [kOneByteString, ...varint(bytes.length), ...bytes];
59}
60 
61// Encode a V8 integer: kInt32 + zigzag(0) = 0
62const v8Int0 = [kInt32, 0x00];
63 
64// Helper to send a crafted payload and assert the process survives.
65async function sendAndSurvive(env, id, payload) {
66 const result = await env.SERVICE.queue('hardening-test', [
67 {
68 id,
69 timestamp: new Date(),
70 serializedBody: payload,
71 attempts: 1,
72 },
73 ]);
74 assert.strictEqual(result.outcome, 'exception');
75}
76 
77// =========================================================================
78// Headers tests (tag 4)
79// =========================================================================
80 
81// Headers deserializer wire format:
82// varint(guard) + varint(count) + [varint(commonId) + ...]...
83// The count bounds check rejects count > 1024.
84export const headersCountOverflow = {
85 async test(ctrl, env) {
86 const payload = makePayload(
87 V8_HEADER,
88 HOST_OBJECT,
89 varint(TAG_HEADERS),
90 varint(0), // guard = IMMUTABLE
91 varint(1025) // count = 1025 (exceeds maximum of 1024)
92 );
93 await sendAndSurvive(env, 'headers-count-overflow', payload);
94 },
95};
96 
97// =========================================================================
98// DOMException legacy tests (tag 7)
99// =========================================================================
100 
101// DOMException legacy wire format:
102// varint64(nameLen) + nameBytes + readValue(js) [expecting object]
103// The object must have string "message" and "stack" properties.
104 
105// Test: readValue returns a non-object (integer instead of object)
106export const domExceptionNonObject = {
107 async test(ctrl, env) {
108 const payload = makePayload(
109 V8_HEADER,
110 HOST_OBJECT,
111 varint(TAG_DOM_EXCEPTION_LEGACY),
112 // readLengthDelimitedString for name: varint64(4) + "Test"
113 varint(4),
114 [0x54, 0x65, 0x73, 0x74],
115 // readValue: an integer, not an object
116 v8Int0
117 );
118 await sendAndSurvive(env, 'domexception-non-object', payload);
119 },
120};
121 
122// Test: object has non-string "message" property
123export const domExceptionNonStringMessage = {
124 async test(ctrl, env) {
125 const payload = makePayload(
126 V8_HEADER,
127 HOST_OBJECT,
128 varint(TAG_DOM_EXCEPTION_LEGACY),
129 // name
130 varint(4),
131 [0x54, 0x65, 0x73, 0x74],
132 // readValue: a JS object with integer "message" and string "stack"
133 [kBeginJSObject],
134 v8String('message'),
135 v8Int0, // message: 0 (not a string)
136 v8String('stack'),
137 v8String(''), // stack: "" (valid string)
138 [kEndJSObject, ...varint(2)]
139 );
140 await sendAndSurvive(env, 'domexception-non-string-message', payload);
141 },
142};
143 
144// Test: object has non-string "stack" property
145export const domExceptionNonStringStack = {
146 async test(ctrl, env) {
147 const payload = makePayload(
148 V8_HEADER,
149 HOST_OBJECT,
150 varint(TAG_DOM_EXCEPTION_LEGACY),
151 // name
152 varint(4),
153 [0x54, 0x65, 0x73, 0x74],
154 // readValue: a JS object with string "message" and integer "stack"
155 [kBeginJSObject],
156 v8String('message'),
157 v8String(''), // message: "" (valid)
158 v8String('stack'),
159 v8Int0, // stack: 0 (not a string)
160 [kEndJSObject, ...varint(2)]
161 );
162 await sendAndSurvive(env, 'domexception-non-string-stack', payload);
163 },
164};
165 
166// =========================================================================
167// Request tests (tag 5)
168// =========================================================================
169 
170// Request deserializer wire format:
171// readLengthDelimitedString [URL] + readValue [init dict]
172// If initDictHandler.tryUnwrap fails, throws DataCloneError.
173export const requestInvalidInit = {
174 async test(ctrl, env) {
175 const payload = makePayload(
176 V8_HEADER,
177 HOST_OBJECT,
178 varint(TAG_REQUEST),
179 // readLengthDelimitedString for URL: varint64(1) + "/"
180 varint(1),
181 [0x2f],
182 // readValue: an integer instead of a RequestInitializerDict
183 v8Int0
184 );
185 await sendAndSurvive(env, 'request-invalid-init', payload);
186 },
187};
188 
189// =========================================================================
190// Response tests (tag 6)
191// =========================================================================
192 
193// Response deserializer wire format:
194// readValue [body] + readValue [init dict]
195// If streamHandler.tryUnwrap fails on body, throws DataCloneError.
196export const responseInvalidBody = {
197 async test(ctrl, env) {
198 const payload = makePayload(
199 V8_HEADER,
200 HOST_OBJECT,
201 varint(TAG_RESPONSE),
202 // readValue for body: an integer instead of a ReadableStream
203 v8Int0
204 );
205 await sendAndSurvive(env, 'response-invalid-body', payload);
206 },
207};
208 
209export default {
210 async queue(batch, env, ctx) {
211 batch.ackAll();
212 },
213};