From c85e4dd239d0b4985de6391a5257ce0a9e84c5e2 Mon Sep 17 00:00:00 2001 From: izayl Date: Mon, 27 Jul 2026 17:32:42 +0800 Subject: [PATCH 1/3] fix(runtime): align fallback base64 globals Fallback atob/btoa should match Node and Web error behavior so guest code sees InvalidCharacterError for malformed input instead of runtime-specific decoder behavior. The conformance case locks the guest V8 globals against host Node. --- .../tests/builtin_conformance.rs | 35 +++++++++++++++++++ .../bridge-src/builtins/process.ts | 24 ++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/native-sidecar/tests/builtin_conformance.rs b/crates/native-sidecar/tests/builtin_conformance.rs index aea2370d7a..22f6413b1f 100644 --- a/crates/native-sidecar/tests/builtin_conformance.rs +++ b/crates/native-sidecar/tests/builtin_conformance.rs @@ -69,6 +69,7 @@ const BUILTIN_CONFORMANCE_CASES: &[&str] = &[ "events", "stream", "buffer", + "global_base64", "url", "stdlib_polyfill", "web_streams", @@ -3834,6 +3835,39 @@ fn buffer_concat_truncation_matches_host_node() { run_isolated_builtin_conformance_test("buffer-concat-truncation"); } +fn global_base64_conformance_matches_host_node() { + assert_conformance( + "global_base64", + r#" +function describeError(callback) { + try { + callback(); + return { threw: false }; + } catch (error) { + return { + threw: true, + name: error?.name ?? null, + code: error?.code ?? null, + message: error?.message ?? null, + }; + } +} + +console.log(JSON.stringify({ + atobText: atob("aGVsbG8="), + atobWhitespace: atob(" YQ== \n"), + atobNumberCoercion: atob(1234), + btoaText: btoa("hello"), + btoaNumberCoercion: btoa(1234), + invalidAtob: describeError(() => atob("%%%")), + invalidUrlSafeDashAtob: describeError(() => atob("AA-A")), + invalidUrlSafeUnderscoreAtob: describeError(() => atob("AA_A")), + invalidBtoa: describeError(() => btoa("✓")), +})); +"#, + ); +} + fn mkdtemp_sync_collision_safe_matches_host_node_impl() { let cwd = temp_dir("mkdtemp-sync-collision-safe"); let entrypoint = cwd.join("entry.mjs"); @@ -4874,6 +4908,7 @@ fn run_named_case(case_name: &str) { "events" => events_conformance_matches_host_node(), "stream" => stream_conformance_matches_host_node(), "buffer" => buffer_conformance_matches_host_node(), + "global_base64" => global_base64_conformance_matches_host_node(), "url" => url_conformance_matches_host_node(), "stdlib_polyfill" => stdlib_polyfill_conformance_matches_host_node(), "web_streams" => web_streams_conformance_matches_host_node(), diff --git a/packages/build-tools/bridge-src/builtins/process.ts b/packages/build-tools/bridge-src/builtins/process.ts index 726e7f66ce..67f03f0330 100644 --- a/packages/build-tools/bridge-src/builtins/process.ts +++ b/packages/build-tools/bridge-src/builtins/process.ts @@ -1141,6 +1141,9 @@ function setupGlobals() { g.Event = Event; g.CustomEvent = CustomEvent; g.EventTarget = EventTarget; + if (typeof g.DOMException === "undefined") { + g.DOMException = SandboxDOMException; + } if (typeof g.Buffer === "undefined") { g.Buffer = Buffer3; } @@ -1161,9 +1164,23 @@ function setupGlobals() { installBuiltinUtilFormatWithOptions(builtinUtilModule); if (typeof g.atob === "undefined" || typeof g.btoa === "undefined") { const base64 = require_base64_js(); + const createInvalidCharacterError = () => { + const error = new g.DOMException("Invalid character", "InvalidCharacterError"); + if (error.code === 0) error.code = 5; + return error; + }; if (typeof g.atob === "undefined") { g.atob = (value) => { - const bytes = base64.toByteArray(String(value)); + const input = String(value).replace(/[\t\n\f\r ]+/g, ""); + if (/[^A-Za-z0-9+/=]/.test(input)) { + throw createInvalidCharacterError(); + } + let bytes = new Uint8Array(0); + try { + bytes = base64.toByteArray(input); + } catch { + throw createInvalidCharacterError(); + } let decoded = ""; for (const byte of bytes) { decoded += String.fromCharCode(byte); @@ -1178,7 +1195,7 @@ function setupGlobals() { for (let index = 0; index < input.length; index += 1) { const code = input.charCodeAt(index); if (code > 255) { - throw new TypeError("Invalid character"); + throw createInvalidCharacterError(); } bytes[index] = code; } @@ -1195,9 +1212,6 @@ function setupGlobals() { if (typeof g.CryptoKey === "undefined") { g.CryptoKey = SandboxCryptoKey; } - if (typeof g.DOMException === "undefined") { - g.DOMException = SandboxDOMException; - } if (typeof g.crypto === "undefined") { g.crypto = builtinCryptoModule; } else { From ba0071436ab74816d528f87efc48980988df3264 Mon Sep 17 00:00:00 2001 From: izayl Date: Mon, 27 Jul 2026 17:35:51 +0800 Subject: [PATCH 2/3] test(runtime): cover forgiving base64 decoding Document why the fallback atob path cannot pass input directly to base64-js and cover the WHATWG unpadded-input behavior alongside rejected base64url characters. --- .../native-sidecar/tests/builtin_conformance.rs | 4 ++++ .../build-tools/bridge-src/builtins/process.ts | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/native-sidecar/tests/builtin_conformance.rs b/crates/native-sidecar/tests/builtin_conformance.rs index 22f6413b1f..f3ec4a6631 100644 --- a/crates/native-sidecar/tests/builtin_conformance.rs +++ b/crates/native-sidecar/tests/builtin_conformance.rs @@ -3855,11 +3855,15 @@ function describeError(callback) { console.log(JSON.stringify({ atobText: atob("aGVsbG8="), + atobUnpaddedSingleByte: atob("YQ"), + atobUnpaddedTwoBytes: atob("YWI"), atobWhitespace: atob(" YQ== \n"), atobNumberCoercion: atob(1234), btoaText: btoa("hello"), btoaNumberCoercion: btoa(1234), invalidAtob: describeError(() => atob("%%%")), + invalidShortAtob: describeError(() => atob("Y")), + invalidPaddingAtob: describeError(() => atob("AA=A")), invalidUrlSafeDashAtob: describeError(() => atob("AA-A")), invalidUrlSafeUnderscoreAtob: describeError(() => atob("AA_A")), invalidBtoa: describeError(() => btoa("✓")), diff --git a/packages/build-tools/bridge-src/builtins/process.ts b/packages/build-tools/bridge-src/builtins/process.ts index 67f03f0330..abcd8ee74d 100644 --- a/packages/build-tools/bridge-src/builtins/process.ts +++ b/packages/build-tools/bridge-src/builtins/process.ts @@ -1164,20 +1164,29 @@ function setupGlobals() { installBuiltinUtilFormatWithOptions(builtinUtilModule); if (typeof g.atob === "undefined" || typeof g.btoa === "undefined") { const base64 = require_base64_js(); - const createInvalidCharacterError = () => { - const error = new g.DOMException("Invalid character", "InvalidCharacterError"); + const createInvalidCharacterError = (message = "Invalid character") => { + const error = new g.DOMException(message, "InvalidCharacterError"); if (error.code === 0) error.code = 5; return error; }; if (typeof g.atob === "undefined") { g.atob = (value) => { + // WHATWG forgiving-base64 decode accepts ASCII whitespace and + // unpadded input, but rejects the base64url alphabet. base64-js + // implements RFC 4648 section 4 instead, so normalize the input and + // reject URL-safe characters before handing it over. const input = String(value).replace(/[\t\n\f\r ]+/g, ""); - if (/[^A-Za-z0-9+/=]/.test(input)) { + if (/[^A-Za-z0-9+/=]/.test(input) || /={3,}/.test(input) || /=[^=]/.test(input)) { throw createInvalidCharacterError(); } + const remainder = input.length % 4; + if (remainder === 1) { + throw createInvalidCharacterError("The string to be decoded is not correctly encoded."); + } + const normalizedInput = remainder === 2 ? `${input}==` : remainder === 3 ? `${input}=` : input; let bytes = new Uint8Array(0); try { - bytes = base64.toByteArray(input); + bytes = base64.toByteArray(normalizedInput); } catch { throw createInvalidCharacterError(); } From fafc1878c8a5a395f1ff1efc2560414e46fbaeaf Mon Sep 17 00:00:00 2001 From: izayl Date: Mon, 27 Jul 2026 18:26:58 +0800 Subject: [PATCH 3/3] fix(runtime): reject partial atob padding Only synthesize padding for unpadded forgiving-base64 input. Inputs that already contain padding must be complete and correctly positioned, matching WHATWG and Node atob behavior. --- crates/native-sidecar/tests/builtin_conformance.rs | 2 ++ packages/build-tools/bridge-src/builtins/process.ts | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/native-sidecar/tests/builtin_conformance.rs b/crates/native-sidecar/tests/builtin_conformance.rs index f3ec4a6631..2e219e05f6 100644 --- a/crates/native-sidecar/tests/builtin_conformance.rs +++ b/crates/native-sidecar/tests/builtin_conformance.rs @@ -3864,6 +3864,8 @@ console.log(JSON.stringify({ invalidAtob: describeError(() => atob("%%%")), invalidShortAtob: describeError(() => atob("Y")), invalidPaddingAtob: describeError(() => atob("AA=A")), + invalidPartialPaddingAtob: describeError(() => atob("YQ=")), + invalidOnlyPaddingAtob: describeError(() => atob("==")), invalidUrlSafeDashAtob: describeError(() => atob("AA-A")), invalidUrlSafeUnderscoreAtob: describeError(() => atob("AA_A")), invalidBtoa: describeError(() => btoa("✓")), diff --git a/packages/build-tools/bridge-src/builtins/process.ts b/packages/build-tools/bridge-src/builtins/process.ts index abcd8ee74d..c207d71691 100644 --- a/packages/build-tools/bridge-src/builtins/process.ts +++ b/packages/build-tools/bridge-src/builtins/process.ts @@ -1176,6 +1176,7 @@ function setupGlobals() { // implements RFC 4648 section 4 instead, so normalize the input and // reject URL-safe characters before handing it over. const input = String(value).replace(/[\t\n\f\r ]+/g, ""); + const hasPadding = input.includes("="); if (/[^A-Za-z0-9+/=]/.test(input) || /={3,}/.test(input) || /=[^=]/.test(input)) { throw createInvalidCharacterError(); } @@ -1183,7 +1184,10 @@ function setupGlobals() { if (remainder === 1) { throw createInvalidCharacterError("The string to be decoded is not correctly encoded."); } - const normalizedInput = remainder === 2 ? `${input}==` : remainder === 3 ? `${input}=` : input; + if (hasPadding && remainder !== 0) { + throw createInvalidCharacterError(); + } + const normalizedInput = !hasPadding && remainder === 2 ? `${input}==` : !hasPadding && remainder === 3 ? `${input}=` : input; let bytes = new Uint8Array(0); try { bytes = base64.toByteArray(normalizedInput);