Skip to content

feat: gate remote images in markdown preview behind a default-off pref (#69)#85

Merged
simion merged 1 commit into
simion:mainfrom
adamatan:feature/gate-remote-images
Jul 11, 2026
Merged

feat: gate remote images in markdown preview behind a default-off pref (#69)#85
simion merged 1 commit into
simion:mainfrom
adamatan:feature/gate-remote-images

Conversation

@adamatan

@adamatan adamatan commented Jul 11, 2026

Copy link
Copy Markdown
Contributor
gate-remote-images-demo.mp4

Summary

This fixes a problem where markdown images were loaded from remote sources without the user's consent, which can trigger nasty stuff like tracking pixels and record the user's IP address. To fix it, we block images by default. The user can then either permit images just for that one file, or turn loading on globally.

fixes #69

The underlying issue: the webview sits outside the seatbelt + CONNECT proxy cage, so a ![](https://...) in a previewed markdown file fired an unprompted GET the moment the file was previewed — no click, no prompt — to whatever host untrusted content named (prompt injection, a dependency's README, a contributor's fork). Accepted as a known gap in bca4e13; this closes it.

The gate. gateRemoteImages() in MarkdownPreview.tsx intercepts every http(s): <img> src before it ever reaches the DOM's src attribute — stashed in data-md-remote-src instead, so the browser never issues the request while blocked. Gated on prefs.loadRemoteImages (new pref, default OFF) or a per-tab remoteImagesUnblocked override; data:/blob: sources and task-relative images (already handled by hydrateTaskImages) are untouched. The CSP itself is unchanged (still allows https: in img-src) — this is a renderer gate, not a CSP tweak, since Tauri's CSP is one whole-webview policy that can't scope to a single component.

UX. simion asked for "a settings toggle in general to auto load them, off by default... implementation should be as simple as possible" — landed on two escape hatches rather than adamatan's original three-tier "this time/always in this repo/always" proposal, skipping a persisted per-repo tier. A banner (reusing TerminalExitedBanner, now with an opt-in center layout) surfaces both: "Show images" unblocks just the open document, session-only — dies with the tab, like mdView's per-tab override. "Always" flips the global pref; the same banner swaps its text/action in place (no toast) to a "Settings" link, which opens Settings, lands on General, and auto-scrolls to + briefly highlights the toggle (view.settingsHighlight, consumed and cleared on mount so a later manual visit doesn't re-trigger it).

Bug caught during manual testing. openPreviewTab's "recycle this preview tab into a different file" path (the standard single-click preview-tab reuse) carried remoteImagesUnblocked over from whichever file was previously shown in that tab slot — unblocking a file the user never actually approved. Reset alongside the existing reveal-target reset, with a regression test.

Test plan

  • npm testgateRemoteImages (block/restore/leave-data-blob-alone/leave-task-relative-alone), remoteImageBannerKind (precedence: blocked always wins over a stale confirm), the openPreviewTab recycle-reset regression, prefs.loadRemoteImages (default + persistence)
  • npx tsc -b
  • Live e2e pass against the automation bridge (isolated profile, never the driven user's own session): blocked-by-default with 4 remote images/4 hosts → one banner; "Show images" unblocks just that tab; recycling the same preview tab into a different file re-blocks it (confirms the fix above); "Always" flips the pref + confirm banner; "Settings" opens General and lands on/highlights the right toggle
  • Manual: /tmp/gate-test-repo fixture covering raw-HTML-wrapped images (DOMPurify path), http: (non-TLS), mermaid + remote image together, many-images stress case, and negative controls (no images, data: only, a link that isn't an image)

🤖 Generated with Claude Code

simion#69)

The markdown preview used to render any ![](https://...) image directly,
which meant an unprompted GET could fire to whatever host untrusted content
named the moment a file was previewed - prompt injection, a dependency's
README, a contributor's fork - since the webview sits outside the seatbelt
+ CONNECT proxy cage. Accepted as a known gap in bca4e13, tracked for
closing in simion#69. This gates that hydration behind a default-off pref, with
two escape hatches.

gateRemoteImages() in MarkdownPreview.tsx now intercepts every http(s)
<img> src before it ever reaches the DOM's src attribute, so the browser
never issues the request when blocked. Gated on prefs.loadRemoteImages
(a new pref, default OFF) or a per-tab remoteImagesUnblocked override.
The CSP itself is unchanged (still allows https: in img-src) - this is a
renderer gate, not a CSP tweak, since Tauri's CSP is one whole-webview
policy that can't scope to a single component.

Two escape hatches, surfaced as a banner in the preview: "Show images"
unblocks just the open document, session-only (dies with the tab, like
mdView's per-tab override). "Always" flips the global pref - the same
banner swaps its text/action in place to a "Settings" link, which opens
Settings, lands on General, and auto-scrolls to + briefly highlights the
toggle.

Also fixes a bug caught during manual testing: openPreviewTab's "recycle
this preview tab into a different file" path carried remoteImagesUnblocked
over from whichever file was previously shown in that tab slot, silently
unblocking images in a file the user never approved. Reset alongside the
existing reveal-target reset.

Covered by unit tests (gateRemoteImages, remoteImageBannerKind, the
openPreviewTab recycle-reset, prefs.loadRemoteImages) and a live e2e pass
driving the real app through all four flows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@simion

simion commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Awesome, will test, merge and release tonight.

What's the video recording software you're using? :D

Thanks

@simion

simion commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Thanks @adamatan, excellent work. This closes a real sandbox gap cleanly: the synchronous stash-and-strip in gateRemoteImages() (same mechanism hydrateTaskImages already relies on) means blocked images never fire a request, and doing it as a renderer gate rather than a CSP tweak is exactly right for Tauri's whole-webview policy. Nice catch on the openPreviewTab recycle leak, and the per-tab override / "Always" -> Settings-highlight UX is well wired and consistent with existing conventions. Verified: 233 tests pass, tsc -b and build clean, default stays OFF. Merging now, it'll ship in the next release. Appreciate the thoroughness and the tests.

@simion simion merged commit 98e5362 into simion:main Jul 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gate remote images in the markdown preview behind a default-off preference

2 participants