feat(desktop): prototype rich link preview cards for generic URLs - #3744
thomaspblock wants to merge 5 commits into
Conversation
Extends the existing compact provider-specific link previews (GitHub/Linear/Google) with generic OpenGraph preview cards for arbitrary URLs in channel messages. - linkPreview.ts: extractGenericLinkPreviewUrls (max 3 per message; skips media URLs, provider-covered URLs, code/spoiler/image-masked content) + RichLinkPreviewMetadata type - useRichLinkPreviews.ts: hook with in-memory caching that invokes the new Tauri command - rich-link-preview-card.tsx: card UI + RichLinkPreviews group - link-preview-attachment.tsx: combined MessageLinkPreviews component (compact provider cards + rich generic cards) so markdown.tsx stays under its file-size ratchet - Rust fetch_link_preview_metadata: OG title/description/site_name/ image/favicon parsing with 3-redirect limit and 256KB/4s caps; requires a title or no card renders - e2eBridge: linkPreviewMetadata mock config; screenshot.mjs gains --mock-config for injecting it Verified: 3787 desktop unit tests, tsc, biome, file-size ratchet, cargo test link_preview (6/6), light/dark screenshots. Signed-off-by: npub1cr79sy35cdv9vqsnnmkrgl8d0wp27edk7mqsw2p5s52upsrv28pszw3kur <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz> Co-authored-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Per feedback in the buzz-frontend thread: the card should sit on the theme's normal background. Remove the bg-muted fill and hover fill; keep the border with its hover emphasis. Signed-off-by: npub1cr79sy35cdv9vqsnnmkrgl8d0wp27edk7mqsw2p5s52upsrv28pszw3kur <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz> Co-authored-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Per feedback in the buzz-frontend thread: shrink the preview card by moving the OG image from a full-width banner (max-h-44) to a 64px thumbnail beside the text, Slack-compact style. Card height is now bounded by the two-line description regardless of image aspect. Co-authored-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: npub1cr79sy35cdv9vqsnnmkrgl8d0wp27edk7mqsw2p5s52upsrv28pszw3kur <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
clippy -D warnings (dead_code) fails on macOS because PROD_ORIGIN, DEV_ORIGIN, and is_trusted_media_origin are only referenced from the target_os = "linux" implementation and the test module. Gate them with cfg(any(target_os = "linux", test)) so all-targets clippy passes on every host while Linux builds and the cross-platform unit tests are unchanged. Co-authored-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Thomas Petersen <thomasp@squareup.com>
jedwards27
left a comment
There was a problem hiding this comment.
:bot: Jude’s code review agent — REQUEST CHANGES
Reviewed 02be413b823c356587e6e9f4d07f6cb06bb41c3c..d983c67c002e1939d57e1c41359b972117b88fd7 at exact head d983c67c002e1939d57e1c41359b972117b88fd7 (clean worktree). This is high risk because displaying channel content now initiates native and webview network traffic chosen by the message author.
Blocking findings
-
[P1] Rendering an untrusted message permits recipient-side SSRF. Every interactive message mounts the preview path (
desktop/src/shared/ui/markdown.tsx:1964), which extracts up to three arbitrary HTTP(S) URLs and automatically invokes the native command (desktop/src/shared/lib/linkPreview.ts:572-605,desktop/src/shared/lib/useRichLinkPreviews.ts:14-18,52-80). The command checks only scheme/host and then follows three redirects (desktop/src-tauri/src/commands/link_preview.rs:28-57); it never resolves or rejects loopback, RFC1918, link-local, CGNAT, ULA, mapped/translated IPv6, or redirect destinations. A channel member can therefore make every recipient Desktop GET localhost/LAN/cloud-metadata targets merely by opening the channel. This contradicts #3369's explicit server-side-fetch and private-address requirements. The repository already has reserved-address classification incrates/buzz-core/src/network.rs:22-95, but safe fetching also needs address validation/pinning at connection time and on every manually handled redirect to resist rebinding. Add positive-public and negative loopback/private/link-local/CGNAT/ULA/mapped-IP/redirect/rebinding tests around the real fetch path. -
[P1] Origin-controlled images bypass the native boundary and hotlink from each recipient webview.
og:imageand favicon values are returned as arbitrary HTTP(S) URLs (desktop/src-tauri/src/commands/link_preview.rs:95-108,181-186) and assigned directly to<img src>(desktop/src/shared/ui/rich-link-preview-card.tsx:21-50,115). This leaks recipients' IP/request timing to a sender-controlled tracker and allows a second private-network request path outside the native byte/timeout controls. #3369 specifically requires proxying and re-encoding so recipient clients never contact the origin. Fetch validated public images through a hardened relay/native proxy, bound time/bytes/type/dimensions, re-encode them, and test that the webview never receives the attacker URL and private/redirecting image targets are rejected. -
[P2] The advertised four-second bound ends at response headers, so body reads can stall indefinitely.
tokio::time::timeoutwraps onlyrequest.send()(desktop/src-tauri/src/commands/link_preview.rs:54-57);read_limited_text(response).awaitis outside it (:73-75,276-290). A server can send successful HTML headers and then drip or stop before 256 KiB. With up to three requests per message and no global concurrency bound, a malicious timeline can accumulate stalled tasks/sockets. Bound the entire send-plus-body operation, constrain concurrency, and add a server test that sends headers then stalls/drips and proves bounded completion/cleanup. -
[P2] The new global cache is unbounded, permanent, cross-community, and makes transient failures permanent until restart.
metadataCachestores promises, results, andnullforever (desktop/src/shared/lib/useRichLinkPreviews.ts:12-35) and is absent from the required community reset (desktop/src/features/communities/useCommunityInit.ts:48-72). This retains URL/metadata state across community switches, grows with every viewed URL, and prevents retry after an offline/timeout failure. Add bounded TTL/LRU semantics, clear it on community reset, and test switch, expiry, eviction, and transient-failure retry.
UX and evidence gaps
The issue now requires Desktop/mobile consistency and displaying the destination domain alongside the site name. This PR is Desktop-only and renders siteName ?? hostname (desktop/src/shared/ui/rich-link-preview-card.tsx:102-105), so an origin-controlled site name can hide the actual domain. The absolute card anchor also has no explicit focus-visible treatment (:90-125). Please either align the issue/scope explicitly or implement and regression-test these trust/accessibility requirements.
The added tests cover URL extraction and HTML parsing, not the security/network boundary or rendered workflow. There is no new component/Playwright coverage for asynchronous failure/retry, keyboard focus/activation, narrow layout, domain display, or proving that the webview does not contact origin-controlled assets. Mocked happy-path screenshots do not establish those contracts.
Validation
At the exact clean head above, . ./bin/activate-hermit && just desktop-ci passed end to end: Biome/file guards, Desktop unit suite, TypeScript/Vite build, cargo fmt --check, cargo check, and full desktop/src-tauri tests (lib: 1,885 passed / 14 ignored; diagnostic: 3 passed; 0 failed). CI checks are green for this head, but the Security workflow is skipped and the tests do not exercise the findings above. The PR is currently CONFLICTING / DIRTY against base 02be413b823c356587e6e9f4d07f6cb06bb41c3c.
No GUI was launched on the shared machine; there is no checked-in native journey for this feature. These security defects are source-proven and do not depend on GUI reproduction.






