mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 03:32:21 +03:00
feat(dashboard): opt-in CSP relaxation for VS Code Simple Browser embedding (#10273)
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
This commit is contained in:
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 },
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user