From c3bf4053e48462f80f801ddd1d573a1bfc1b210c Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:49:29 -0300 Subject: [PATCH] fix(providers): scope TinyCMS DOM shims to the call, not the process (#12072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initTinyCmsWasm()/generateSecurePayload() installed global.window/document DOM shims via setupDomMocks() but never called the returned restore callback. On an npm-global install the Next.js dashboard SSR runs in the same Node process, so after the first TinyCMS request every later SSR render observed the leaked fake document, whose createElement() returns null for anything but 'canvas' — turning the next render into a 500. Wrap both call sites in install -> use -> restore (try/finally) so the shims are scoped to just the call instead of the process lifetime. Also fixes a second, smaller bug found while tracing the client-visible symptom: fetchInterceptionToggles() called res.json() without checking res.ok first, so a non-JSON error body (e.g. the plain-text 500 above) surfaced as a raw SyntaxError in the interceptionLoadError toast instead of a clean HTTP message. --- .../fixes/12072-tinycms-dom-shim-leak.md | 1 + open-sse/executors/tinycmsSigner.ts | 34 ++++-- .../ProviderInterceptionSection.tsx | 13 ++- .../ProviderInterceptionSection.test.tsx | 103 ++++++++++++++++++ .../repro-12072-tinycms-dom-shim-leak.test.ts | 63 +++++++++++ 5 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 changelog.d/fixes/12072-tinycms-dom-shim-leak.md create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/ProviderInterceptionSection.test.tsx create mode 100644 tests/unit/repro-12072-tinycms-dom-shim-leak.test.ts diff --git a/changelog.d/fixes/12072-tinycms-dom-shim-leak.md b/changelog.d/fixes/12072-tinycms-dom-shim-leak.md new file mode 100644 index 0000000000..60a4a8676a --- /dev/null +++ b/changelog.d/fixes/12072-tinycms-dom-shim-leak.md @@ -0,0 +1 @@ +- fix(providers): scope TinyCMS Web signer's DOM shims to each call instead of leaking them for the process lifetime, and surface a clean HTTP status on a non-JSON interception-toggles error (#12072) diff --git a/open-sse/executors/tinycmsSigner.ts b/open-sse/executors/tinycmsSigner.ts index f62db10eea..c2f378bfa4 100644 --- a/open-sse/executors/tinycmsSigner.ts +++ b/open-sse/executors/tinycmsSigner.ts @@ -467,13 +467,21 @@ let wasmInitialized = false; export async function initTinyCmsWasm() { if (wasmInitialized) return; // Install the DOM shims the wasm-bindgen glue expects before instantiating - // the module (see setupDomMocks() above). Left installed for the process - // lifetime — generateSecurePayload() keeps calling into the same canvas - // shims on every invocation, not just at init. - setupDomMocks(); - const wasmBuffer = Buffer.from(WASM_BASE64, 'base64'); - await __wbg_init(wasmBuffer); - wasmInitialized = true; + // the module (see setupDomMocks() above), and restore them right after — + // scoped to just this init call instead of the process lifetime. This + // process runs the Next.js dashboard SSR too (npm-global install), so + // leaving global.window/document installed here would poison every later + // SSR render (#12072). generateSecurePayload() below re-installs its own + // shims around each call, since the wasm-bindgen glue reaches back into + // document.createElement/getContext on every invocation, not just at init. + const restore = setupDomMocks(); + try { + const wasmBuffer = Buffer.from(WASM_BASE64, 'base64'); + await __wbg_init(wasmBuffer); + wasmInitialized = true; + } finally { + restore(); + } } // Add type bindings @@ -501,5 +509,15 @@ export function generateSecurePayload( client_ip: string, difficulty: number ): SecurePayload { - return generate_secure_payload(username, timestamp, nonce_js, challenge, client_ip, difficulty) as SecurePayload; + // Scope the DOM shims to just this synchronous call (install -> use -> + // restore) instead of relying on whatever initTinyCmsWasm() left behind + // — that call now restores its own shims immediately, and this is fully + // synchronous (no await between install and restore), so nothing else on + // Node's single-threaded event loop can observe the shim in between. + const restore = setupDomMocks(); + try { + return generate_secure_payload(username, timestamp, nonce_js, challenge, client_ip, difficulty) as SecurePayload; + } finally { + restore(); + } } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx index d66ac0c5c9..da6c708dd3 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx @@ -32,8 +32,15 @@ type Translate = (key: string, values?: Record) => string; const DEFAULT_TOGGLES: InterceptionToggles = { interceptSearch: false, interceptFetch: false }; +async function throwOnErrorResponse(res: Response): Promise { + if (res.ok) return; + const errData = await res.json().catch(() => ({})); + throw new Error(errData.error || `HTTP ${res.status}`); +} + async function fetchInterceptionToggles(providerId: string): Promise { const res = await fetch(`/api/providers/${providerId}/interception-rules`); + await throwOnErrorResponse(res); const data = await res.json(); return { interceptSearch: data?.interceptSearch === true, @@ -41,12 +48,6 @@ async function fetchInterceptionToggles(providerId: string): Promise { - if (res.ok) return; - const errData = await res.json().catch(() => ({})); - throw new Error(errData.error || `HTTP ${res.status}`); -} - async function putInterceptionToggles( providerId: string, toggles: InterceptionToggles diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/ProviderInterceptionSection.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/ProviderInterceptionSection.test.tsx new file mode 100644 index 0000000000..e9dd9abb82 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/ProviderInterceptionSection.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +// +// Regression test for issue #12072 (second, smaller bug found while fixing +// the TinyCMS DOM-shim leak): fetchInterceptionToggles() used to call +// `await res.json()` without checking `res.ok` first, so a non-JSON error +// body (e.g. a plain-text 500 from the poisoned-SSR bug) surfaced as a raw +// `SyntaxError` inside the `interceptionLoadError` toast instead of a clean +// `HTTP ` message. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ProviderInterceptionSection from "../ProviderInterceptionSection"; + +// Stable references: the component's load effect depends on `t` and `notify`, +// so a mock returning a fresh closure/object on every render would re-fire the +// effect after every setState (an infinite loop) instead of running once. +const stableTranslate = (key: string, values?: Record) => + values ? `${key}:${JSON.stringify(values)}` : key; +vi.mock("next-intl", () => ({ + useTranslations: () => stableTranslate, +})); + +const notifyError = vi.fn(); +const stableNotify = { error: notifyError, success: vi.fn() }; +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => stableNotify, +})); + +const cleanups: Array<() => void> = []; + +function renderComponent(node: React.ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(node)); + cleanups.push(() => { + act(() => root.unmount()); + container.remove(); + }); + return container; +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("ProviderInterceptionSection (#12072)", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + notifyError.mockClear(); + }); + + afterEach(() => { + while (cleanups.length) cleanups.pop()?.(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + }); + + it("surfaces a clean HTTP status message when GET returns a non-JSON 500 body", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ + ok: false, + status: 500, + json: () => Promise.reject(new SyntaxError('Unexpected token \'I\', "Internal S"...')), + } as unknown as Response) + ) + ); + + renderComponent(); + await flush(); + + expect(notifyError).toHaveBeenCalledTimes(1); + const [message] = notifyError.mock.calls[0] as [string]; + expect(message).toContain("HTTP 500"); + expect(message).not.toContain("Unexpected token"); + expect(message).not.toContain("SyntaxError"); + }); + + it("loads toggles normally when GET returns a valid JSON body", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ interceptSearch: true, interceptFetch: false }), + } as unknown as Response) + ) + ); + + renderComponent(); + await flush(); + + expect(notifyError).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/repro-12072-tinycms-dom-shim-leak.test.ts b/tests/unit/repro-12072-tinycms-dom-shim-leak.test.ts new file mode 100644 index 0000000000..7e7b8e839b --- /dev/null +++ b/tests/unit/repro-12072-tinycms-dom-shim-leak.test.ts @@ -0,0 +1,63 @@ +/** + * Regression test for issue #12072. + * + * initTinyCmsWasm() / generateSecurePayload() used to call setupDomMocks() + * and never invoke the restore callback it returns, so global.window / + * global.document / HTMLCanvasElement remained installed on the Node + * process for its entire lifetime. On an npm-global install the Next.js + * dashboard SSR runs in that same process, so after the first TinyCMS + * request every SSR render observed a fake `document` whose + * createElement() returns null for anything but 'canvas' — which turned + * the following SSR render into a plain-text 500. + * + * This test proves the shims are scoped to the call (installed, used, + * restored) instead of leaking past it, directly against + * tinycmsSigner.ts, without needing a live TinyCMS network call or a + * running Next.js server. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +test("initTinyCmsWasm does not leave global.window/document installed after it resolves", async () => { + const g = global as Record; + + // Sanity: nothing must be present before we start, otherwise the + // assertions below prove nothing. + assert.equal("window" in g, false, "test process must not already have global.window"); + assert.equal("document" in g, false, "test process must not already have global.document"); + + const { initTinyCmsWasm } = await import("../../open-sse/executors/tinycmsSigner.ts"); + + await initTinyCmsWasm(); + + assert.equal( + typeof g.window, + "undefined", + "REGRESSION (#12072): global.window leaked past initTinyCmsWasm() — this is what makes " + + "`typeof window !== \"undefined\"` true for every subsequent SSR render in the same process" + ); + assert.equal( + typeof g.document, + "undefined", + "REGRESSION (#12072): global.document leaked past initTinyCmsWasm()" + ); +}); + +test("generateSecurePayload does not leave global.window/document installed after it returns", async () => { + const g = global as Record; + + const { generateSecurePayload } = await import("../../open-sse/executors/tinycmsSigner.ts"); + + generateSecurePayload("user", String(Date.now()), "nonce", "challenge", "127.0.0.1", 1); + + assert.equal( + typeof g.window, + "undefined", + "REGRESSION (#12072): global.window leaked past generateSecurePayload()" + ); + assert.equal( + typeof g.document, + "undefined", + "REGRESSION (#12072): global.document leaked past generateSecurePayload()" + ); +});