From 8a1edda52ac5e5bb8e36af61eb661ca8614179ce Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Fri, 14 Aug 2026 10:55:36 -0300 Subject: [PATCH] feat(dashboard): opt-in CSP relaxation for VS Code Simple Browser embedding (#10273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OmniRoute ships `frame-ancestors 'none'` + `X-Frame-Options: DENY` on every route, so the VS Code Simple Browser renders a blank tab — which is what the OmniCopilot extension's `dashboardOpen: "editor"` mode uses. Add the build-time opt-in `DASHBOARD_ALLOW_EMBED=vscode`. When set, the HTML pages are served with `frame-ancestors 'self' vscode-webview:` and without `X-Frame-Options` (XFO cannot express a custom scheme and would veto the relaxed CSP). Unset — the default — nothing changes. The API surface stays strictly unframable in both modes. Its exclusion list is derived from the `rewrites()` table plus `/api`, `/a2a`, `/healthz`, so a future root-level API alias is excluded automatically instead of silently becoming framable. The two generated `source` patterns are complementary by construction: every pathname matches exactly one, so there is no gap (a page with no security headers) and no order-dependent overlap. Closes #10273 --- .env.example | 11 + .../features/10273-dashboard-embed-csp.md | 1 + docs/reference/ENVIRONMENT.md | 1 + next.config.mjs | 28 +- scripts/build/dashboardEmbed.mjs | 142 ++++++++ tests/unit/dashboard-embed-csp-10273.test.ts | 324 ++++++++++++++++++ 6 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 changelog.d/features/10273-dashboard-embed-csp.md create mode 100644 scripts/build/dashboardEmbed.mjs create mode 100644 tests/unit/dashboard-embed-csp-10273.test.ts diff --git a/.env.example b/.env.example index 8472bf8743..cf22f8da53 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,17 @@ PORT=20128 # stay consistent without relying on window.location.origin alone: # NEXT_PUBLIC_BASE_URL=https://host/omniroute +# Opt-in iframe embedding of the OmniRoute HTML pages (issue #10273). Off by default: +# every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`, which is why the +# VS Code Simple Browser (used by the OmniCopilot extension's "Open Dashboard → editor" +# mode) renders a blank tab. Set this to `vscode` to switch the HTML pages — dashboard, +# login, docs, landing — to `frame-ancestors 'self' vscode-webview:` and drop +# X-Frame-Options for them (XFO cannot express a custom scheme). The API surface +# (/api, /v1, /v1beta, /a2a, /healthz and the root-level aliases) keeps the strict +# headers regardless. Only `vscode` is recognised; `1`/`true` do NOT enable it. +# Used by: next.config.mjs via scripts/build/dashboardEmbed.mjs — build-time, rebuild after changing. +# DASHBOARD_ALLOW_EMBED=vscode + # Split-port mode: serve Dashboard and API on separate ports for network isolation. # Used by: src/lib/runtime/ports.ts — overrides PORT for each service. # API_PORT=20129 diff --git a/changelog.d/features/10273-dashboard-embed-csp.md b/changelog.d/features/10273-dashboard-embed-csp.md new file mode 100644 index 0000000000..8627e0ddbe --- /dev/null +++ b/changelog.d/features/10273-dashboard-embed-csp.md @@ -0,0 +1 @@ +- feat(dashboard): opt-in `DASHBOARD_ALLOW_EMBED=vscode` relaxes CSP `frame-ancestors` to `'self' vscode-webview:` and drops `X-Frame-Options` for HTML pages only, so the dashboard renders inside the VS Code Simple Browser (OmniCopilot). Default posture unchanged — API routes stay unframable (#10273) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ede96d13e5..908538f16d 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -123,6 +123,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | | `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs`, `scripts/docker/ensure-docker-base-path.mjs` | URL subpath for serving OmniRoute behind a reverse proxy (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. In Docker the value is baked during `docker build` (`ARG OMNIROUTE_BASE_PATH`); pre-built root images can apply a different runtime value once at container start before Next.js boots. Set `NEXT_PUBLIC_BASE_URL` to the public origin including the same subpath. | | `NEXT_PUBLIC_OMNIROUTE_BASE_PATH` | _(empty = root)_ | `src/shared/hooks/useDisplayBaseUrl.ts` | Browser-visible mirror of `OMNIROUTE_BASE_PATH`, inlined at build time so the dashboard endpoint display shows `https://host/omniroute/v1` instead of `https://host/v1`. Falls back to `OMNIROUTE_BASE_PATH` when unset. Rebuild after changing (Next `basePath` is build-time). | +| `DASHBOARD_ALLOW_EMBED` | _(unset = never framable)_ | `next.config.mjs`, `scripts/build/dashboardEmbed.mjs` | Opt-in iframe embedding of the HTML pages. Unset, every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`. Set to `vscode` to serve the pages (dashboard, login, docs, landing) with `frame-ancestors 'self' vscode-webview:` and no `X-Frame-Options`, so the VS Code Simple Browser can render them (OmniCopilot's `dashboardOpen: "editor"` mode). The API surface (`/api`, `/v1`, `/v1beta`, `/a2a`, `/healthz`, root-level aliases) keeps the strict headers either way. Only `vscode` is recognised — `1`/`true` do not enable it. Build-time: rebuild after changing. | | `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | | `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | | `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | diff --git a/next.config.mjs b/next.config.mjs index e1c2e7cc1a..34be60c02e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -4,6 +4,11 @@ import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs"; import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs"; +import { + buildSecurityHeaderRules, + nonPageRoutePrefixes, + resolveDashboardEmbedMode, +} from "./scripts/build/dashboardEmbed.mjs"; const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); const distDir = process.env.NEXT_DIST_DIR || ".build/next"; @@ -75,6 +80,11 @@ function isNextIntlExtractorDynamicImportWarning(warning) { // for security-sensitive environments. See docs/security/SOCKET_DEV_FINDINGS.md. const isMinimalBuild = process.env.OMNIROUTE_BUILD_PROFILE === "minimal"; +// #10273: `null` unless the operator opts in with DASHBOARD_ALLOW_EMBED=vscode. Read at build +// time like every other knob in this file (OMNIROUTE_BASE_PATH, OMNIROUTE_BUILD_PROFILE, …), +// so changing it requires a rebuild. See scripts/build/dashboardEmbed.mjs. +const dashboardEmbedMode = resolveDashboardEmbedMode(process.env); + const minimalBuildAliases = isMinimalBuild ? { "@/mitm/cert/install": "./src/mitm/cert/install.stub.ts", @@ -389,11 +399,21 @@ const nextConfig = { }, async headers() { + // #10273: opt-in embedding for the VS Code Simple Browser (OmniCopilot). Off by default — + // `securityHeaders` then applies to `/:path*` exactly as it always has. When the operator + // sets DASHBOARD_ALLOW_EMBED=vscode, buildSecurityHeaderRules() splits that catch-all into + // two complementary rules: the API surface keeps `frame-ancestors 'none'` + X-Frame-Options, + // the HTML pages get `frame-ancestors 'self' vscode-webview:` and no X-Frame-Options. + // The exclusion list is DERIVED from the rewrite table below (self-reference is safe — the + // config object is fully built by the time Next calls headers()), so a future root-level API + // alias is excluded automatically instead of silently becoming framable. + const embedRules = buildSecurityHeaderRules({ + mode: dashboardEmbedMode, + securityHeaders, + prefixes: dashboardEmbedMode ? nonPageRoutePrefixes(await nextConfig.rewrites()) : [], + }); return [ - { - source: "/:path*", - headers: securityHeaders, - }, + ...embedRules, // G-10: allow OmniRoute's own dashboard to embed the 9Router UI via our reverse proxy. // `frame-ancestors 'self'` overrides the global `frame-ancestors 'none'` only for this // path. The route is already LOCAL_ONLY (routeGuard.ts) so remote origins cannot reach it. diff --git a/scripts/build/dashboardEmbed.mjs b/scripts/build/dashboardEmbed.mjs new file mode 100644 index 0000000000..338cfeab8f --- /dev/null +++ b/scripts/build/dashboardEmbed.mjs @@ -0,0 +1,142 @@ +/** + * Opt-in iframe embedding for OmniRoute's HTML pages (#10273). + * + * OmniRoute ships `frame-ancestors 'none'` + `X-Frame-Options: DENY` on every route, which + * is the right default for a proxy that holds provider credentials. The OmniCopilot VS Code + * extension, however, renders the dashboard inside the built-in Simple Browser — an iframe + * whose ancestor is a `vscode-webview:` document — so the strict default paints a blank tab. + * + * Setting `DASHBOARD_ALLOW_EMBED=vscode` at build time swaps the page surface to + * `frame-ancestors 'self' vscode-webview:` and drops `X-Frame-Options` for those pages. + * XFO has no syntax for a custom scheme, and keeping `DENY` alongside a permissive + * `frame-ancestors` would still block the frame in engines that honour XFO first — dropping + * it is required, not cosmetic. (Modern engines ignore XFO entirely once `frame-ancestors` + * is present, so nothing is lost where CSP is supported.) + * + * The API surface is deliberately left out: `/api/*`, `/v1*`, `/a2a`, `/healthz` and every + * root-level rewrite alias keep the strict headers even in embed mode. Those are the + * Hard-Rule-15/17 process-spawning and proxy surfaces and never need framing. + * + * Build-time by design: Next.js resolves `headers()` when the config loads, matching the + * existing env-driven knobs in `next.config.mjs` (`OMNIROUTE_BASE_PATH`, + * `OMNIROUTE_BUILD_PROFILE`, …). Changing the value requires a rebuild. + */ + +export const DASHBOARD_EMBED_ENV = "DASHBOARD_ALLOW_EMBED"; + +/** Ancestor allow-list per supported embed mode. Adding a mode here is the only extension point. */ +export const EMBED_FRAME_ANCESTORS = Object.freeze({ + // `vscode-webview:` is the scheme VS Code assigns to webview/Simple Browser documents. + // `'self'` keeps OmniRoute's own same-origin frames (e.g. the G-10 9Router embed) working. + vscode: "'self' vscode-webview:", +}); + +/** The strict `frame-ancestors` token the CSP carries by default. */ +export const STRICT_FRAME_ANCESTORS = "frame-ancestors 'none'"; + +/** + * App-router surfaces that are not HTML pages and have no `rewrites()` alias to derive them + * from. Everything else in the exclusion list comes from the rewrite table, so a future API + * alias is excluded automatically instead of silently becoming framable. + */ +export const STATIC_NON_PAGE_PREFIXES = Object.freeze(["api", "a2a", "healthz"]); + +/** + * Resolve the opt-in embed mode from the environment. + * Unknown / truthy-looking values (`1`, `true`, `on`) intentionally do NOT enable embedding: + * the operator must name the ancestor family they are opening up. + * + * @param {Record} env + * @returns {"vscode" | null} + */ +export function resolveDashboardEmbedMode(env = process.env) { + const raw = env?.[DASHBOARD_EMBED_ENV]; + if (typeof raw !== "string") return null; + const normalized = raw.trim().toLowerCase(); + return Object.hasOwn(EMBED_FRAME_ANCESTORS, normalized) ? normalized : null; +} + +/** + * The first path segment of every route that must stay unframable, derived from the + * `rewrites()` table plus the static app-router API surfaces. + * + * @param {{ source: string }[]} rewriteRules + * @returns {string[]} sorted, de-duplicated prefixes + */ +export function nonPageRoutePrefixes(rewriteRules = []) { + const prefixes = new Set(STATIC_NON_PAGE_PREFIXES); + for (const { source } of rewriteRules) { + const first = source.replace(/^\//, "").split("/")[0]; + // Skip parameterised first segments (`/:path*`) — they would exclude the whole site. + if (first && !first.startsWith(":")) prefixes.add(first); + } + return [...prefixes].sort(); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Two complementary Next.js `source` patterns built from the same prefix list, so the union + * covers every pathname exactly once — no gap (a page with no security headers) and no + * overlap (an order-dependent merge). + * + * @param {string[]} prefixes + * @returns {{ nonPageSource: string, pageSource: string }} + */ +export function complementarySources(prefixes) { + const alternation = prefixes.map(escapeRegExp).join("|"); + const boundary = `(?:${alternation})(?:/|$)`; + return { + nonPageSource: `/((?=${boundary}).*)`, + pageSource: `/((?!${boundary}).*)`, + }; +} + +/** + * Swap only the `frame-ancestors` token of an existing CSP, leaving every other directive + * byte-identical. + * + * @param {string} contentSecurityPolicy + * @param {"vscode"} mode + */ +export function relaxFrameAncestors(contentSecurityPolicy, mode) { + return contentSecurityPolicy.replace( + STRICT_FRAME_ANCESTORS, + `frame-ancestors ${EMBED_FRAME_ANCESTORS[mode]}` + ); +} + +/** + * Build the `headers()` rules carrying OmniRoute's baseline security headers. + * + * With embedding off this returns the single catch-all rule the config has always had, so a + * default build is unchanged. With embedding on it returns two complementary rules: the API + * surface keeps the strict headers, the page surface gets the relaxed CSP and no XFO. + * + * @param {{ + * mode: "vscode" | null, + * securityHeaders: { key: string, value: string }[], + * prefixes?: string[], + * }} options + * @returns {{ source: string, headers: { key: string, value: string }[] }[]} + */ +export function buildSecurityHeaderRules({ mode, securityHeaders, prefixes = [] }) { + if (!mode) return [{ source: "/:path*", headers: securityHeaders }]; + + const { nonPageSource, pageSource } = complementarySources(prefixes); + const pageHeaders = securityHeaders + // X-Frame-Options cannot express `vscode-webview:` and would veto the relaxed CSP. + .filter((header) => header.key !== "X-Frame-Options") + .map((header) => + header.key === "Content-Security-Policy" + ? { key: header.key, value: relaxFrameAncestors(header.value, mode) } + : header + ); + + return [ + { source: nonPageSource, headers: securityHeaders }, + { source: pageSource, headers: pageHeaders }, + ]; +} diff --git a/tests/unit/dashboard-embed-csp-10273.test.ts b/tests/unit/dashboard-embed-csp-10273.test.ts new file mode 100644 index 0000000000..16951bcb5a --- /dev/null +++ b/tests/unit/dashboard-embed-csp-10273.test.ts @@ -0,0 +1,324 @@ +// Regression guard for #10273: opt-in CSP relaxation so OmniRoute's HTML pages can be +// embedded in the VS Code Simple Browser (the OmniCopilot extension's `dashboardOpen: +// "editor"` mode renders them inside a `vscode-webview:` iframe). +// +// The default posture is UNCHANGED and must stay that way: `frame-ancestors 'none'` + +// `X-Frame-Options: DENY` on every route. Only when the operator explicitly sets +// DASHBOARD_ALLOW_EMBED=vscode do the HTML pages switch to +// `frame-ancestors 'self' vscode-webview:` and drop `X-Frame-Options` (XFO has no syntax +// for a custom scheme, and CSP frame-ancestors supersedes it in modern engines). +// +// The API surface (`/api`, `/v1`, `/v1beta`, the root-level rewrite aliases, `/a2a`, +// `/healthz`) must keep the strict headers even in embed mode — those are the +// Hard-Rule-15/17 surfaces and never need framing. +// +// These tests assert EFFECTIVE headers, not config shape: `effectiveHeaders()` replays +// Next.js's own matching + last-wins merge (see +// node_modules/next/dist/server/lib/router-utils/resolve-routes.js, `resHeaders[key] = value`) +// so a rule that silently stops matching, or an ordering regression, fails here. + +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { getPathMatch } from "next/dist/shared/lib/router/utils/path-match.js"; + +import { + DASHBOARD_EMBED_ENV, + EMBED_FRAME_ANCESTORS, + resolveDashboardEmbedMode, + nonPageRoutePrefixes, + buildSecurityHeaderRules, +} from "../../scripts/build/dashboardEmbed.mjs"; + +const modulePath = path.join(process.cwd(), "next.config.mjs"); +const originalEmbed = process.env[DASHBOARD_EMBED_ENV]; + +interface HeaderEntry { + key: string; + value: string; +} +interface HeaderRule { + source: string; + headers: HeaderEntry[]; +} + +async function loadHeaders(label: string): Promise { + const mod = await import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}`); + return mod.default.headers(); +} + +/** Replay Next.js's header matching + last-wins merge for one pathname. */ +function effectiveHeaders(rules: HeaderRule[], pathname: string): Record { + const merged: Record = {}; + for (const rule of rules) { + if (getPathMatch(rule.source, { removeUnnamedParams: true })(pathname) === false) continue; + for (const { key, value } of rule.headers) merged[key] = value; + } + return merged; +} + +// Pages an operator expects to reach inside the VS Code Simple Browser. `/login` is on the +// list on purpose: the webview has its own cookie jar, so an embedded session ALWAYS starts +// unauthenticated and `/dashboard` redirects there (src/server/authz/pipeline.ts). +const PAGE_PATHS = [ + "/", + "/dashboard", + "/dashboard/providers", + "/dashboard/combos/editor", + "/login", + "/forgot-password", + "/docs/guides/i18n", + "/landing", + "/status", +]; + +// Never framable — the process-spawning / proxy surfaces plus every root-level API alias +// declared in next.config.mjs `rewrites()`. +const API_PATHS = [ + "/api", + "/api/v1/chat/completions", + "/api/services/ninerouter/start", + "/v1", + "/v1/models", + "/v1beta/models", + "/chat/completions", + "/responses", + "/responses/abc/cancel", + "/models", + "/codex/responses", + "/anthropic/v1/messages", + "/openai/v1/models", + "/metrics", + "/debug", + "/.env", + "/a2a", + "/healthz", +]; + +function restoreEnv(): void { + if (originalEmbed === undefined) delete process.env[DASHBOARD_EMBED_ENV]; + else process.env[DASHBOARD_EMBED_ENV] = originalEmbed; +} + +test.afterEach(restoreEnv); + +// ── the opt-in switch itself ─────────────────────────────────────────────── + +test("#10273 embed mode is OFF unless DASHBOARD_ALLOW_EMBED names a known mode", () => { + for (const raw of [undefined, "", " ", "0", "1", "true", "yes", "on", "browser", "vscode-web"]) { + assert.equal( + resolveDashboardEmbedMode(raw === undefined ? {} : { [DASHBOARD_EMBED_ENV]: raw }), + null, + `DASHBOARD_ALLOW_EMBED=${JSON.stringify(raw)} must not enable embedding` + ); + } +}); + +test("#10273 DASHBOARD_ALLOW_EMBED=vscode enables the vscode mode, case/space tolerant", () => { + for (const raw of ["vscode", "VSCode", " vscode ", "VSCODE"]) { + assert.equal(resolveDashboardEmbedMode({ [DASHBOARD_EMBED_ENV]: raw }), "vscode"); + } + assert.equal(EMBED_FRAME_ANCESTORS.vscode, "'self' vscode-webview:"); +}); + +// ── default posture must not move ────────────────────────────────────────── + +test("#10273 default build keeps frame-ancestors 'none' + X-Frame-Options: DENY everywhere", async () => { + delete process.env[DASHBOARD_EMBED_ENV]; + const rules = await loadHeaders("embed-off"); + + assert.equal( + rules[0].source, + "/:path*", + "the global rule must stay a plain catch-all by default" + ); + + for (const pathname of [...PAGE_PATHS, ...API_PATHS]) { + const headers = effectiveHeaders(rules, pathname); + assert.match( + headers["Content-Security-Policy"], + /frame-ancestors 'none'/, + `${pathname} must keep frame-ancestors 'none' when the opt-in is off` + ); + assert.equal( + headers["X-Frame-Options"], + "DENY", + `${pathname} must keep X-Frame-Options: DENY when the opt-in is off` + ); + } +}); + +test("#10273 default build emits no extra header rules (byte-identical to pre-feature config)", async () => { + delete process.env[DASHBOARD_EMBED_ENV]; + const rules = await loadHeaders("embed-off-shape"); + assert.deepEqual( + rules.map((rule) => rule.source), + ["/:path*", "/dashboard/providers/services/:name/embed/:path*"] + ); +}); + +// ── embed mode ───────────────────────────────────────────────────────────── + +test("#10273 embed mode lets vscode-webview: frame the HTML pages and drops X-Frame-Options", async () => { + process.env[DASHBOARD_EMBED_ENV] = "vscode"; + const rules = await loadHeaders("embed-on-pages"); + + for (const pathname of PAGE_PATHS) { + const headers = effectiveHeaders(rules, pathname); + assert.ok( + headers["Content-Security-Policy"], + `${pathname} must still receive a Content-Security-Policy` + ); + assert.match( + headers["Content-Security-Policy"], + /frame-ancestors 'self' vscode-webview:/, + `${pathname} must allow the vscode-webview: ancestor in embed mode` + ); + assert.equal( + headers["X-Frame-Options"], + undefined, + `${pathname} must NOT carry X-Frame-Options in embed mode (it would still block the iframe)` + ); + } +}); + +test("#10273 embed mode keeps the API surface strictly unframable", async () => { + process.env[DASHBOARD_EMBED_ENV] = "vscode"; + const rules = await loadHeaders("embed-on-api"); + + for (const pathname of API_PATHS) { + const headers = effectiveHeaders(rules, pathname); + assert.match( + headers["Content-Security-Policy"], + /frame-ancestors 'none'/, + `${pathname} is an API surface and must keep frame-ancestors 'none' even in embed mode` + ); + assert.equal( + headers["X-Frame-Options"], + "DENY", + `${pathname} is an API surface and must keep X-Frame-Options: DENY even in embed mode` + ); + assert.ok( + !headers["Content-Security-Policy"].includes("vscode-webview:"), + `${pathname} must never allow the vscode-webview: ancestor` + ); + } +}); + +test("#10273 embed mode relaxes ONLY frame-ancestors — every other directive/header survives", async () => { + process.env[DASHBOARD_EMBED_ENV] = "vscode"; + const relaxed = effectiveHeaders(await loadHeaders("embed-on-intact"), "/dashboard"); + + delete process.env[DASHBOARD_EMBED_ENV]; + const strict = effectiveHeaders(await loadHeaders("embed-off-intact"), "/dashboard"); + + assert.equal( + relaxed["Content-Security-Policy"], + strict["Content-Security-Policy"].replace( + "frame-ancestors 'none'", + `frame-ancestors ${EMBED_FRAME_ANCESTORS.vscode}` + ), + "embed mode must swap the frame-ancestors token and change nothing else in the CSP" + ); + + for (const key of [ + "X-Content-Type-Options", + "Referrer-Policy", + "Permissions-Policy", + "Strict-Transport-Security", + ]) { + assert.equal(relaxed[key], strict[key], `${key} must be identical in embed mode`); + } +}); + +test("#10273 embed mode preserves the G-10 9Router embed override (last rule still wins)", async () => { + process.env[DASHBOARD_EMBED_ENV] = "vscode"; + const rules = await loadHeaders("embed-on-g10"); + const headers = effectiveHeaders(rules, "/dashboard/providers/services/ninerouter/embed/ui"); + + assert.equal( + headers["Content-Security-Policy"], + "frame-ancestors 'self'", + "the G-10 same-origin override must keep the last word for the 9Router embed route" + ); +}); + +// ── the API prefix list must stay derived from the config, not hand-maintained ── + +test("#10273 every root-level rewrite alias is excluded from the embeddable page surface", async () => { + const modUrl = `${pathToFileURL(modulePath).href}?case=prefixes-${Date.now()}`; + const nextConfig = (await import(modUrl)).default; + const rewrites = await nextConfig.rewrites(); + const prefixes = new Set(nonPageRoutePrefixes(rewrites)); + + for (const { source } of rewrites) { + const first = source.replace(/^\//, "").split("/")[0]; + assert.ok( + prefixes.has(first), + `rewrite alias "${source}" must be excluded from the embeddable page surface — ` + + `it proxies an API route and must never be framable` + ); + } + // The app-router API surfaces that have no rewrite alias. + for (const literal of ["api", "a2a", "healthz"]) { + assert.ok(prefixes.has(literal), `"${literal}" must be excluded from the page surface`); + } +}); + +test("#10273 Next.js accepts the generated header sources in BOTH modes (startup guard)", async () => { + // `loadCustomRoutes` is the validation Next runs when the config loads: a `source` it + // rejects aborts the build. The embed-mode sources are regexes, and CI never builds with + // DASHBOARD_ALLOW_EMBED set — without this guard a malformed source would only blow up on + // the operator's machine, at build time, with the feature already shipped. + const require = createRequire(import.meta.url); + const loadCustomRoutes = require("next/dist/lib/load-custom-routes.js").default; + + for (const mode of [undefined, "vscode"]) { + if (mode) process.env[DASHBOARD_EMBED_ENV] = mode; + else delete process.env[DASHBOARD_EMBED_ENV]; + + const nextConfig = ( + await import(`${pathToFileURL(modulePath).href}?case=validate-${mode}-${Date.now()}`) + ).default; + const routes = await loadCustomRoutes({ + ...nextConfig, + trailingSlash: false, + skipTrailingSlashRedirect: false, + basePath: "", + i18n: undefined, + }); + + assert.equal( + routes.headers.length, + mode ? 3 : 2, + `mode=${mode ?? "off"} should produce ${mode ? 3 : 2} header routes` + ); + } +}); + +test("#10273 buildSecurityHeaderRules produces complementary sources with no gap", () => { + const securityHeaders = [ + { key: "Content-Security-Policy", value: "frame-ancestors 'none'; default-src 'self'" }, + { key: "X-Frame-Options", value: "DENY" }, + ]; + const rules = buildSecurityHeaderRules({ + mode: "vscode", + securityHeaders, + prefixes: ["api", "v1"], + }); + + // Every pathname must be covered by exactly one of the two rules — a gap would ship a + // page with NO security headers at all, an overlap would make the merge order-dependent. + for (const pathname of ["/", "/dashboard", "/login", "/api/v1/models", "/v1/models", "/apifoo"]) { + const matched = rules.filter( + (rule) => getPathMatch(rule.source, { removeUnnamedParams: true })(pathname) !== false + ); + assert.equal( + matched.length, + 1, + `${pathname} must match exactly one rule, got ${matched.length}` + ); + } +});