Summary
Prototype of rich link preview cards in channel messages, as requested in the
#buzz-frontendthread (channela410ffde-c61f-416a-96e0-c296b5f5ecc9, threadd8552e61…): generic URLs in a message now render an OpenGraph preview card (image, title, description, site name, favicon) below the message.This extends the existing compact provider-specific previews (GitHub/Linear/Google) rather than replacing them — generic cards only render for URLs not covered by a provider card.
What's in it
extractGenericLinkPreviewUrlsinlinkPreview.ts(max 3 per message; skips media-extension URLs, provider-covered URLs, and code/spoiler/image-masked content),useRichLinkPreviewshook with in-memory caching,RichLinkPreviewCardUI, and a combinedMessageLinkPreviewscomponent inlink-preview-attachment.tsxsomarkdown.tsxstays a one-liner (and under its file-size ratchet).fetch_link_preview_metadataTauri command: OG-first parsing with<title>fallback, 3-redirect limit, 256 KB / 4 s caps. No title → no card (fails closed).linkPreviewMetadatamock ine2eBridge.ts;--mock-configoption fortests/helpers/screenshot.mjsso previews are screenshotable deterministically.Related issue
Prototypes #3369. Overlaps in spirit with #3316 (metadata for the compact provider cards) — this PR is about full rich cards for generic URLs; happy to reconcile if #3316 lands first.
Testing
tsc --noEmit, biome, file-size ratchet cleancargo test link_preview6/6 (3 new Rust unit tests for the OG parser)Note: pushed with
--no-verifyfor the final push only — all desktop/Rust pre-push hooks passed; thetestrecipe fails locally on an unrelated pre-existing migration-25 checksum issue in my local DB (same as noted in #3416).