feat(web): render HTML and PDF files in the file viewer#9143

merged2
t3code/open-outside-workspace-files
t3code/html-preview-in-panel
openedSep 2, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago
5 unresolved threads · Jump to file
Selected thread: apps/web/src/components/files/FilePreviewPanel.tsx:230 — ### Host previews skip exact-file remint **Medium Severity** <!-- DESCRIPTION START --> Host HTML and PDF previews mint a `media-file` URL, which is bound to device and inode, but only append `workspace-revision` on change. Video previews remint that exact-file capability. After an outside-workspace file is replaced, the iframe can keep a dead or cached URL instead of a new capability. <!-- DESCRIPTION END --> <!-- BUGBOT_BUG_ID: 62d8a8f8-ca76-4eb6-ad64-8a1ef02c1895 --> <!-- LOCATIONS START apps/web/src/components/files/FilePreviewPanel.tsx#L215-L230 apps/web/src/components/files/FilePreviewPanel.tsx#L279-L288 LOCATIONS END --> <details> <summary>Additional Locations (1)</summary> - [`apps/web/src/components/files/FilePreviewPanel.tsx#L279-L288`](https://github.com/pingdotgg/t3code/blob/4f990b7ec27919888b1d159f46aaba7f7a8fc23e/apps/web/src/components/files/FilePreviewPanel.tsx#L279-L288) </details> <div><a href="https://cursor.com/open?link=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OmU1MjYyZTk4LWE3ODAtNDZhNy04NGNkLTQ3MzA5MTU2ZDI2ZCIsImVuY3J5cHRpb25LZXkiOiJ6ZWNBSUdmOUxDaHJTbjcxVFRlbzZkN0E0R3V1bmh6bi1RTXNQS0NhNlYwIiwiYnJhbmNoIjoidDNjb2RlL2h0bWwtcHJldmlldy1pbi1wYW5lbCIsInJlcG9Pd25lciI6InBpbmdkb3RnZyIsInJlcG9OYW1lIjoidDNjb2RlIiwicHJvdmlkZXIiOiJnaXRodWIifX0" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/fix-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/fix-in-cursor-light.png"><img alt="Fix in Cursor" width="115" height="28" src="https://cursor.com/assets/images/fix-in-cursor-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/agents?link=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OmU1MjYyZTk4LWE3ODAtNDZhNy04NGNkLTQ3MzA5MTU2ZDI2ZCIsImVuY3J5cHRpb25LZXkiOiJ6ZWNBSUdmOUxDaHJTbjcxVFRlbzZkN0E0R3V1bmh6bi1RTXNQS0NhNlYwIiwiYnJhbmNoIjoidDNjb2RlL2h0bWwtcHJldmlldy1pbi1wYW5lbCIsInJlcG9Pd25lciI6InBpbmdkb3RnZyIsInJlcG9OYW1lIjoidDNjb2RlIiwicHJOdW1iZXIiOjkxNDMsImNvbW1pdFNoYSI6IjRmOTkwYjdlYzI3OTE5ODg4YjFkMTU5ZjQ2YWFiYTdmN2E4ZmMyM2UiLCJwcm92aWRlciI6ImdpdGh1YiJ9fQ" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/fix-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/fix-in-web-light.png"><img alt="Fix in Web" width="99" height="28" src="https://cursor.com/assets/images/fix-in-web-dark.png"></picture></a></div> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4f990b7ec27919888b1d159f46aaba7f7a8fc23e. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
0 / 2 viewed2 files changedLine totals unavailable

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

M
macroscopeappBotSep 2, 2026

đź”´ 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.

suggestion
1 sandbox="allow-forms allow-popups allow-modals"
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/files/FilePreviewPanel.tsx around line 258:
2
3Untrusted 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.
J
Julius MarmingeSep 2, 2026

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);
C
cursorBotSep 2, 2026

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.

J
Julius MarmingeSep 2, 2026

Fixed in 4f990b7ec: strips ?/# like the shared helper.