feat(web): render HTML and PDF files in the file viewer#9143
5 unresolved threads Β· Jump to file
- apps/web/src/components/files/FilePreviewPanel.tsx:200β200 Β· <!-- MURMUR_IGNORE --> π **High** `files/FilePreviewPanel.tsx:200` A selected PDF path with a quer
- apps/web/src/components/files/FilePreviewPanel.tsx:258β258 Β· π΄ **Critical** `files/FilePreviewPanel.tsx:258` Untrusted workspace HTML can execute JavaScript an
- apps/web/src/components/files/FilePreviewPanel.tsx:230 Β· ### Host previews skip exact-file remint **Medium Severity** <!-- DESCRIPTION START --> Host HTML
- apps/web/src/components/files/FilePreviewPanel.tsx:261 Β· ### Rendered HTML ignores in-panel edits **Medium Severity** <!-- DESCRIPTION START --> Toggling a
- apps/web/src/components/files/FilePreviewPanel.tsx:200 Β· ### PDF classification misses paths containing `?` or `#` **Low Severity** <!-- DESCRIPTION START
apps/web/src/components/files/FilePreviewPanel.tsx Β· Earlier revision 95496904 Β· right 258β258
Original diff
@@ -196,6 +197,69 @@ function WorkspaceImagePreview(props: {
);
}
+const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path);
+
+/**
+ * Renders an HTML or PDF file in place from its signed asset URL. HTML runs in
+ * a sandboxed frame with an opaque origin, so a page cannot reach the app's
+ * session or storage. A file inside the workspace may load sibling assets; a
+ * host file outside it is served on its own.
+ */
+function WorkspaceBrowserPreview(props: {
+ readonly environmentId: EnvironmentId;
+ readonly threadRef: ScopedThreadRef;
+ readonly absolutePath: string;
+ readonly workspaceRoot: string;
+ readonly title: string;
+ readonly workspaceMutationId: string | null;
+}) {
+ const insideWorkspace =
+ mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined;
+ const resource = useMemo(
+ () => ({
+ _tag: insideWorkspace ? ("workspace-file" as const) : ("media-file" as const),
+ threadId: props.threadRef.threadId,
+ path: props.absolutePath,
+ }),
+ [insideWorkspace, props.threadRef.threadId, props.absolutePath],
+ );
+ const assetUrl = useAssetUrlState(props.environmentId, resource);
+ const revisionSuffix =
+ props.workspaceMutationId === null
+ ? ""
+ : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
+
+ if (assetUrl._tag === "Failure") {
+ return (
+ <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
+ Unable to load file preview.
+ </div>
+ );
+ }
+ if (assetUrl._tag !== "Success") {
+ return (
+ <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
+ <LoaderCircle className="size-5 animate-spin" />
+ </div>
+ );
+ }
+ const src = `${assetUrl.url}${revisionSuffix}`;
+ const className = "min-h-0 flex-1 border-0 bg-white";
+ // The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts.
+ return isPdfPreviewFile(props.absolutePath) ? (
+ // oxlint-disable-next-line react/iframe-missing-sandbox
+ <iframe key={src} src={src} title={props.title} className={className} />
+ ) : (
+ <iframe
+ key={src}
+ src={src}
+ title={props.title}
+ className={className}
+ sandbox="allow-scripts allow-forms allow-popups allow-modals"apps/web/src/components/files/FilePreviewPanel.tsx Β· right 258β258
π΄ Critical files/FilePreviewPanel.tsx:258
Untrusted workspace HTML can execute JavaScript and exfiltrate its location.href, which contains the bearer-signed workspace-file URL; anyone receiving that URL can fetch the selected file and permitted sibling assets until expiry. The opaque sandbox origin does not hide the frame URL, so remove allow-scripts or otherwise avoid exposing the bearer credential to executable page content.
| 1 | sandbox="allow-forms allow-popups allow-modals" |
π Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/web/src/components/files/FilePreviewPanel.tsx around line 258: |
| 2 | |
| 3 | Untrusted workspace HTML can execute JavaScript and exfiltrate its `location.href`, which contains the bearer-signed `workspace-file` URL; anyone receiving that URL can fetch the selected file and permitted sibling assets until expiry. The opaque sandbox origin does not hide the frame URL, so remove `allow-scripts` or otherwise avoid exposing the bearer credential to executable page content. |
Considered and accepted. The desktop integrated browser and the mobile WebView already load this exact signed URL with scripts enabled, so this is not a new exposure, and docs/internals/environment-auth.md already treats signed asset URLs as bearer capabilities. What the page can exfiltrate is a one-hour, single-file (or, inside the workspace, preview-extension-only sibling) read of content the author already controls, to an attacker who can also reach the environment. Removing allow-scripts would break most agent-generated reports (charts, tabs), which is the point of the feature. The frame has an opaque origin, so it cannot reach the app's session or storage. Dismissing.
apps/web/src/components/files/FilePreviewPanel.tsx Β· Earlier revision 4028a2d6 Β· right 200
Original diff
@@ -196,6 +197,69 @@ function WorkspaceImagePreview(props: {
);
}
+const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path);PDF classification misses paths containing ? or #
Low Severity
isPdfPreviewFile tests the raw path with /\.pdf$/i without stripping ? or # characters, while isBrowserPreviewFile (used on the same input one line later) splits on [?#] before matching. If a path contains a literal # or ? after .pdf, isPdfPreviewFile returns false but isBrowserPreviewFile returns true, so the file falls into the isHtml branch. This means a PDF would get a sandboxed iframe (breaking the built-in PDF viewer), an enabled text query for content it can't display as text, and a source-toggle that doesn't apply. The shared hasPreviewExtension in filePreview.ts also strips these characters, making the local function the odd one out.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4028a2d6a2cafcbc0839a8ab7c111b44141bf5bd. Configure here.
Fixed in 4f990b7ec: strips ?/# like the shared helper.