File
Blob: src/workerd/api/tests/deserialize-hardening-test.js
| 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 | |
| 21 | import assert from 'node:assert'; |
| 22 | |
| 23 | // Helper: encode a uint32 as a varint (LEB128) |
| 24 | function 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 |
| 36 | const V8_HEADER = [0xff, 0x0f]; |
| 37 | const HOST_OBJECT = [0x5c]; |
| 38 | |
| 39 | // V8 value tags |
| 40 | const kOneByteString = 0x22; |
| 41 | const kInt32 = 0x49; |
| 42 | const kBeginJSObject = 0x6f; |
| 43 | const kEndJSObject = 0x7b; |
| 44 | |
| 45 | // Workerd serialization tags |
| 46 | const TAG_HEADERS = 4; |
| 47 | const TAG_REQUEST = 5; |
| 48 | const TAG_RESPONSE = 6; |
| 49 | const TAG_DOM_EXCEPTION_LEGACY = 7; |
| 50 | |
| 51 | function 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 |
| 56 | function 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 |
| 62 | const v8Int0 = [kInt32, 0x00]; |
| 63 | |
| 64 | // Helper to send a crafted payload and assert the process survives. |
| 65 | async 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. |
| 84 | export 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) |
| 106 | export 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 |
| 123 | export 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 |
| 145 | export 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. |
| 173 | export 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. |
| 196 | export 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 | |
| 209 | export default { |
| 210 | async queue(batch, env, ctx) { |
| 211 | batch.ackAll(); |
| 212 | }, |
| 213 | }; |