fix(frontend): harden artifact and markdown rendering#4117
Conversation
|
Follow-up commits pushed on top of 1.
|
willem-bd
left a comment
There was a problem hiding this comment.
Reviewed the security-hardening changes. The direction is sound (allowlist for hrefs, no untrusted script execution in artifact iframes, wrapping localStorage, surfacing fetch errors), but I'm flagging one functional regression and a few correctness/comment issues:
- 🔴
sandbox="allow-same-origin"(noallow-scripts) breaks the app's own HTML artifact scroll-restoration script - the injected<script>can no longer run. - 🟠 Citation links bypass the new
isSafeHrefguard. - 🟠 The thread-history
!res.okthrow is swallowed by the existing inner catch, so it isn't actually surfaced to the UI / TanStack Query as the comment claims. - 🟡 The new
hooks.test.tsis a strawman (doesn't render the hook). - 🟡
mailto:/tel:links are now rejected;safeLocalStorageonly coverslocal.ts.
Details inline. Given the needs-validation label, the scroll-restoration regression in particular is worth verifying on a real HTML artifact.
| // origin and break inline scripts / forms in the rendered preview) | ||
| // while still denying cross-origin scripts, popups, top navigation, | ||
| // and form submission by default. | ||
| sandbox="allow-same-origin" |
There was a problem hiding this comment.
Regression: appendHtmlPreviewScrollRestoration (still called at line 496) injects a <script> into the blob HTML (preview.ts:216-253) that does window.parent.postMessage(...) to save/restore scroll position, and the parent listens for those messages (lines 460-488). With allow-scripts removed, that injected script never runs, so the iframe never emits save/restore-request events and scroll position is no longer preserved across re-renders. No test covers this - the e2e only asserts the sandbox attribute value, and the only scroll test is for markdown artifacts.
No security gain: the prior config (allow-scripts allow-forms, no allow-same-origin) was already the safe way to run sandboxed scripts - opaque origin can't reach parent.document/cookies, and postMessage(..., "*") works fine from it. So this trades a working feature for no real security benefit unless scroll restoration is reimplemented parent-side (which allow-same-origin would actually permit via contentDocument access).
Misleading comment: the rationale says staying same-origin prevents an opaque origin that would "break inline scripts / forms," but the new sandbox denies both scripts and forms. It should say they're intentionally disabled (matching the PR description) and explain why allow-same-origin is still needed.
There was a problem hiding this comment.
Confirmed and fixed — allow-scripts allow-forms is restored (the opaque-origin config was indeed already the safe one), and the comment now explains that scripts are needed for the injected scroll-restoration postMessage bridge while allow-same-origin is deliberately omitted. The e2e asserts the restored sandbox value.
| // Reject unsafe schemes up front so a prompt-injected / pasted href can | ||
| // never reach the rendered anchor. Keep the visible label so the user | ||
| // can still see what the link claimed to point at. | ||
| if (href !== undefined && !isSafeHref(href)) { |
There was a problem hiding this comment.
🟠 The isSafeHref guard runs after the citation branch (line 61), and CitationLink renders <a href={href}> directly (citation-link.tsx:34). So a prompt-injected [citation:x](javascript:alert(1)) matches ^citation:(.+)$ on the label, skips this guard, and renders a clickable javascript: anchor - exactly the threat this hardening targets.
Consider moving the isSafeHref check above the citation branch, or validating href inside CitationLink.
There was a problem hiding this comment.
Fixed — the isSafeHref guard now runs before the citation branch, so [citation:x](javascript:...) renders the disabled span. Added a render-level regression test ("blocks unsafe hrefs before the citation branch") that renders the component with a citation-labelled javascript: href and asserts no anchor is produced.
| * branch below and ``resolveArtifactURL``) — relative ``/…`` URLs are | ||
| * inherently safe and pass through ``URL.protocol === ""``. | ||
| */ | ||
| const SAFE_HREF_PROTOCOLS = ["http:", "https:"] as const; |
There was a problem hiding this comment.
🟡 Heads up: this allowlist also rejects mailto: and tel: schemes, so legitimate [contact](mailto:[email protected]) links now render as disabled spans. If that's intentional, fine - otherwise consider adding them. The unit test doesn't cover mailto:/tel:, so the behavior change is invisible.
There was a problem hiding this comment.
Added mailto: and tel: to the allowlist, updated the stale docstring that still claimed http/https-only, and added unit cases covering both schemes so the behavior is pinned.
| // never reach the rendered anchor. Keep the visible label so the user | ||
| // can still see what the link claimed to point at. | ||
| if (href !== undefined && !isSafeHref(href)) { | ||
| const { className, children, ...rest } = props; |
There was a problem hiding this comment.
🟡 Minor: this destructures only { className, children, ...rest }, leaving target/rel (valid AnchorHTMLAttributes) in rest spread onto a <span>, which triggers React DOM warnings. The external branch below extracts target/rel explicitly; this branch should too. (node from react-markdown is a pre-existing spread issue, not new.)
There was a problem hiding this comment.
Fixed more thoroughly: the span fallback no longer spreads ...rest at all, so anchor-only attributes (href/target/rel) and react-markdown's node prop never reach the <span>. The new render-level test actually caught href leaking onto the span in ArtifactLink via the spread — both fallbacks now only take className/children.
| credentials: "include", | ||
| }).then((res) => { | ||
| // 5xx/404 responses would otherwise be parsed as JSON and the real | ||
| // status swallowed. Surface the failure to TanStack Query so |
There was a problem hiding this comment.
🟠 The guard throws on !res.ok, but the surrounding catch (err) { console.error(err); } (line 1720) swallows it, so loadMessages() resolves normally and the effect's .catch(() => toast.error(...)) never fires. This is also a manual useCallback, not a TanStack Query, so isError does not flip - contrary to the comment added here ("Surface the failure to TanStack Query so isError flips").
The real effect is only a cleaner console.error and avoiding res.json() on HTML error bodies. To actually surface failures to the user, rethrow past the inner catch (or remove it) so the toast fires.
There was a problem hiding this comment.
You were right that the throw was swallowed and the TanStack comment was wrong. This is now moot: upstream #4065 rewrote useThreadHistory as a real TanStack useInfiniteQuery whose queryFn throws on !response.ok (via readResponseErrorMessage) and toasts on historyQuery.error — exactly what this change was groping toward. After rebasing, this PR no longer touches core/threads/hooks.ts.
| }, | ||
| }); | ||
|
|
||
| // Mirror the .then guard from `useThreadHistory` so dropping the |
There was a problem hiding this comment.
🟡 This test reconstructs the .then guard inline and asserts it rejects - it never renders useThreadHistory. So removing the guard from hooks.ts would not fail this test (the test has its own copy of the guard). The comment here claiming it pins the contract ("dropping the !res.ok throw causes this expectation to flip ... and the test to fail loudly") is inaccurate; it only verifies the fetch wrapper passes ok/status/statusText through.
Consider an integration test that renders the hook with a stubbed fetch returning 500 and asserts the error surfaces.
There was a problem hiding this comment.
Agreed it was a strawman — the test re-implemented the guard instead of exercising it. The file was removed during the rebase: the manual .then guard it mirrored no longer exists after upstream #4065 replaced loadMessages with a TanStack useInfiniteQuery.
| * settings panel. This wrapper traps every storage exception so callers can | ||
| * always fall back to a sane default. | ||
| */ | ||
| const safeLocalStorage = { |
There was a problem hiding this comment.
🟡 Only core/settings/local.ts is wrapped. core/agents/feature-cache.ts:21,33 and app/workspace/agents/new/page.tsx:133,137 still call window.localStorage directly and can throw into render handlers. Fine if intentionally scoped to settings, but the "best-effort storage" robustness is inconsistent across the app.
There was a problem hiding this comment.
Made it consistent: safeLocalStorage is now exported and the agent-create save-hint effect (app/workspace/agents/new/page.tsx) uses it — that was the only remaining unguarded call site. core/agents/feature-cache.ts already wraps its window.localStorage access in try/catch internally, so it was left as is.
willem-bd
left a comment
There was a problem hiding this comment.
Thanks @nankingjing. One net-new test-coverage gap beyond the prior review.
[P2] markdown-link unit test only exercises isSafeHref in isolation, not the rendered component
- Location:
frontend/tests/unit/components/workspace/messages/markdown-link.test.ts:1-21 - Problem: The new test imports and calls
isSafeHrefdirectly but never renders theMarkdownLinkcomponent. If theisSafeHrefguard atmarkdown-link.tsx:74is removed or its condition is inverted (!isSafeHref→isSafeHref), every existing assertion still passes because the function itself is unchanged - yet the component would render a clickablejavascript:anchor, silently reintroducing the XSS this PR is meant to prevent. - Evidence: The test file imports only
{ isSafeHref }and asserts on its boolean return. No test renderscreateMarkdownLinkComponent()output with ajavascript:href and asserts the DOM contains a<span>rather than an<a>. - Suggested fix: Add a render-level case, e.g.
render(<MarkdownLink href="javascript:alert(1)">x</MarkdownLink>)and assertcontainer.querySelector("a")is null andcontainer.querySelector("span")is not null. Mirror it with a positive case (https://example.com) asserting an<a>is rendered. - Test: The render-level test itself - it fails if the guard is removed or the condition flipped.
…tation XSS bypass, surface fetch errors
willem-bd identified three issues:
1. [REGRESSION] sandbox removed allow-scripts which broke HTML artifact
scroll-restoration — the injected postMessage script could not run.
The prior config (allow-scripts allow-forms without allow-same-origin,
i.e. opaque origin) was already safe. Restored with corrected comment.
2. [XSS bypass] CitationLink rendered <a href={href}> directly before
isSafeHref ran, so prompt-injected [citation:x](javascript:...)
bypassed the safety check. Moved citation block after the guard.
3. [Silent failure] loadMessages(!res.ok) throw was swallowed by inner
catch(console.error), so TanStack isError never flipped and toast
never fired. Re-throw after console.error.
Also:
- Added mailto: and tel: to SAFE_HREF_PROTOCOLS
- Extracted target/rel from rest spread onto <span> (React DOM warning)
dca9190 to
85a44bc
Compare
|
Restored |
willem-bd
left a comment
There was a problem hiding this comment.
Review findings (4 inline comments). Details in each thread.
| // citation block so prompt-injected [citation:x](javascript:...) is | ||
| // blocked. Keep the visible label so the user can still see what the | ||
| // link claimed to point at. | ||
| if (href !== undefined && !isSafeHref(href)) { |
There was a problem hiding this comment.
This guard is good, but it only covers createMarkdownLinkComponent (used by message content + message-list-item). Markdown artifacts render through a separate, unguarded component: artifact-file-detail.tsx renders .md previews via <SafeStreamdown components={{ a: ArtifactLink }}> (artifact-file-detail.tsx:518), and ArtifactLink (citations/artifact-link.tsx) renders <a {...rest} /> with no scheme check.
A prompt-injected [x](javascript:...) in a .md artifact would then execute in the main document, where sessionStorage / CSRF cookies are reachable - exactly what this guard prevents elsewhere. Consider applying the exported isSafeHref at the top of ArtifactLink (same span fallback), or having ArtifactLink delegate to this shared component.
There was a problem hiding this comment.
Fixed — ArtifactLink now applies the shared isSafeHref guard up front and renders the same disabled-span fallback, so javascript:/data: hrefs in .md artifact previews can't reach a real anchor in the main document. Added render-level tests for both the unsafe-span and safe-anchor paths (which also caught the href leaking onto the fallback span through the rest spread — fixed).
| } while (pendingLoadRef.current); | ||
| } catch (err) { | ||
| console.error(err); | ||
| throw err; |
There was a problem hiding this comment.
The rethrow does enable the auto-load toast at loadMessages().catch(() => toast.error(...)) (hooks.ts:1750) - that's the real improvement here. But two issues:
-
The comment on the
!res.okblock above says this surfaces the failure "to TanStack Query soisErrorflips on the caller side."loadMessagesisn't a queryFn (it uses local refs +setMessageRows), and the hook's return object (hooks.ts:1772) has noisError/errorfield - soisErrornever flips anywhere. The comment is misleading. -
loadMessagesis also exposed asloadMoreand called as a bareloadMore?.()without.catch()fromLoadMoreHistoryIndicator(message-list.tsx:133/147, wired vialoadMore={loadMoreHistory}at :708). Before this change the catch swallowed the error so it never rejected; now a 5xx on the scroll/manual path produces an unhandled promise rejection (and no toast on that path), and theloadMore?: () => voidsignature understates the new contract.
Suggest catching + toasting inside loadMessages and not rethrowing (keeps the toast, removes the unhandled-rejection surface), or adding .catch() at both call sites. If callers need to observe failures, expose an explicit error/isError field rather than throwing through a () => void callback.
There was a problem hiding this comment.
Both points were valid — and both are now moot after the rebase: upstream #4065 replaced loadMessages with useInfiniteQuery, so loadMore is historyQuery.fetchNextPage (which never throws through the callback — TanStack captures failures in query state) and errors surface through historyQuery.error + toast. This PR no longer touches core/threads/hooks.ts.
| // status swallowed. Surface the failure to TanStack Query so | ||
| // `isError` flips on the caller side and we stop pretending the | ||
| // thread has an empty history. | ||
| if (!res.ok) { |
There was a problem hiding this comment.
This new error-surfacing path (the !res.ok throw here + the throw err rethrow below) doesn't have test coverage. Worth adding a test asserting a 5xx / 404 surfaces the failure to the caller and doesn't leave loading stuck true.
There was a problem hiding this comment.
After rebasing onto main, the error path here belongs to upstream #4065's useInfiniteQuery queryFn (throws on !response.ok, toasts via historyQuery.error, and loading derives from isLoading/isFetchingNextPage so it can't stick). Our duplicate guard and its test were dropped rather than shadow-testing upstream's implementation from this PR.
| ).toBeVisible(); | ||
| await expect( | ||
| artifactsPanel.locator('iframe[title="Artifact preview"]'), | ||
| ).toHaveAttribute("sandbox", "allow-scripts allow-forms"); |
There was a problem hiding this comment.
Good to assert the sandbox on the HTML preview iframe. The PR also adds sandbox="" to the other artifact iframe - the urlOfArtifact one at artifact-file-detail.tsx:363 - but there's no assertion covering it. A parallel toHaveAttribute("sandbox", "") would catch a future regression (e.g. someone adding allow-scripts) on that iframe too.
There was a problem hiding this comment.
Done — added an e2e that opens a PDF write_file artifact and asserts sandbox="" on the urlOfArtifact iframe. Writing it surfaced that non-code browser-previewable write_file artifacts were forced into the code editor (iframe never rendered — the CI failure you saw); fixed so they render in the sandboxed iframe. Full Playwright suite passes locally (95/95).
…oadMessages toast, test coverage, sandbox e2e - Apply isSafeHref guard to ArtifactLink (artifact-link.tsx) to block prompt-injected javascript:/data: hrefs in .md artifact previews, matching the guard in createMarkdownLinkComponent. - Remove throw err from loadMessages catch block to eliminate unhandled promise rejection at LoadMoreHistoryIndicator; toast inside loadMessages so both call sites (effect + loadMore) surface errors. Fix misleading comment about TanStack Query isError. - Add unit tests for the !res.ok fetch guard path (500/404/200). - Add e2e sandbox assertion for the urlOfArtifact iframe (non-code browser-previewable files like PDF). Co-Authored-By: Claude <[email protected]>
…tation XSS bypass, surface fetch errors
willem-bd identified three issues:
1. [REGRESSION] sandbox removed allow-scripts which broke HTML artifact
scroll-restoration — the injected postMessage script could not run.
The prior config (allow-scripts allow-forms without allow-same-origin,
i.e. opaque origin) was already safe. Restored with corrected comment.
2. [XSS bypass] CitationLink rendered <a href={href}> directly before
isSafeHref ran, so prompt-injected [citation:x](javascript:...)
bypassed the safety check. Moved citation block after the guard.
3. [Silent failure] loadMessages(!res.ok) throw was swallowed by inner
catch(console.error), so TanStack isError never flipped and toast
never fired. Re-throw after console.error.
Also:
- Added mailto: and tel: to SAFE_HREF_PROTOCOLS
- Extracted target/rel from rest spread onto <span> (React DOM warning)
…oadMessages toast, test coverage, sandbox e2e - Apply isSafeHref guard to ArtifactLink (artifact-link.tsx) to block prompt-injected javascript:/data: hrefs in .md artifact previews, matching the guard in createMarkdownLinkComponent. - Remove throw err from loadMessages catch block to eliminate unhandled promise rejection at LoadMoreHistoryIndicator; toast inside loadMessages so both call sites (effect + loadMore) surface errors. Fix misleading comment about TanStack Query isError. - Add unit tests for the !res.ok fetch guard path (500/404/200). - Add e2e sandbox assertion for the urlOfArtifact iframe (non-code browser-previewable files like PDF). Co-Authored-By: Claude <[email protected]>
2c844ec to
78b4549
Compare
|
@nankingjing, please take a look at the failed tests. |
… (drop /mock/ prefix) The test used /mock/api/threads/... for the PDF artifact content route, but the urlOfArtifact helper generates /api/threads/... (without /mock/) when isMock=false. The other tests (e.g. presented artifacts) already use the correct /api/threads/... pattern. Co-Authored-By: Claude <[email protected]>
78b4549 to
794345f
Compare
|
@nankingjing Please resolve the conflicts. |
…tation XSS bypass
willem-bd identified these issues:
1. [REGRESSION] sandbox removed allow-scripts which broke HTML artifact
scroll-restoration — the injected postMessage script could not run.
The prior config (allow-scripts allow-forms without allow-same-origin,
i.e. opaque origin) was already safe. Restored with corrected comment.
2. [XSS bypass] CitationLink rendered <a href={href}> directly before
isSafeHref ran, so prompt-injected [citation:x](javascript:...)
bypassed the safety check. Moved citation block after the guard.
Also:
- Added mailto: and tel: to SAFE_HREF_PROTOCOLS
- Kept anchor-only attributes off the <span> fallback (React DOM warning)
Note: the loadMessages re-throw originally in this commit was dropped
during the rebase — upstream bytedance#4065 rewrote useThreadHistory as a
TanStack useInfiniteQuery that already surfaces fetch failures.
The artifact preview iframe's sandbox was restored to 'allow-scripts allow-forms' for scroll-restoration. Update the E2E test to expect the corrected value.
…tifact sandbox e2e - Apply the isSafeHref guard to ArtifactLink (artifact-link.tsx) so a prompt-injected javascript:/data: href in a .md artifact preview cannot reach a real anchor in the main document, matching the guard in createMarkdownLinkComponent. - Add an e2e asserting the urlOfArtifact iframe (non-code browser-previewable files like PDF) keeps its empty sandbox. Note: the loadMessages error-surfacing changes originally in this commit were dropped during the rebase — upstream bytedance#4065 rewrote useThreadHistory as a TanStack useInfiniteQuery whose queryFn already throws on !response.ok and surfaces the failure via a toast.
…n sandboxed iframe When a write_file artifact such as PDF is clicked in chat, the component receives a path that forced isCodeFile=true, hiding the sandboxed iframe behind the code editor. Now non-code browser-previewable files are detected early so the sandboxed iframe renders correctly. Fixes the E2E test: renders sandboxed iframe for a browser-previewable non-code file.
… (drop /mock/ prefix) The test used /mock/api/threads/... for the PDF artifact content route, but the urlOfArtifact helper generates /api/threads/... (without /mock/) when isMock=false. The other tests (e.g. presented artifacts) already use the correct /api/threads/... pattern.
…t links
- Render MarkdownLink and ArtifactLink via renderToStaticMarkup and
assert an unsafe javascript: href produces a disabled <span> (never an
<a>), including through the citation-labelled branch, and that safe
https hrefs render hardened anchors (target=_blank, rel=noopener).
- The new ArtifactLink render test caught the unsafe href leaking onto
the fallback <span> through the {...rest} spread; drop the spread in
both span fallbacks so anchor-only attributes (href/target/rel) and
react-markdown's node prop never reach the span.
- Cover mailto:/tel: in the isSafeHref unit cases and fix the stale
SAFE_HREF_PROTOCOLS docstring that still claimed http/https-only.
…e facade Export safeLocalStorage from core/settings/local and use it for the agent-create save-hint read/write so blocked browser storage (Safari private mode, strict containers, embedded WebViews) cannot throw from the effect. core/agents/feature-cache.ts already guards its localStorage access with try/catch, so settings + agent pages are now consistently best-effort.
794345f to
2aa447e
Compare
|
@WillemJiang Conflicts resolved and the failed test fixed — branch rebased onto latest Rebase / conflict resolution. The only overlap was Failed E2E test. Remaining review items from @willem-bd are addressed (inline replies on each thread), including the P2 note about the Validation on the rebased branch: |
e4d7393 to
95aa048
Compare
|
@fancyboi999 thanks for catching the scheme-less relative URL regression in the last review. Fix applied in 95aa048:
Gate results on the fix commit:
Additional audit finding (documented, not a fix needed at this time): subtask-card.tsx:199 renders task.prompt through <SafeStreamdown components={{ a: CitationLink }}>. CitationLink renders without its own scheme guard — but this is already protected by streamdown's bundled rehype-harden plugin (v1.1.7), which blocks javascript:/data:/file:/vbscript: hrefs and clears them to "" before they reach the component. The protection is correct at runtime; the PR's caller-side guards (MarkdownLink, ArtifactLink) are defence-in-depth. |
…UI hardening - bytedance#4117 XSS hardening (isSafeHref, iframe sandbox, safeLocalStorage) - bytedance#4354 streaming chunk batcher for large file tools - bytedance#4294 LLM concurrency cap + burst-rate retry shedding - bytedance#4355 E2B release serialization - bytedance#4267 transient image context cleanup - bytedance#4374 AI disclaimer (bytedance#4373 reasoning-effort default) - bytedance#4337 /goal length validation + counter - bytedance#4278/bytedance#4302/bytedance#4312 URL encoding fixes - bytedance#4375 list_uploaded_files schema fix - bytedance#4288 uploads markdown companion name claim - bytedance#4258 remove frontend debug logs
Summary
sandbox="allow-scripts allow-forms"(opaque origin; scripts are required by the injected scroll-restorationpostMessagebridge) and theurlOfArtifactiframe (PDF/images/audio/video) gets an emptysandboxwrite_fileartifacts (e.g. PDF) render in the sandboxed iframe instead of being forced into the code editorjavascript:,data:,file:, …) before rendering anchors — in chat messages (MarkdownLink, including the citation branch) and in.mdartifact previews (ArtifactLink);mailto:/tel:stay allowedScope notes
The thread-history error-surfacing changes were dropped while rebasing: upstream #4065 rewrote
useThreadHistoryas a TanStackuseInfiniteQuerywhose queryFn already throws on!response.okand toasts the failure, so this PR no longer touchescore/threads/hooks.ts.Validation
pnpm check(eslint + tsc) — cleanpnpm test— 653 unit tests passpnpm format— cleanurlOfArtifactsandbox test