fix(preview): harden sandbox and transformation

This commit is contained in:
2026-08-26 03:34:52 -05:00
parent 656c14caf3
commit 7262e7730e
7 changed files with 125 additions and 17 deletions
+3
View File
@@ -2,6 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- Preview documents use data: URLs. Any later navigation of that child
browsing context is denied before a network request is sent. -->
<meta http-equiv="Content-Security-Policy" content="frame-src data:;" />
<link rel="icon" type="image/svg+xml" href="/n small.png" /> <link rel="icon" type="image/svg+xml" href="/n small.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NexusOS</title> <title>NexusOS</title>
+13 -10
View File
@@ -304,8 +304,8 @@ const _MIN_PREVIEW_H = 160;
const _MAX_PREVIEW_H = 720; const _MAX_PREVIEW_H = 720;
const _MAX_H_STEPS = 60; const _MAX_H_STEPS = 60;
// Live preview for a ```html or ```svg fenced block: a Preview/Code toggle, // Live preview for a renderable fenced block: a Preview/Code toggle rendered
// rendered via a sandboxed <iframe srcDoc>. // via a sandboxed iframe whose document is an encoded data: URL.
// //
// Trust boundary: `sandbox="allow-scripts"` — deliberately without // Trust boundary: `sandbox="allow-scripts"` — deliberately without
// allow-same-origin, allow-forms, allow-popups, or allow-top-navigation. No // allow-same-origin, allow-forms, allow-popups, or allow-top-navigation. No
@@ -313,11 +313,13 @@ const _MAX_H_STEPS = 60;
// actually matters here: even the inline scripts the CSP allows to run can't // actually matters here: even the inline scripts the CSP allows to run can't
// read this app's cookies/localStorage, can't call its API (no credentialed // read this app's cookies/localStorage, can't call its API (no credentialed
// or same-origin fetch is possible), and can't reach `window.parent`. The CSP // or same-origin fetch is possible), and can't reach `window.parent`. The CSP
// above closes the remaining gap same-origin-denial doesn't cover on its own: // above blocks resource and script-initiated network access. The embedding
// arbitrary outbound network access to the open internet. Nothing here is // document's `frame-src data:` policy in index.html closes a separate CSP gap:
// treated as a substitute for real code execution sandboxing (Docker, a WASM // a child is otherwise allowed to navigate its own browsing context to a URL.
// runtime, etc.) - this block never runs anything outside the browser's own // The initial data: document is allowed and inherits the parent policy, while
// iframe sandbox, which is the point: no backend attack surface at all. // an http(s) navigation is rejected before its request is sent. Nothing here
// substitutes for a general code-execution sandbox (Docker, WASM, etc.);
// model-authored code runs only inside the browser's sandboxed frame.
function RenderBlock({ lang, value, streaming }) { function RenderBlock({ lang, value, streaming }) {
const [tab, setTab] = useState("preview"); const [tab, setTab] = useState("preview");
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -331,7 +333,7 @@ function RenderBlock({ lang, value, streaming }) {
}; };
// Don't preview a block whose fence hasn't closed yet - it's incomplete // Don't preview a block whose fence hasn't closed yet - it's incomplete
// markup by definition, and re-pointing an iframe's srcDoc at a half-formed // markup by definition, and re-pointing an iframe at a half-formed
// document on every streamed token is both wasteful and flickery. Code view // document on every streamed token is both wasteful and flickery. Code view
// already has its own streaming indicator (the same dot CodeBlock uses). // already has its own streaming indicator (the same dot CodeBlock uses).
const showPreview = tab === "preview" && !streaming; const showPreview = tab === "preview" && !streaming;
@@ -434,16 +436,17 @@ function PreviewFrame({ lang, value, expanded }) {
// A build failure (JSX that doesn't parse) has no document to show at all, so // A build failure (JSX that doesn't parse) has no document to show at all, so
// the message stands in for the frame rather than sitting under it. // the message stands in for the frame rather than sitting under it.
const { doc, error: buildError } = buildSrcDoc(lang, value); const { doc, error: buildError } = buildSrcDoc(lang, value);
const frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
const shown = buildError || error; const shown = buildError || error;
return ( return (
<> <>
{doc && ( {frameUrl && (
<iframe <iframe
ref={frameRef} ref={frameRef}
title="rendered output" title="rendered output"
sandbox="allow-scripts" sandbox="allow-scripts"
srcDoc={doc} src={frameUrl}
style={{ style={{
width: "100%", width: "100%",
height: expanded ? "70vh" : `${height}px`, height: expanded ? "70vh" : `${height}px`,
+46 -1
View File
@@ -298,7 +298,7 @@ function handleWord(sc, word) {
if (word === "import" && atStatement) { if (word === "import" && atStatement) {
if (sc.peek() === "(" || sc.peek() === ".") { sc.emit(word); return true; } // import()/import.meta if (sc.peek() === "(" || sc.peek() === ".") { sc.emit(word); return true; } // import()/import.meta
const from = sc.i; const from = sc.i;
skipStatement(sc); skipImportStatement(sc);
recordImport(sc, sc.src.slice(from, sc.i)); recordImport(sc, sc.src.slice(from, sc.i));
return true; return true;
} }
@@ -400,6 +400,51 @@ function skipStatement(sc) {
sc.prevWord = word; sc.prevWord = word;
} }
/**
* Drop a static import, including the common multiline named-import form.
* A bare newline before the module string is part of the import; one after it
* ends a semicolon-free import. Semicolons always win so a malformed import
* (for example a missing `}`) cannot swallow the rest of the component.
*/
function skipImportStatement(sc) {
const from = sc.i;
const sig = sc.prevSig;
const word = sc.prevWord;
let sawModule = false;
while (!sc.eof) {
const ch = sc.peek();
if (ch === ";") { sc.i++; break; }
if (ch === "\n" && sawModule) break;
if (ch === '"' || ch === "'") {
const outLength = sc.out.length;
copyString(sc, ch);
sc.out.length = outLength;
sawModule = true;
continue;
}
if (ch === "/" && sc.peek(1) === "/") {
const outLength = sc.out.length;
copyLineComment(sc);
sc.out.length = outLength;
continue;
}
if (ch === "/" && sc.peek(1) === "*") {
const outLength = sc.out.length;
copyBlockComment(sc);
sc.out.length = outLength;
continue;
}
sc.i++;
}
// Keep runtime error line numbers aligned with the source shown in Code.
sc.out.push(missingNewlines(sc.src.slice(from, sc.i), ""));
sc.prevSig = sig;
sc.prevWord = word;
}
function skipBalancedBraces(sc) { function skipBalancedBraces(sc) {
const from = sc.i; const from = sc.i;
const sig = sc.prevSig; const sig = sc.prevSig;
@@ -165,6 +165,20 @@ test("imports are dropped - the sandbox has no module loader", () => {
assert.equal(js('import "./styles.css";\nconst x = 1;'), "const x = 1;"); assert.equal(js('import "./styles.css";\nconst x = 1;'), "const x = 1;");
}); });
test("multiline named imports are dropped as one statement", () => {
const source = `import {
useState,
useEffect,
} from "react";
const x = 1;`;
const out = transform(source);
assert.equal(out.code, "\n\n\n\nconst x = 1;");
assert.deepEqual(out.imports, [
{ names: ["useState", "useEffect"], module: "react" },
]);
assert.doesNotThrow(() => new Function(out.code));
});
test("dropped imports report the bindings they would have provided", () => { test("dropped imports report the bindings they would have provided", () => {
// So a missing name can say where it was supposed to come from, instead of // So a missing name can say where it was supposed to come from, instead of
// surfacing as "useInView is not defined" at its first use. // surfacing as "useInView is not defined" at its first use.
+18 -5
View File
@@ -393,7 +393,15 @@ def _critique_shared(markup: str) -> list[str]:
issues: list[str] = [] issues: list[str] = []
lower = markup.lower() lower = markup.lower()
if re.search(r"""(?i)(?:src|href)\s*=\s*['"]https?://""", markup): external_attr = re.search(
r"""(?i)\b(?:src|srcset|href|xlink:href|poster|action|formaction|data)\b\s*=\s*(?:['"]\s*)?https?://""",
markup,
)
external_css = re.search(
r"""(?i)(?:url\s*\(\s*['"]?\s*https?://|@import\s+(?:url\s*\(\s*)?['"]?\s*https?://)""",
markup,
)
if external_attr or external_css:
issues.append( issues.append(
"Remove external http(s) URLs — the sandboxed preview blocks them. " "Remove external http(s) URLs — the sandboxed preview blocks them. "
"Inline CSS/JS; use data: URIs for images/fonts." "Inline CSS/JS; use data: URIs for images/fonts."
@@ -1014,14 +1022,15 @@ STANDING_TOOLS = frozenset({"render_preview"})
# User-message cues that justify running the (slow, non-stream) tool loop with # User-message cues that justify running the (slow, non-stream) tool loop with
# render_preview. Kept narrow so ordinary chat isn't blocked behind a tool turn. # render_preview. Kept narrow so ordinary chat isn't blocked behind a tool turn.
_RENDER_HINTS = ( _RENDER_HINTS = (
"visual", "visualize", "visualization", "chart", "graph", "diagram", "visual", "visuals", "visualize", "visualization", "chart", "charts",
"canvas", "plot", "interactive", "animation", "render_preview", "graph", "graphs", "diagram", "diagrams", "canvas", "plot", "plots",
"interactive", "animation", "animations", "render_preview",
"render preview", "svg", "draw me", "live preview", "render preview", "svg", "draw me", "live preview",
"demonstrate", "demo", "html demo", "html snippet", "html file", "demonstrate", "demo", "html demo", "html snippet", "html file",
# Ways of asking for something that reacts to the pointer. "interactive" # Ways of asking for something that reacts to the pointer. "interactive"
# alone missed "mouse-over sensitive", and with it the whole feature. # alone missed "mouse-over sensitive", and with it the whole feature.
"hover", "mouse", "drag", "click on", "real-time", "realtime", "hover", "mouse", "drag", "click on", "real-time", "realtime",
"simulation", "simulate", "particle", "animate", "simulation", "simulations", "simulate", "particle", "particles", "animate",
# Every language the render window can display. Naming one is asking for a # Every language the render window can display. Naming one is asking for a
# preview, and this way a language added to PREVIEW_LANGS starts hinting # preview, and this way a language added to PREVIEW_LANGS starts hinting
# for itself instead of being unreachable until someone edits this tuple - # for itself instead of being unreachable until someone edits this tuple -
@@ -1031,8 +1040,12 @@ _RENDER_HINTS = (
def wants_render_preview(message: str) -> bool: def wants_render_preview(message: str) -> bool:
"""True when this turn should advertise render_preview / enter the tool loop.""" """True when this turn should advertise render_preview / enter the tool loop."""
import re
lower = (message or "").lower() lower = (message or "").lower()
return any(h in lower for h in _RENDER_HINTS) return any(
re.search(rf"(?<![A-Za-z0-9_]){re.escape(hint)}(?![A-Za-z0-9_])", lower)
for hint in _RENDER_HINTS
)
def is_action(name: str) -> bool: def is_action(name: str) -> bool:
+14
View File
@@ -533,3 +533,17 @@ def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch):
# Double-click must not launch a second pull/rebuild over the first. # Double-click must not launch a second pull/rebuild over the first.
assert client.post("/update/apply").json()["started"] is False assert client.post("/update/apply").json()["started"] is False
def test_preview_iframe_cannot_navigate_to_a_network_url():
"""The child CSP blocks resource loads; the parent CSP must separately
block a sandboxed frame from navigating its own browsing context."""
index = (REPO_ROOT / "interface" / "web" / "index.html").read_text(encoding="utf-8")
markdown = (REPO_ROOT / "interface" / "web" / "src" / "Markdown.jsx").read_text(
encoding="utf-8"
)
assert "frame-src data:" in index
assert 'sandbox="allow-scripts"' in markdown
assert "encodeURIComponent(doc)" in markdown
assert "src={frameUrl}" in markdown
assert "srcDoc={doc}" not in markdown
+17 -1
View File
@@ -514,9 +514,25 @@ def test_asking_for_a_preview_language_or_pointer_interaction_offers_the_tool():
assert tools.wants_render_preview(prompt), prompt assert tools.wants_render_preview(prompt), prompt
# Still narrow: ordinary chat must not pay for a tool turn. # Still narrow: ordinary chat must not pay for a tool turn.
for prompt in ("what's the weather vibe today", "summarise this email thread"): for prompt in (
"what's the weather vibe today",
"summarise this email thread",
"write a concise paragraph about caching",
):
assert not tools.wants_render_preview(prompt), prompt assert not tools.wants_render_preview(prompt), prompt
assert tools.wants_render_preview("compare these graphs")
def test_external_preview_resources_are_rejected_in_attributes_and_css():
for markup in (
'<img src=https://example.com/chart.png alt="chart">',
'<style>.chart { background: url("https://example.com/chart.png"); }</style>',
'<style>@import "https://example.com/chart.css";</style>',
):
issues = tools._critique_shared(markup)
assert any("external http(s)" in issue for issue in issues), markup
def test_every_preview_language_hints_for_itself(): def test_every_preview_language_hints_for_itself():
for lang in tools.PREVIEW_LANGS: for lang in tools.PREVIEW_LANGS: