Skip to content

Commit c855d7e

Browse files
committed
feat(security): harden preview navigation and fade demos after load
Block area/xlink escapes, set iframe CSP plus a dark fallback, and reveal previews only once srcdoc finishes loading.
1 parent 06ce2de commit c855d7e

4 files changed

Lines changed: 47 additions & 8 deletions

File tree

src/scripts/app.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
downsampleMetricSamples,
2727
installMetricTooltips,
2828
installTimelineTracking,
29+
installPreviewFade,
30+
restartPreview,
2931
modelBadge,
3032
type Battle,
3133
type HistoryResult,
@@ -1632,7 +1634,7 @@ els.results.addEventListener("click", async (e) => {
16321634
} else if (action === "reload-preview") {
16331635
// Restart the demo without spending tokens — just reload the iframe.
16341636
const f = entry.el.querySelector("iframe[data-preview]") as HTMLIFrameElement | null;
1635-
if (f) f.srcdoc = f.srcdoc;
1637+
if (f) restartPreview(f);
16361638
} else if (action === "dismiss-waiting") {
16371639
entry.waitingDismissed = true;
16381640
btn.closest("[data-waiting-overlay]")?.remove();
@@ -1951,4 +1953,5 @@ syncRunBtn();
19511953
updateHistoryCount();
19521954
installMetricTooltips();
19531955
installTimelineTracking();
1956+
installPreviewFade();
19541957
loadModels();

src/scripts/battle.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
renderBattleInsights,
2121
installMetricTooltips,
2222
installTimelineTracking,
23+
installPreviewFade,
24+
restartPreview,
2325
SCROLLLINK_KEY,
2426
type ViewMode,
2527
type Battle,
@@ -32,6 +34,7 @@ const VIEW_KEY = "ab:view";
3234

3335
installMetricTooltips();
3436
installTimelineTracking();
37+
installPreviewFade();
3538

3639
// Fetch the formatter/highlighter chunk in parallel with the battle itself.
3740
const codeRender = import("./code-render");
@@ -322,7 +325,7 @@ async function initBattle(b: Battle) {
322325
const f = btn
323326
.closest("article")
324327
?.querySelector("iframe[data-preview]") as HTMLIFrameElement | null;
325-
if (f) f.srcdoc = f.srcdoc;
328+
if (f) restartPreview(f);
326329
} else if (btn.dataset.action === "open" && v.code) {
327330
openHardenedPreview(v.code, `Preview · ${displayLabel(v)}`);
328331
}

src/scripts/lib.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,21 @@ describe("result extraction", () => {
6464
describe("preview hardening", () => {
6565
it("injects a strict CSP and strips navigation gadgets", () => {
6666
const hardened = hardenPreviewDocument(
67-
`<!doctype html><html><head><base href="https://evil.test/"><meta http-equiv="refresh" content="0;url=https://evil.test"><link rel="dns-prefetch" href="//evil.test"><link rel="preconnect stylesheet" href="https://evil.test/x.css"></head><body><img src="https://evil.test/t.gif"><script>fetch("https://evil.test")</script></body></html>`,
67+
`<!doctype html><html><head><base href="https://evil.test/"><meta http-equiv="refresh" content="0;url=https://evil.test"><link rel="dns-prefetch" href="//evil.test"><link rel="preconnect stylesheet" href="https://evil.test/x.css"></head><body><map><area href="https://evil.test/phish"></map><img src="https://evil.test/t.gif"><script>fetch("https://evil.test")</script></body></html>`,
6868
{ bridgeId: "model-a" },
6969
);
7070

7171
expect(hardened).toContain(`content="${PREVIEW_CSP}"`);
7272
expect(hardened).toContain("connect-src 'none'");
7373
expect(hardened).toContain("manifest-src 'none'");
74+
expect(hardened).toContain("background:#080a08");
7475
expect(hardened).not.toMatch(/<base\b/i);
76+
expect(hardened).not.toMatch(/<area\b/i);
7577
expect(hardened).not.toMatch(/http-equiv\s*=\s*["']?refresh/i);
7678
expect(hardened).not.toMatch(/<link\b[^>]*\b(?:dns-prefetch|preconnect)\b/i);
7779
expect(hardened).toContain("blockedLink");
80+
expect(hardened).toContain('closest("a,area")');
81+
expect(hardened).toContain("getAttributeNS(XLINK");
7882
expect(hardened).toContain('href.charAt(0)!=="#"');
7983
expect(hardened.indexOf("Content-Security-Policy")).toBeLessThan(
8084
hardened.indexOf("fetch("),
@@ -114,12 +118,15 @@ describe("preview hardening", () => {
114118
);
115119

116120
expect(html).toContain(`sandbox="${PREVIEW_SANDBOX}"`);
121+
expect(html).toContain('csp="default-src &#39;none&#39;');
117122
expect(html).toContain(`allow="${PREVIEW_ALLOW}"`);
118123
expect(html).toContain('referrerpolicy="no-referrer"');
119124
expect(html).not.toContain("allow-same-origin");
120125
expect(html).not.toContain("allow-modals");
121126
expect(html).not.toContain("allow-forms");
122127
expect(html).toContain("Content-Security-Policy");
128+
expect(html).toContain("bg-[var(--color-surface)] opacity-0");
129+
expect(html).toContain("transition-opacity duration-300");
123130
});
124131

125132
it("does not create an iframe until a deferred public preview is approved", () => {

src/scripts/lib.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export const PREVIEW_CSP = [
268268

269269
/** Tightest sandbox that still allows interactive demos. No same-origin, popups, forms, or modals. */
270270
export const PREVIEW_SANDBOX = "allow-scripts";
271+
const PREVIEW_FALLBACK_BACKGROUND = "#080a08";
271272

272273
/** Deny browser/device capabilities that interactive demos do not need. */
273274
export const PREVIEW_ALLOW = [
@@ -297,12 +298,13 @@ function scrollBridge(id: string): string {
297298

298299
// Stops ordinary links and submissions from replacing the preview with a
299300
// phishing page. The sandbox remains the primary boundary for scripted escape.
300-
const PREVIEW_NAVIGATION_GUARD = `<script>(function(){function blockedLink(target){var link=target&&target.closest&&target.closest("a[href]");if(!link)return false;link.removeAttribute("ping");var href=(link.getAttribute("href")||"").trim();return href!==""&&href.charAt(0)!=="#"}addEventListener("click",function(event){if(blockedLink(event.target))event.preventDefault()},true);addEventListener("auxclick",function(event){if(blockedLink(event.target))event.preventDefault()},true);addEventListener("submit",function(event){event.preventDefault()},true)})()</scr`+`ipt>`;
301+
const PREVIEW_NAVIGATION_GUARD = `<script>(function(){var XLINK="http://www.w3.org/1999/xlink";function blockedLink(target){var link=target&&target.closest&&target.closest("a,area");if(!link)return false;link.removeAttribute("ping");var href=(link.getAttribute("href")||link.getAttributeNS(XLINK,"href")||"").trim();if(href!==""&&href.charAt(0)!=="#")return true;return false}addEventListener("click",function(event){if(blockedLink(event.target))event.preventDefault()},true);addEventListener("auxclick",function(event){if(blockedLink(event.target))event.preventDefault()},true);addEventListener("submit",function(event){event.preventDefault()},true)})()</scr`+`ipt>`;
301302

302303
/** Strip navigation gadgets that can leave the srcdoc document before CSP helps. */
303304
function stripPreviewNavigationGadgets(code: string): string {
304305
return code
305306
.replace(/<base\b[^>]*>/gi, "")
307+
.replace(/<area\b[^>]*>/gi, "")
306308
.replace(/<meta\b[^>]*http-equiv\s*=\s*(["']?)refresh\1[^>]*>/gi, "")
307309
.replace(
308310
/<link\b(?=[^>]*\brel\s*=\s*(?:"[^"]*\b(?:preconnect|dns-prefetch|prefetch|prerender|modulepreload)\b[^"]*"|'[^']*\b(?:preconnect|dns-prefetch|prefetch|prerender|modulepreload)\b[^']*'|[^\s>]*(?:preconnect|dns-prefetch|prefetch|prerender|modulepreload)[^\s>]*))[^>]*>/gi,
@@ -320,7 +322,8 @@ export function hardenPreviewDocument(
320322
): string {
321323
const untrusted = stripPreviewNavigationGadgets(code);
322324
const cspMeta = `<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">`;
323-
const securityBootstrap = `${cspMeta}${PREVIEW_NAVIGATION_GUARD}`;
325+
const fallbackStyle = `<style>html,body{min-height:100%;background:${PREVIEW_FALLBACK_BACKGROUND}}</style>`;
326+
const securityBootstrap = `${cspMeta}${fallbackStyle}${PREVIEW_NAVIGATION_GUARD}`;
324327
const withoutDoctype = untrusted.replace(/<!doctype\b[^>]*>/gi, "");
325328
const htmlAttrs = withoutDoctype.match(/<html\b([^>]*)>/i)?.[1] || "";
326329
const completeDocument = withoutDoctype.match(
@@ -347,7 +350,7 @@ export function hardenPreviewDocument(
347350
*/
348351
export function openHardenedPreview(code: string, title = "AI Battle preview"): void {
349352
const inner = hardenPreviewDocument(code);
350-
const wrapper = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title><style>html,body{margin:0;height:100%;background:#0a0a0a}iframe{display:block;width:100%;height:100%;border:0;background:#fff}</style></head><body><iframe sandbox="${PREVIEW_SANDBOX}" allow="${PREVIEW_ALLOW}" referrerpolicy="no-referrer" srcdoc="${esc(inner)}" title="${esc(title)}"></iframe></body></html>`;
353+
const wrapper = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title><style>html,body{margin:0;height:100%;background:${PREVIEW_FALLBACK_BACKGROUND}}iframe{display:block;width:100%;height:100%;border:0;background:${PREVIEW_FALLBACK_BACKGROUND}}</style></head><body><iframe sandbox="${PREVIEW_SANDBOX}" csp="${esc(PREVIEW_CSP)}" allow="${PREVIEW_ALLOW}" referrerpolicy="no-referrer" srcdoc="${esc(inner)}" title="${esc(title)}"></iframe></body></html>`;
351354
const url = URL.createObjectURL(new Blob([wrapper], { type: "text/html" }));
352355
window.open(url, "_blank", "noopener,noreferrer");
353356
setTimeout(() => URL.revokeObjectURL(url), 60_000);
@@ -356,14 +359,37 @@ export function openHardenedPreview(code: string, title = "AI Battle preview"):
356359
function previewIframeHTML(r: ResultView, resultKey: string): string {
357360
const doc = hardenPreviewDocument(r.code, { bridgeId: resultKey });
358361
return `
359-
<div class="relative h-full bg-[var(--color-panel)] p-1.5">
362+
<div class="relative h-full bg-[var(--color-surface)] p-1.5">
360363
<button data-action="reload-preview" data-model="${esc(resultKey)}" aria-label="Restart preview" class="absolute right-2 top-2 z-10 flex items-center gap-1 rounded-md border border-[var(--color-line)] bg-[var(--color-panel)]/90 px-2 py-1 text-[10px] text-[var(--color-ink-dim)] backdrop-blur transition-colors hover:text-[var(--color-ink)]" title="restart the demo">
361364
${svg("i-refresh", "size-3.5")}<span>restart</span>
362365
</button>
363-
<iframe data-preview="${esc(resultKey)}" class="h-full w-full rounded-lg bg-white shadow-inner" sandbox="${PREVIEW_SANDBOX}" allow="${PREVIEW_ALLOW}" referrerpolicy="no-referrer" srcdoc="${esc(doc)}" title="Preview generated by ${esc(r.id)}"></iframe>
366+
<iframe data-preview="${esc(resultKey)}" class="h-full w-full rounded-lg bg-[var(--color-surface)] opacity-0 shadow-inner transition-opacity duration-300 ease-out motion-reduce:transition-none" sandbox="${PREVIEW_SANDBOX}" csp="${esc(PREVIEW_CSP)}" allow="${PREVIEW_ALLOW}" referrerpolicy="no-referrer" srcdoc="${esc(doc)}" title="Preview generated by ${esc(r.id)}"></iframe>
364367
</div>`;
365368
}
366369

370+
/** Fade previews in only after their srcdoc has finished loading. */
371+
export function installPreviewFade(root: Document = document): void {
372+
root.addEventListener(
373+
"load",
374+
(event) => {
375+
const frame = event.target;
376+
if (!(frame instanceof HTMLIFrameElement) || !frame.matches("iframe[data-preview]"))
377+
return;
378+
requestAnimationFrame(() => {
379+
frame.classList.remove("opacity-0");
380+
frame.classList.add("opacity-100");
381+
});
382+
},
383+
true,
384+
);
385+
}
386+
387+
export function restartPreview(frame: HTMLIFrameElement): void {
388+
frame.classList.remove("opacity-100");
389+
frame.classList.add("opacity-0");
390+
frame.srcdoc = frame.srcdoc;
391+
}
392+
367393
// Content for a finished result (output / code / preview).
368394
export function doneContentHTML(
369395
r: ResultView,

0 commit comments

Comments
 (0)