fix(preview): harden sandbox and transformation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user