mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 19:22:32 +03:00
Compare commits
2 Commits
fix/releas
...
feat/10273
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e6623751 | ||
|
|
8a1edda52a |
11
.env.example
11
.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
|
||||
|
||||
1
changelog.d/features/10273-dashboard-embed-csp.md
Normal file
1
changelog.d/features/10273-dashboard-embed-csp.md
Normal file
@@ -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)
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
142
scripts/build/dashboardEmbed.mjs
Normal file
142
scripts/build/dashboardEmbed.mjs
Normal file
@@ -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<string, string | undefined>} 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 },
|
||||
];
|
||||
}
|
||||
324
tests/unit/dashboard-embed-csp-10273.test.ts
Normal file
324
tests/unit/dashboard-embed-csp-10273.test.ts
Normal file
@@ -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<HeaderRule[]> {
|
||||
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<string, string> {
|
||||
const merged: Record<string, string> = {};
|
||||
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}`
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user