From fa3e08f13247edf97ee4b91c35188755a90a3a7c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 16 Jun 2026 23:24:36 -0700 Subject: [PATCH 01/10] iframe-proxy: share the proxy across VS Code and standalone via lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the host-agnostic proxy into lib/src/host as one TypeScript source so both hosts run the same code instead of reimplementing it: - iframe-proxy-rewrite.ts — pure policy/rewriting (shim, instrumentHtml, refusesFraming/hasRestrictiveFrameAncestors, loopback/SSRF checks, error pages). Now unit-tested (17 cases), incl. the frame-ancestors logic. - iframe-proxy.ts — the Node http/net server, logger injected so it depends on neither host. - IframeProxyResult moves to a dependency-free leaf (platform/ iframe-proxy-types.ts, re-exported) so the Node code doesn't pull the browser type-graph into a Node compile. VS Code: iframe-proxy-host.ts collapses to a thin wrapper that injects `log`. Standalone: build-sidecar-proxy.mjs esbuilds the shared TS into sidecar/iframe-proxy.cjs (wired into stage/build/tauri, gitignored); the sidecar handles iframe:createProxyUrl over stdio; a Rust iframe_create_proxy_url command bridges it; tauri-adapter implements createIframeProxyUrl; and the Tauri CSP gains frame-src for the loopback proxy. IframePanel is shared, so the webview needs no changes. Verified: lib tsc + 609 tests, VS Code bundle, sidecar cjs builds and runtime-proxies, standalone tsc. The Rust command needs a machine with the Rust toolchain to compile/run (cargo absent here). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + lib/src/host/iframe-proxy-rewrite.test.ts | 117 ++++++ lib/src/host/iframe-proxy-rewrite.ts | 153 ++++++++ lib/src/host/iframe-proxy.ts | 287 ++++++++++++++ lib/src/lib/platform/iframe-proxy-types.ts | 19 + lib/src/lib/platform/types.ts | 19 +- lib/tsconfig.app.json | 4 +- pnpm-lock.yaml | 3 + standalone/package.json | 7 +- standalone/scripts/build-sidecar-proxy.mjs | 23 ++ standalone/sidecar/main.js | 9 + standalone/src-tauri/src/lib.rs | 18 + standalone/src-tauri/tauri.conf.json | 2 +- standalone/src/tauri-adapter.ts | 13 +- vscode-ext/src/iframe-proxy-host.ts | 419 +-------------------- 15 files changed, 664 insertions(+), 430 deletions(-) create mode 100644 lib/src/host/iframe-proxy-rewrite.test.ts create mode 100644 lib/src/host/iframe-proxy-rewrite.ts create mode 100644 lib/src/host/iframe-proxy.ts create mode 100644 lib/src/lib/platform/iframe-proxy-types.ts create mode 100644 standalone/scripts/build-sidecar-proxy.mjs diff --git a/.gitignore b/.gitignore index ca51287f..ba812d73 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ standalone/src-tauri/binaries/ standalone/src-tauri/gen/ standalone/dist/ standalone/sidecar/dor-cli/ +standalone/sidecar/iframe-proxy.cjs standalone/sidecar/node_modules/ standalone/node_modules/ diff --git a/lib/src/host/iframe-proxy-rewrite.test.ts b/lib/src/host/iframe-proxy-rewrite.test.ts new file mode 100644 index 00000000..394f882f --- /dev/null +++ b/lib/src/host/iframe-proxy-rewrite.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { + hasRestrictiveFrameAncestors, + refusesFraming, + instrumentHtml, + isLoopbackHost, + isBlockedAddress, + IFRAME_SHIM, + errorPageHtml, + frameRefusedPage, +} from './iframe-proxy-rewrite'; + +describe('hasRestrictiveFrameAncestors', () => { + it('treats a standalone * as permissive', () => { + expect(hasRestrictiveFrameAncestors('frame-ancestors *')).toBe(false); + expect(hasRestrictiveFrameAncestors("default-src 'self'; frame-ancestors *")).toBe(false); + }); + + it('treats a scoped wildcard source as restrictive', () => { + // The naive `/\*/` test would wrongly pass this — it contains a `*`. + expect(hasRestrictiveFrameAncestors('frame-ancestors https://*.example.com')).toBe(true); + }); + + it('treats self / none / scoped hosts as restrictive', () => { + expect(hasRestrictiveFrameAncestors("frame-ancestors 'self'")).toBe(true); + expect(hasRestrictiveFrameAncestors("frame-ancestors 'none'")).toBe(true); + expect(hasRestrictiveFrameAncestors('frame-ancestors https://example.com')).toBe(true); + }); + + it('ignores other directives and is case/space tolerant', () => { + expect(hasRestrictiveFrameAncestors("script-src 'self'")).toBe(false); + expect(hasRestrictiveFrameAncestors('FRAME-ANCESTORS *')).toBe(false); + expect(hasRestrictiveFrameAncestors(' frame-ancestors https://x.com ; ')).toBe(true); + }); +}); + +describe('refusesFraming', () => { + it('refuses on any X-Frame-Options', () => { + expect(refusesFraming({ 'x-frame-options': 'DENY' })).toBe(true); + expect(refusesFraming({ 'x-frame-options': 'SAMEORIGIN' })).toBe(true); + }); + + it('allows when there is no framing header', () => { + expect(refusesFraming({})).toBe(false); + expect(refusesFraming({ 'content-security-policy': "default-src 'self'" })).toBe(false); + }); + + it('honors a restrictive CSP frame-ancestors', () => { + expect(refusesFraming({ 'content-security-policy': "frame-ancestors 'self'" })).toBe(true); + expect(refusesFraming({ 'content-security-policy': 'frame-ancestors *' })).toBe(false); + }); + + it('refuses if any of multiple CSP headers is restrictive', () => { + expect(refusesFraming({ + 'content-security-policy': ['frame-ancestors *', "frame-ancestors 'self'"], + })).toBe(true); + }); +}); + +describe('instrumentHtml', () => { + it('injects the shim before ', () => { + const out = instrumentHtml('xhi'); + expect(out).toContain("__dormouse:'leader'"); + expect(out).toMatch(/<\/script><\/head>/); + expect(out).toContain('x'); + }); + + it('falls back to after when there is no head', () => { + const out = instrumentHtml('hi'); + expect(out).toMatch(/\s*`; + if (/<\/head>/i.test(html)) return html.replace(/<\/head>/i, `${shimTag}`); + if (/]*>/i.test(html)) return html.replace(/(]*>)/i, `$1${shimTag}`); + return shimTag + html; +} + +// A remote refuses framing if it sends any X-Frame-Options or a CSP +// frame-ancestors that is not the permissive standalone `*`. Conservative on +// purpose: when in doubt we divert to an error page rather than show a +// guaranteed-blank frame. +export function refusesFraming(headers: ProxyHeaders): boolean { + if (headers['x-frame-options']) return true; + const csp = headers['content-security-policy']; + const policies = Array.isArray(csp) ? csp : csp ? [csp] : []; + return policies.some((policy) => hasRestrictiveFrameAncestors(policy)); +} + +export function hasRestrictiveFrameAncestors(policy: string): boolean { + const directives = policy.split(';'); + for (const directive of directives) { + const parts = directive.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0 || parts[0].toLowerCase() !== 'frame-ancestors') continue; + const sources = parts.slice(1); + if (!sources.includes('*')) return true; + } + return false; +} + +export function isLoopbackHost(hostname: string): boolean { + const h = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.startsWith('127.'); +} + +export function isBlockedAddress(hostname: string): boolean { + const h = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + // IPv4 link-local / cloud metadata (169.254.0.0/16, incl. 169.254.169.254). + if (/^169\.254\./.test(h)) return true; + // IPv6 link-local (fe80::/10). + if (/^fe[89ab][0-9a-f]:/.test(h)) return true; + return false; +} + +// --- Served error / diagnostic pages ---------------------------------------- + +export interface ErrorPage { + title: string; + message: string; + hint?: string; +} + +export function frameRefusedPage(upstream: URL): ErrorPage { + return { + title: `${upstream.host} refuses to be embedded`, + message: `${upstream.host} sends a frame-blocking header (X-Frame-Options or CSP frame-ancestors), so it can’t be shown in an iframe surface.`, + hint: `dor ab open ${upstream.href}`, + }; +} + +export function unreachablePage(upstream: URL, detail: string): ErrorPage { + return { + title: `Nothing responding at ${upstream.host}`, + message: `Dormouse couldn’t reach ${upstream.href} (${detail}). Is the dev server running?`, + }; +} + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"'); +} + +export function errorPageHtml(page: ErrorPage): string { + const hint = page.hint + ? `

Try ${escapeHtml(page.hint)}

` + : ''; + return ` + +
+

${escapeHtml(page.title)}

+

${escapeHtml(page.message)}

+ ${hint} +
`; +} diff --git a/lib/src/host/iframe-proxy.ts b/lib/src/host/iframe-proxy.ts new file mode 100644 index 00000000..ec69bd7f --- /dev/null +++ b/lib/src/host/iframe-proxy.ts @@ -0,0 +1,287 @@ +/** + * Host-agnostic transparent proxy for the iframe surface + * (docs/specs/dor-iframe.md → "The Transparent Proxy"). + * + * Instead of pointing the `