From ced6ee72b3496f7246a279e3dbe8d9acaa966a9f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 11 Sep 2026 22:04:41 -0300 Subject: [PATCH] fix(providers): scope TinyCMS DOM shims to the call, not the process (#12072) (#13254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit. Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them. - ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on #13243) - `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK - complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline - 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs - `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for #13278 and #13276, annotated and landed on #13243 ⚠️ base-red inherited: #12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch. --- .../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()" + ); +});