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">
<head>
<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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NexusOS</title>
+13 -10
View File
@@ -304,8 +304,8 @@ const _MIN_PREVIEW_H = 160;
const _MAX_PREVIEW_H = 720;
const _MAX_H_STEPS = 60;
// Live preview for a ```html or ```svg fenced block: a Preview/Code toggle,
// rendered via a sandboxed <iframe srcDoc>.
// Live preview for a renderable fenced block: a Preview/Code toggle rendered
// via a sandboxed iframe whose document is an encoded data: URL.
//
// Trust boundary: `sandbox="allow-scripts"` — deliberately without
// 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
// 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
// above closes the remaining gap same-origin-denial doesn't cover on its own:
// arbitrary outbound network access to the open internet. Nothing here is
// treated as a substitute for real code execution sandboxing (Docker, a WASM
// runtime, etc.) - this block never runs anything outside the browser's own
// iframe sandbox, which is the point: no backend attack surface at all.
// above blocks resource and script-initiated network access. The embedding
// document's `frame-src data:` policy in index.html closes a separate CSP gap:
// a child is otherwise allowed to navigate its own browsing context to a URL.
// The initial data: document is allowed and inherits the parent policy, while
// 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 }) {
const [tab, setTab] = useState("preview");
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
// 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
// already has its own streaming indicator (the same dot CodeBlock uses).
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
// the message stands in for the frame rather than sitting under it.
const { doc, error: buildError } = buildSrcDoc(lang, value);
const frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
const shown = buildError || error;
return (
<>
{doc && (
{frameUrl && (
<iframe
ref={frameRef}
title="rendered output"
sandbox="allow-scripts"
srcDoc={doc}
src={frameUrl}
style={{
width: "100%",
height: expanded ? "70vh" : `${height}px`,
+46 -1
View File
@@ -298,7 +298,7 @@ function handleWord(sc, word) {
if (word === "import" && atStatement) {
if (sc.peek() === "(" || sc.peek() === ".") { sc.emit(word); return true; } // import()/import.meta
const from = sc.i;
skipStatement(sc);
skipImportStatement(sc);
recordImport(sc, sc.src.slice(from, sc.i));
return true;
}
@@ -400,6 +400,51 @@ function skipStatement(sc) {
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) {
const from = sc.i;
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;");
});
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", () => {
// 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.