feat: browser-side annotation screenshots for web preview

Capture the preview/browser iframe DOM with snapDOM (html-to-image
fallback) so web annotation screenshots match the visible viewport,
without a headless Chromium dependency.

- Preserve document scroll via viewport crop and re-bake nested scroll
  (e.g. the Starlight sidebar) deterministically on the clone
- Pin position:fixed elements to their measured viewport rect so headers
  and sidebars land correctly in the crop
- Extract preview capture/proxy helpers into
  lib/preview/screenshot-capture.ts to slim down ContextPanel
- Guard the external preview proxy against SSRF to private, loopback and
  reserved/link-local addresses (incl. cloud metadata)
- Fully validate preview bridge messages before formatting/use
- Warn on the empty browser tab that pages run with full access, so
  users browse untrusted sites knowingly
This commit is contained in:
Bohdan Triapitsyn
2026-05-30 02:04:32 +03:00
parent 49ed0b52c9
commit 7f90ffb878
15 changed files with 1591 additions and 210 deletions
@@ -107,7 +107,7 @@ export const classifyPreviewResourceError = ({ tagName, url }) => {
return 'report';
};
export const classifyPreviewNavigation = ({ url, currentUrl }) => {
export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) => {
let parsed;
try {
parsed = new URL(String(url || ''), currentUrl || 'http://localhost/');
@@ -136,10 +136,22 @@ export const classifyPreviewNavigation = ({ url, currentUrl }) => {
}
const path = parsed.pathname || '/';
const proxyMatch = current?.pathname?.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
if (parsed.origin === current?.origin && path.startsWith('/api/preview/proxy/')) {
return { action: 'allow', url: parsed.toString() };
}
if (proxyMatch && parsed.origin === current?.origin && path.startsWith('/') && !path.startsWith(proxyMatch[1])) {
try {
if (targetOrigin) {
const upstreamUrl = new URL(`${parsed.pathname}${parsed.search}${parsed.hash}`, targetOrigin);
return { action: 'proxy', url: upstreamUrl.toString() };
}
} catch {
// fall through to the default policy
}
}
const host = parsed.hostname;
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
if (isLoopback || (parsed.origin === current?.origin && path.startsWith('/'))) {
@@ -149,7 +161,7 @@ export const classifyPreviewNavigation = ({ url, currentUrl }) => {
return { action: 'external', url: parsed.toString() };
};
const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
if (window.__openchamberPreviewBridgeInstalled) return;
window.__openchamberPreviewBridgeInstalled = true;
@@ -157,6 +169,7 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
const VERSION = 1;
const MAX_TEXT = 500;
const MAX_ARG = 1000;
const TARGET_ORIGIN = typeof window.__openchamberPreviewTargetOrigin === 'string' ? window.__openchamberPreviewTargetOrigin : '';
let inspectMode = false;
let lastHoverKey = '';
let pendingHover = null;
@@ -368,12 +381,19 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
const parsed = new URL(value, window.location.href);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { action: 'allow', url: parsed.toString() };
const current = new URL(window.location.href);
const proxyMatch = current.pathname.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
if (parsed.origin === current.origin && parsed.pathname === current.pathname && parsed.search === current.search && parsed.hash) {
return { action: 'allow', url: parsed.toString() };
}
if (parsed.origin === current.origin && parsed.pathname.startsWith('/api/preview/proxy/')) {
return { action: 'allow', url: parsed.toString() };
}
if (proxyMatch && parsed.origin === current.origin && parsed.pathname.startsWith('/') && parsed.pathname.indexOf(proxyMatch[1]) !== 0 && TARGET_ORIGIN) {
try {
const upstreamUrl = new URL(parsed.pathname + parsed.search + parsed.hash, TARGET_ORIGIN);
return { action: 'proxy', url: upstreamUrl.toString() };
} catch {}
}
const host = parsed.hostname;
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
if (isLoopback || (parsed.origin === current.origin && parsed.pathname.startsWith('/'))) {
@@ -819,7 +839,44 @@ const buildCookie = ({
return chunks.join('; ');
};
const normalizeLoopbackUrl = (rawUrl) => {
// SSRF guard for the `allowExternal` path: refuse to proxy private, loopback and
// reserved addresses (incl. cloud-metadata 169.254.169.254). Operates on the
// WHATWG-normalized hostname, so decimal/hex/octal IPv4 forms are already canonical
// dotted-decimal here. NOTE: this blocks IP *literals* only — a hostname that
// resolves to a private IP (DNS rebinding) is not caught and would need
// resolve-time IP pinning. Loopback for local preview goes through the non-external
// path (allowExternal=false), which is unaffected.
const isBlockedExternalHost = (hostname) => {
if (!hostname) return true;
let host = hostname.toLowerCase();
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const a = Number(v4[1]);
const b = Number(v4[2]);
if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private
if (a === 169 && b === 254) return true; // link-local incl. cloud metadata
if (a === 172 && b >= 16 && b <= 31) return true; // private
if (a === 192 && b === 168) return true; // private
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
return false;
}
if (host.includes(':')) {
if (host === '::1' || host === '::') return true; // loopback / unspecified
if (host.startsWith('fe80')) return true; // link-local
if (host.startsWith('fc') || host.startsWith('fd')) return true; // unique local fc00::/7
if (host.includes('::ffff:')) return true; // IPv4-mapped (dotted or hex form)
return false;
}
return false;
};
export const normalizeProxyTargetUrl = (rawUrl, { allowExternal = false } = {}) => {
let url;
try {
url = new URL(rawUrl);
@@ -832,8 +889,12 @@ const normalizeLoopbackUrl = (rawUrl) => {
}
const hostname = url.hostname;
if (!LOOPBACK_HOSTS.has(hostname)) {
return { ok: false, error: 'Only loopback hosts are supported' };
if (!allowExternal) {
if (!LOOPBACK_HOSTS.has(hostname)) {
return { ok: false, error: 'Only loopback hosts are supported' };
}
} else if (isBlockedExternalHost(hostname)) {
return { ok: false, error: 'Refusing to proxy private or reserved addresses' };
}
const port = url.port ? Number.parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80);
@@ -843,7 +904,7 @@ const normalizeLoopbackUrl = (rawUrl) => {
// Normalize common loopback hostnames to IPv4 to avoid environments where
// `localhost` resolves to ::1 but the dev server only binds IPv4.
if (hostname === '0.0.0.0' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') {
if (LOOPBACK_HOSTS.has(hostname) && (hostname === '0.0.0.0' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]')) {
url.hostname = '127.0.0.1';
}
@@ -851,6 +912,8 @@ const normalizeLoopbackUrl = (rawUrl) => {
return { ok: true, origin: url.origin };
};
const normalizeLoopbackUrl = (rawUrl) => normalizeProxyTargetUrl(rawUrl, { allowExternal: false });
export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind }) => {
if (typeof bodyText !== 'string' || bodyText.length === 0) {
return bodyText;
@@ -858,9 +921,11 @@ export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind
const prefix = proxyBasePath.endsWith('/') ? proxyBasePath.slice(0, -1) : proxyBasePath;
const target = targetOrigin ? new URL(targetOrigin) : null;
const isSameLoopbackTarget = (url) => {
const isSameTargetOrigin = (url) => {
if (!target) return false;
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
if (url.origin === target.origin) return true;
const host = url.hostname;
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '0.0.0.0' && host !== '::1' && host !== '[::1]') {
return false;
@@ -875,7 +940,7 @@ export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind
}
try {
const parsed = new URL(value);
if (isSameLoopbackTarget(parsed)) {
if (isSameTargetOrigin(parsed)) {
return `${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`;
}
} catch {
@@ -1074,12 +1139,13 @@ export const createPreviewProxyRuntime = ({
}) => {
ensureSweeper();
const injectPreviewBridge = (bodyText) => {
const injectPreviewBridge = (bodyText, targetOrigin) => {
if (typeof bodyText !== 'string' || bodyText.includes(PREVIEW_BRIDGE_SCRIPT_ID)) {
return bodyText;
}
const script = `<script id="${PREVIEW_BRIDGE_SCRIPT_ID}">${PREVIEW_BRIDGE_SCRIPT}</script>`;
const targetOriginScript = `<script>window.__openchamberPreviewTargetOrigin=${JSON.stringify(targetOrigin || '')};</script>`;
const script = `${targetOriginScript}<script id="${PREVIEW_BRIDGE_SCRIPT_ID}">${PREVIEW_BRIDGE_SCRIPT}</script>`;
if (/<head(?:\s[^>]*)?>/i.test(bodyText)) {
return bodyText.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${script}`);
}
@@ -1128,7 +1194,10 @@ export const createPreviewProxyRuntime = ({
}
const ttlMs = typeof req.body?.ttlMs === 'number' ? req.body.ttlMs : DEFAULT_TARGET_TTL_MS;
const normalized = normalizeLoopbackUrl(rawUrl);
const allowExternal = req.body?.allowExternal === true;
const normalized = allowExternal
? normalizeProxyTargetUrl(rawUrl, { allowExternal: true })
: normalizeLoopbackUrl(rawUrl);
if (!normalized.ok) {
return res.status(400).json({ error: normalized.error });
}
@@ -1244,7 +1313,7 @@ export const createPreviewProxyRuntime = ({
targetOrigin: resolved.entry.origin,
kind: isHtml ? 'html' : isCss ? 'css' : 'javascript',
});
return isHtml ? injectPreviewBridge(rewrittenBody) : rewrittenBody;
return isHtml ? injectPreviewBridge(rewrittenBody, resolved.entry.origin) : rewrittenBody;
}),
error: (err, _req, res) => {
const isDev = typeof process !== 'undefined'
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { classifyPreviewNavigation, classifyPreviewResourceError, rewritePreviewBody } from './proxy-runtime.js';
import { classifyPreviewNavigation, classifyPreviewResourceError, normalizeProxyTargetUrl, rewritePreviewBody } from './proxy-runtime.js';
const rewrite = (bodyText, kind) => rewritePreviewBody({
bodyText,
@@ -128,6 +128,17 @@ describe('preview navigation policy', () => {
});
});
it('maps app-origin root links back to the upstream origin while proxied', () => {
expect(classifyPreviewNavigation({
url: 'http://127.0.0.1:57123/support',
currentUrl,
targetOrigin: 'https://openchamber.dev',
})).toEqual({
action: 'proxy',
url: 'https://openchamber.dev/support',
});
});
it('sends non-loopback http links outside the preview iframe', () => {
expect(classifyPreviewNavigation({ url: 'https://example.com/docs', currentUrl })).toEqual({
action: 'external',
@@ -142,3 +153,37 @@ describe('preview navigation policy', () => {
});
});
});
describe('proxy target normalization (SSRF guard)', () => {
it('allows ordinary external hosts when allowExternal is set', () => {
expect(normalizeProxyTargetUrl('https://docs.openchamber.dev/security/', { allowExternal: true }))
.toEqual({ ok: true, origin: 'https://docs.openchamber.dev' });
});
it('rejects non-loopback hosts without allowExternal', () => {
expect(normalizeProxyTargetUrl('https://example.com/', {}).ok).toBe(false);
});
it('refuses private, loopback and link-local literals on the external path', () => {
for (const url of [
'http://127.0.0.1/',
'http://10.0.0.5/',
'http://172.16.9.9/',
'http://192.168.1.1/',
'http://169.254.169.254/latest/meta-data/',
'http://100.64.0.1/',
'http://localhost/',
'http://service.local/',
'http://[::1]/',
'http://[fd00::1]/',
'http://[fe80::1]/',
'http://2130706433/', // decimal form of 127.0.0.1, normalized by WHATWG URL
]) {
expect(normalizeProxyTargetUrl(url, { allowExternal: true }).ok, url).toBe(false);
}
});
it('still blocks private hosts even via IPv4-mapped IPv6', () => {
expect(normalizeProxyTargetUrl('http://[::ffff:127.0.0.1]/', { allowExternal: true }).ok).toBe(false);
});
});