Compare commits

..

1 Commits

Author SHA1 Message Date
diegosouzapw
c3bf4053e4 fix(providers): scope TinyCMS DOM shims to the call, not the process (#12072)
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 <status> message.
2026-09-10 14:49:29 -03:00
13 changed files with 212 additions and 96 deletions

View File

@@ -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)

View File

@@ -1 +0,0 @@
- fix(a2a): accept the dashboard's own session cookie on /a2a so "Run message/send" no longer fails with "Unauthorized: missing or invalid API key" (#12888)

View File

@@ -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();
}
}

View File

@@ -201,7 +201,6 @@ export default function A2ADashboardPage() {
const response = await fetch("/a2a", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
jsonrpc: "2.0",
id: "dashboard-send",
@@ -235,7 +234,6 @@ export default function A2ADashboardPage() {
const response = await fetch("/a2a", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
jsonrpc: "2.0",
id: "dashboard-stream",

View File

@@ -32,8 +32,15 @@ type Translate = (key: string, values?: Record<string, string>) => string;
const DEFAULT_TOGGLES: InterceptionToggles = { interceptSearch: false, interceptFetch: false };
async function throwOnErrorResponse(res: Response): Promise<void> {
if (res.ok) return;
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `HTTP ${res.status}`);
}
async function fetchInterceptionToggles(providerId: string): Promise<InterceptionToggles> {
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<Interceptio
};
}
async function throwOnErrorResponse(res: Response): Promise<void> {
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

View File

@@ -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 <status>` 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<string, string>) =>
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(<ProviderInterceptionSection providerId="openai" />);
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(<ProviderInterceptionSection providerId="openai" />);
await flush();
expect(notifyError).not.toHaveBeenCalled();
});
});

View File

@@ -187,7 +187,7 @@ export async function POST(req: NextRequest) {
const tm = getTaskManager();
// GHSA-jcm5-6wpp-wjj8: scope every task read/mutation below to the caller's
// owner id (hashed API key; undefined under the keyless local-first posture).
const callerOwner = await resolveA2AOwner(req);
const callerOwner = resolveA2AOwner(req);
// A2A 1.0 method-name compatibility (SendMessage → message/send, etc.)
const isV1Method = method in V1_METHOD_ALIASES;

View File

@@ -35,7 +35,7 @@ export async function authorizeA2ATaskRoute(request: Request): Promise<A2ARestAu
const apiKey = extractApiKey(request);
if (isRequireApiKeyEnabled()) {
if (apiKey && (await isValidApiKey(apiKey))) return { owner: await resolveA2AOwner(request) };
if (apiKey && (await isValidApiKey(apiKey))) return { owner: resolveA2AOwner(request) };
const managementError = await requireManagementAuth(request, {
invalidApiKeyStatus: 401,
alwaysRequireAuth: true,
@@ -46,6 +46,6 @@ export async function authorizeA2ATaskRoute(request: Request): Promise<A2ARestAu
const managementError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
if (managementError === null) return { owner: undefined };
if (apiKey && (await isValidApiKey(apiKey))) return { owner: await resolveA2AOwner(request) };
if (apiKey && (await isValidApiKey(apiKey))) return { owner: resolveA2AOwner(request) };
return managementError;
}

View File

@@ -11,7 +11,6 @@
import { createHash, timingSafeEqual } from "crypto";
import type { NextRequest } from "next/server";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
function tokensMatch(provided: string, expected: string): boolean {
@@ -30,17 +29,12 @@ function tokensMatch(provided: string, expected: string): boolean {
export async function authenticateA2ARequest(req: NextRequest | Request): Promise<boolean> {
const apiKey = extractApiKey(req as NextRequest);
if (isRequireApiKeyEnabled()) {
if (apiKey) return isValidApiKey(apiKey);
// #12888: mirror clientApiPolicy's dashboard-session fallback so the
// dashboard's own A2A playground (no Authorization header, session
// cookie only) is accepted the same way /api/v1/* already accepts it.
return isDashboardSessionAuthenticated(req);
return apiKey ? await isValidApiKey(apiKey) : false;
}
const configuredKey = process.env.OMNIROUTE_API_KEY;
if (configuredKey) {
if (apiKey) return tokensMatch(apiKey, configuredKey);
return isDashboardSessionAuthenticated(req);
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
}
// No API key required and none configured — allow (keyless local-first).
@@ -49,15 +43,11 @@ export async function authenticateA2ARequest(req: NextRequest | Request): Promis
/**
* Owner id for task scoping (GHSA-jcm5-6wpp-wjj8): a stable hash of the
* caller's API key, `"dashboard"` for a session-authenticated caller with no
* API key (#12888 — keeps dashboard-originated tasks scoped consistently
* instead of falling into the ownerless keyless bucket), or `undefined` when
* the call carries neither (keyless posture — ownerless tasks stay visible to
* everyone, by design).
* caller's API key, or `undefined` when the call carries no key (keyless
* posture — ownerless tasks stay visible to everyone, by design).
*/
export async function resolveA2AOwner(req: NextRequest | Request): Promise<string | undefined> {
export function resolveA2AOwner(req: NextRequest | Request): string | undefined {
const apiKey = extractApiKey(req as NextRequest);
if (apiKey) return createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
if (await isDashboardSessionAuthenticated(req)) return "dashboard";
return undefined;
if (!apiKey) return undefined;
return createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
}

View File

@@ -1,57 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
process.env.OMNIROUTE_API_KEY = "test-configured-key-12888";
process.env.JWT_SECRET = "test-jwt-secret-for-probe-12888";
const { authenticateA2ARequest, resolveA2AOwner } = await import("../../src/lib/a2a/authenticate.ts");
const { isDashboardSessionAuthenticated } = await import("../../src/shared/utils/apiAuth.ts");
const { SignJWT } = await import("jose");
async function buildSessionRequest(): Promise<unknown> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const sessionToken = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30d")
.sign(secret);
return {
headers: new Headers({ cookie: `auth_token=${sessionToken}` }),
cookies: { get: () => undefined },
nextUrl: { searchParams: new URLSearchParams() },
url: "http://localhost:20128/a2a",
};
}
test("A2A route accepts a dashboard-session-authenticated request with no Authorization header (bug #12888)", async () => {
const fakeRequest = await buildSessionRequest();
const dashboardSessionOk = await isDashboardSessionAuthenticated(fakeRequest as never);
assert.equal(dashboardSessionOk, true, "expected the dashboard session cookie itself to be valid");
const a2aAuthOk = await authenticateA2ARequest(fakeRequest as never);
assert.equal(
a2aAuthOk,
true,
"/a2a should accept the dashboard's own session-authenticated requests " +
"(matching /api/v1/* behavior) but currently requires an explicit Authorization header"
);
});
test("A2A route still rejects a request with neither a valid API key nor a valid session cookie", async () => {
const fakeRequest = {
headers: new Headers(),
cookies: { get: () => undefined },
nextUrl: { searchParams: new URLSearchParams() },
url: "http://localhost:20128/a2a",
};
const a2aAuthOk = await authenticateA2ARequest(fakeRequest as never);
assert.equal(a2aAuthOk, false, "unauthenticated, keyless requests must still be rejected");
});
test("resolveA2AOwner() returns a stable 'dashboard' owner id for a session-authenticated caller with no API key", async () => {
const fakeRequest = await buildSessionRequest();
const owner = await resolveA2AOwner(fakeRequest as never);
assert.equal(owner, "dashboard");
});

View File

@@ -154,7 +154,7 @@ test("GET history owner-scoping: an API-key caller sees only its own + ownerless
const ownerAReq = new Request("http://localhost/api/a2a/tasks/history", {
headers: AUTH_HEADERS,
});
const ownerA = await resolveA2AOwner(ownerAReq as never);
const ownerA = resolveA2AOwner(ownerAReq as never);
assert.ok(ownerA, "the shared key resolves to a stable owner hash");
seedRow({ id: "owned-by-a", apiKeyId: ownerA ?? null, createdAt: "2026-01-01T00:00:00.000Z" });

View File

@@ -123,7 +123,7 @@ describe("REST /api/a2a/tasks/[id] — authentication (GHSA-jcm5)", () => {
// And the same task IS visible to its owner (owner hash derived from the key).
const owned = tm.createTask(
{ skill: "smart-routing", messages: [] },
await resolveA2AOwner(req as never)
resolveA2AOwner(req as never)
);
const res2 = await restGet.GET(
new Request(`http://localhost/api/a2a/tasks/${owned.id}`, {

View File

@@ -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<string, unknown>;
// 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<string, unknown>;
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()"
);
});