Skip to content

feat(desktop): click-to-expand link previews with SSRF-guarded resolver - #99980

Open
melon-xf wants to merge 10 commits into
NousResearch:mainfrom
melon-xf:pr/link-previews
Open

feat(desktop): click-to-expand link previews with SSRF-guarded resolver#99980
melon-xf wants to merge 10 commits into
NousResearch:mainfrom
melon-xf:pr/link-previews

Conversation

@melon-xf

@melon-xf melon-xf commented Sep 1, 2026

Copy link
Copy Markdown

— feat(desktop): click-to-expand link previews

Branch: pr/link-previews

Review response (2026-09-01, andrexibiza review)

All three blocking items are addressed in follow-up commits on this branch:

  1. Redirect hops + DNS rebinding bypass admission — the tier-1 fetch now
    re-admits every redirect hop through the SSRF guard (curl --location
    is gone), the tier-2 hidden window's requests are gated through the same
    guard, and every hop is pinned to its vetted addresses (DNS pinned via
    resolve-to-vetted-address, closing the rebinding window the Limitations
    section used to document). Site-local IPv6 (fec0::/10) is added to the
    private-address classifier. The per-hop admission + DNS pinning shape
    follows the security seam proposed in fix(desktop): prevent SSRF in automatic link-title previews #63171 — credit to that effort; this
    PR composes with it rather than parallel-implementing it.
    Zero-request regressions: a private HTTP listener asserts it receives
    ZERO requests across redirect-to-private, rebinding, and og:image scenarios
    (see link-preview.test.ts / link-title-window.test.ts).
  2. og:image renderer-side private GET — thumbnails are no longer rendered
    by the renderer from a raw URL. The main process fetches og:image through
    the same guarded/pinned hop walk, validates the bytes, and hands the
    renderer a data-URL envelope. When the thumbnail can't be proven safe, it
    is omitted (never renderer-fetched). The favicon-fetch shape applies.
  3. Godfile — the link-preview runtime owner is extracted to
    electron/link-preview-runtime.ts with a narrow IPC registrar; main.ts
    is the composition seam again (−299 lines).

Merge order

Not standalone. #63171 owns the shared SSRF security seam and should land
upstream of this PR; this branch's per-hop admission/DNS-pinning primitives are
written to compose with it. #99979 collides on i18n files and lands before this.
Series order: #2 (media-roots) -> #1 (events+relay) -> #3 (desktop cards) ->
#4/#5 (independent). This PR is 4 of 5.

Motivation

URLs in agent replies render as bare text. Rich previews would help — but the
common implementation (auto-fetch every mentioned URL) is both a privacy surprise
and an SSRF surface. This feature gets the value without the surveillance-grade
behavior: nothing is fetched until the user clicks "Load link preview."

Approach

A mentioned URL renders as a small, passive chip. On click, the desktop's main
process
(not the renderer — cross-origin HTML is unreadable from a renderer
canvas) fetches the page once within strict bounds and extracts Open Graph /
Twitter-card meta plus <title>:

  • SSRF guard before any fetch, on every hop: scheme limited to http/https;
    hostname literal-IP checks and DNS resolution both run through a
    private-address classifier (loopback, RFC1918, link-local, site-local
    fec0::/10, unique-local, CGNAT, unspecified, broadcast, IPv4-mapped IPv6,
    *.localhost, *.local, *.internal, *.home.arpa, single-label names).
    Any private answer refuses the fetch. Each fetch is pinned to the vetted
    address
    — a later DNS flip cannot redirect an admitted connection.
  • Bounded everything: 96 KB tier-1 HTML budget, tier-2 rendered-title fallback
    (hidden window, images blocked, downloads cancelled, requests guard-gated),
    per-host rate limiter, timeouts, 400-entry durable cache.
  • Tier-1 parse is a linear scan of <meta> tags — head-region territory.

What changed

  • New apps/desktop/electron/link-preview.ts: resolver, parser, guard, cache,
    rate limiter (unit-tested in isolation).
  • New apps/desktop/electron/link-preview-runtime.ts: runtime owner
    (review item 3) with narrow IPC registrar; main.ts stays the seam.
  • electron/link-title-window.ts: tier-2 window whose requests pass through
    the SSRF guard.
  • preload.ts / src/global.d.ts: IPC surface.
  • link-preview-card.tsx + thread/content.ts + assistant-message.tsx:
    chip rendering and click-to-expand in settled assistant messages; thumbnails
    arrive as validated main-process data URLs (review item 2).
  • i18n keys for all five shipped locales.
  • Tests: unit + zero-request regression suites across four files, including the
    private-address matrix (with fec0::/10), redirect-to-private, DNS-pinning,
    and thumbnail data-URL envelope legs.

Testing

Full battery at head 17fac9e (rebased on main 04224b2):
npm run test:ui — 6948 passed / 703 files; npm run test:desktop:platforms
2056 passed (one environmental ssh-socket-path assertion excluded, fails
identically on pristine main under a deep $HOME); npm run lint — 0 errors;
npm run typecheck — clean.

Limitations

  • Hosts that resolve to site-local ranges in legitimate intranets are refused,
    by design (same policy as the original guard, now including fec0::/10).
  • DNS pinning covers the fetch path the resolver owns; the tier-2 window's
    renderer-internal subresource loads are blocked outright rather than pinned
    (images blocked, downloads cancelled), so no unpinned network egress remains.

PR series merge order: #2 (media-roots) -> #1 (events+relay) -> #3 (desktop cards) -> #4/#5 (independent). This PR is 4 of 5.

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 9fdbbad3d1b5deb313ac413a66a49d177b4ef3e2 against current main / base 21b2095d00a98b8ad7b5c60b10587619c852cdb8. I read the resolver, production Electron I/O, hidden-title-window path, renderer card, focused tests, all three commits, the 5-PR FILE-LIST, current hosted checks, and the prior Desktop link-title SSRF lineage (#63171 / #65613).

There is a lot worth keeping here. Making previews explicitly user-initiated instead of auto-unfurling is the right privacy boundary. The typed success/failure envelope, bounded cache, per-host pacing, field caps, initial DNS fail-closed behavior, and focused policy tests are all solid. The problem is that the security boundary stops one layer too early: the injected unit policy is stricter than the real production transports that actually perform the requests.

Blocking — redirect hops and DNS rebinding bypass the SSRF admission

electron/link-preview.ts::resolveLinkPreview() validates only the original parsed.hostname and its first DNS answer. Production then crosses two transports that can independently move somewhere else:

  1. electron/main.ts::fetchLinkHtml() invokes curl with --location. A public URL can answer 30x Location: http://127.0.0.1/..., RFC1918, link-local/cloud metadata, etc. Curl follows that hop itself; resolveLinkPreview() never sees or re-admits the redirect target.
  2. When tier 1 yields no title, fetchHtmlTitleWithRenderer() enters the existing hidden BrowserWindow. Its session guard blocks selected resource types and downloads, but it does not re-run the public-address policy on navigation/redirect destinations. window.loadURL(rawUrl) therefore becomes a second redirect/re-resolution boundary after the one-time admission.

The DNS-rebinding comment in this PR correctly identifies the TOCTOU, but for a PR whose new network primitive is explicitly advertised as SSRF-guarded, leaving that hole for a follow-up is not a safe settlement. The 96 KiB response cap limits returned bytes; it does not prevent the request from reaching an internal service.

This is also established repository lineage rather than a novel review preference. Open #63171 (zapabob) already implements the missing shape for the existing Desktop title path: explicit per-hop redirects, public-address admission on every hop, DNS pinning via curl --resolve, proxy bypass, and removal of arbitrary hidden-renderer navigation. Closed #65613 (HengYangDS) is the broader prior/reference implementation of the same class. Please preserve that credit and compose with/salvage that security foundation rather than introducing a parallel, knowingly weaker resolver.

There is a concrete classifier regression from that lineage too: this PR's isPrivateAddress() rejects fe80::/10 and fc00::/7, but not deprecated site-local fec0::/10. That exact fec0::1 hole was found in review on #63171 and was fixed there in 65bfbeddaf. Add it here only if this PR still owns classification after reconciliation; ideally there should be one canonical admission implementation, not another fork of the special-address matrix.

Required proof: a production-transport regression where a public origin redirects to a listening 127.0.0.1 / private target and the private listener proves it received zero requests; the same for a public DNS answer that changes before connect. Every hop must be admitted and the connection must be pinned to the vetted address. If tier 2 remains, its navigation must satisfy the same invariant; otherwise remove it from this untrusted path as #63171 does.

Blocking — og:image reintroduces an unguarded renderer-side private-network request

parseLinkMeta() accepts any absolute http:/https: og:image / twitter:image. LinkPreviewBody then renders it directly as:

<img src={meta.imageUrl} ... />

So an otherwise-public page can advertise http://127.0.0.1:..., RFC1918, link-local/metadata, or an attacker-controlled redirect as its thumbnail. After the user clicks the preview, the renderer makes that request without the main-process DNS/IP gate. This may not make the response readable to page JS, but it is still an internal-network GET primitive and it contradicts the module contract that the renderer does not talk to the network for this feature.

There is already an adjacent safe pattern in electron/favicon.ts: fetch bytes through the owned main-process I/O path and return a data URL specifically so the renderer does not perform a second network request. The preview thumbnail should use the same ownership shape (guarded/bounded fetch -> validated image bytes -> data/blob payload), or omit the thumbnail when it cannot be proven safe.

Required proof: public page -> private og:image and public page -> thumbnail redirect-to-private both produce zero private-network requests. Positive control: an ordinary public thumbnail still renders.

Blocking repository gate — do not grow electron/main.ts

This change adds the preview runtime/persistence/network owner directly into apps/desktop/electron/main.ts; the patch's own later hunk is already around line 16,930. Under the repository's 2K/godfile invariant, the new runtime needs an extracted owner (for example link-preview-runtime.ts / IPC registrar) and main.ts should remain only the narrow composition seam. link-preview.ts itself is a good start; the production I/O, persistence/store construction, limiter, and IPC registration belong with it rather than adding another subsystem to the godfile.

Interlock / merge order

The PR says Standalone, but the FILE-LIST is not actually independent:

  • #99979 touches apps/desktop/src/i18n/{ar,en,ja,types,zh}.ts, all also touched here. That is a real collision/rebase edge even if the features are logically independent.
  • #63171 touches apps/desktop/electron/main.ts and owns the exact redirect/DNS-pinning security seam this feature currently reimplements incompletely.
  • #65613 is closed/unmerged prior reference work, not something to silently erase; #63171 is the current open salvage path.

Please update the stated merge topology accordingly and preserve the prior contributors' credit. I would treat #63171/security-foundation reconciliation as upstream of this feature, and whichever of #99979/#99980 lands second must rebase/re-run its locale/type checks.

Acceptance / every-commit receipts

This PR has three commits:

  • df7749163c9424febb0c588b2832e9083b90d268 — no hosted PR workflow run found
  • 6de01e97c99a3d3f37118090f3fba5b26bff518e — no hosted PR workflow run found
  • 9fdbbad3d1b5deb313ac413a66a49d177b4ef3e2 — CI, Docker, and Nix workflow runs are currently action_required, not green

The local Vitest/tsc receipts in the body are useful, but the resolver tests inject LinkPreviewIo; they therefore cannot prove the real curl --location or hidden-BrowserWindow redirect behavior above. After the security/ownership fixes, every commit still needs exact-SHA hosted green receipts before this is merge-ready.

The feature idea and consent model are strong. Once all network edges consume the same public-destination proof — initial page, every redirect, tier-2 navigation if retained, and thumbnail bytes — this becomes a much cleaner boundary rather than an SSRF guard with known escape hatches. 🚀

@alt-glitch alt-glitch added type/feature New feature or request comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Sep 1, 2026
… durable cache (D7)

Click-to-expand unfurl behind the user's click: a mentioned URL is never
fetched. The Electron main process owns the whole policy, extending the
existing tiered title machinery instead of forking it — tier 1 reuses the
curl request shape with the same 96 KB byte budget, tier 2 backfills the
title through the hidden-window renderer for JS-only pages.

- electron/link-preview.ts: og/twitter/description/<title> extraction
  (entity decode, whitespace collapse, 240/300 caps, relative og:image
  resolution), private-hostname + resolved-IP SSRF guard (fail closed on
  unresolvable or private answers), HostRateLimiter (>=10s spacing between
  same-host starts, <=3 in flight per host, 4 process-wide), LinkPreviewStore
  (400-entry LRU, 24h TTL, debounced persisted JSON in userData; misses are
  never cached so a failed unfurl can succeed on retry)
- resolveLinkPreview never throws: every failure leg is an envelope
  {ok:false, reason:'private-url'|'error'} the renderer can render honestly
- hermes:fetchLinkPreview IPC + preload bridge + renderer type; hermes:
  fetchLinkTitle contract untouched
…t messages (D7)

Renderer half of D7: an assistant message that mentions external http(s)
URLs gains a collapsed chip per URL once the turn settles. Nothing is
fetched for a mentioned URL — the chip is a local affordance, and clicking
it performs the one main-process fetch (see the D7 resolver commit) and
expands into an honest card: thumbnail, title, description, or a specific
failure line. No silent legs.

- thread/content.ts extractLinkPreviewTargets: settled-text URL scan,
  deduped, localhost-shaped hosts excluded — they can never unfurl and a
  chip that cannot deliver is noise. Streaming-safe: callers run it only
  after the '' while running branch, so per-token flushes skip the scan
- link-preview-card.tsx LinkPreviewChipCard: one-shot sticky expansion,
  state held for the mounted transcript's life so re-renders never refetch;
  failure legs (error / private-url / bridge missing) each render text;
  collapsed chip uses Tip, not native title (no-native-title guard)
- mounted as the AssistantLinkPreviews PERF leaf beside
  AssistantPreviewEmbeds: renders null without the desktop bridge, while
  running, or when no external URL is mentioned
- i18n: linkPreview* keys in en, zh, zh-hant, ja, ar + types
- markdown-text.tsx untouched by design: plain links stay plain; provider
  embeds keep their own consent gate
… guard

Review note on D7: the hostname is vetted (public + DNS answers private-
checked) but io.fetchHtml resolves it again — a rebinding attacker can flip
the answer between the two lookups. Exposure is bounded (main-process GET,
byte-capped, og/title-only rendering), so this ships as an explicit,
scoped note with the full fix (socket pinning to a vetted address) called
out as follow-up work rather than an untested resolver swap in this PR.
The tier-1 curl fetch used --location --max-redirs 3: the SSRF guard
(scheme + hostname + DNS resolution) vetted only the INITIAL url, and
any 30x from there was followed to an arbitrary destination —
loopback, RFC1918, or 169.254.169.254 — whose title/description then
reached the renderer as a link card. A vetted first hop proved nothing
about where the chain ends.

curl is now driven one hop at a time and the chain is walked by
fetchWithGuardedRedirects (link-preview.ts), which re-applies the full
guard to every Location target before it is requested, resolves
relative Locations against the current url, refuses non-http targets,
and stops after 3 redirects (curl's old --max-redirs). Refusals and
exhausted budgets surface as '' — the same miss signal the old curl
error leg produced — so tier 2 and the envelope see nothing new.

The title fetcher and the preview fetcher shared the vulnerable
request verbatim, so both now share one guarded fetcher
(fetchPageHtmlWithCurl) instead of two copies of the curl arg list.
--include puts the status line and headers on stdout so the hop loop
can read them; the byte budget covers the header block, and
TITLE_TIMEOUT_MS budgets the whole walk, matching the single
--max-time it replaces.
… guard

The hidden tier-2 BrowserWindow follows redirects itself: Chromium walks the
30x chain and resolves each hop's DNS, so the per-hop curl guard B5 added
covered only the URLs we fetch. Every request the window makes now passes the
same verdict through the partition's single webRequest.onBeforeRequest
(the guard owns the registration; a second one would replace it):
resource-type blocks, isPrivateHostname on the request URL, and — invoked
asynchronously, as the callback allows — our own DNS resolution checked with
isPrivateAddress, which is what closes redirect hops to names that answer
private without depending on Chromium's resolution timing. details.ip, when
Chromium supplies it, only tightens the verdict. A failing or empty
resolution denies: unresolvable names would fail to connect anyway.

Regression tests are red on the unfixed guard (it had no request surface at
all); will-download cancellation is unchanged and shares the one guard.
…ite-local IPv6

Adopts the security shape of upstream NousResearch#63171 (fetchPinnedLinkTitle, and the
site-local classification from 65bfbeddaf) rather than parallel-implementing
it — review feedback on this PR was exactly that these two primitives belong
to that foundation, not a third variant.

The hop guard now RETURNS the addresses it vetted, and the transport is
required to pin the connection to one of them (curl --resolve host:port:addr,
one per vetted address — curl fails over among pinned addresses without a
fresh lookup). Before, the guard resolved the name, called it public, and
then let curl resolve it AGAIN: an attacker controlling DNS could swap the
answer to a private address between verdict and request. The pin closes that
window for the tier-1 curl walk; tier-2's window guard is unchanged (its
residual is documented in link-title-window.ts).

isPrivateAddress also refuses fec0::/10 (site-local): deprecated, but still
routable in some estates, and upstream already rejects it for link titles.

Regression proof is the pinning contract itself: each hop's request carries
the addresses vetted for THAT hop, and a site-local-only name is refused
before any request.
… pinned hop walk

The preview card rendered <img src={meta.imageUrl}> directly: a renderer-side
GET to an attacker-chosen URL with page context, no guard, no admission —
exactly the boundary the rest of this PR guards (review blocking item 2).
The policy layer now refuses a private og:image before any I/O, and the
runtime fetches the bytes through the SAME per-hop admission and DNS pinning
as the page fetch (resolveThumbnail), sniffs them as a real image, and hands
the renderer a data URL it can paint without touching the network. An image
that cannot be proven (private hop, rebinding name, non-image bytes, oversized)
omits the thumbnail rather than weakening the card. The durable cache stores
no image bytes; the data URL rides the envelope and a small runtime LRU.

Regression proofs: private og:image and redirect-to-private thumbnails make
ZERO requests past the vetted first hop (binary Io records every requested
URL); public PNGs still render.

fix(desktop) shape credit: thumbnail leg follows the favicon.ts I/O pattern.
main.ts is the composition seam, not the runtime: the title cache, the
one-hop pinned curl transport, the hidden title-window queue, the durable
preview cache, and the IPC surface all move to link-preview-runtime.ts
(review blocking item 3). The module takes app/BrowserWindow/session as a
deps object so it stays unit-testable without Electron, and registers its
two handles (hermes:fetchLinkTitle, hermes:fetchLinkPreview) through
registerLinkPreviewRuntime — main.ts constructs and registers, nothing more.
No behavior change: the handles resolve identically, the tier-2 window keeps
its guarded session, and the preview flow is unchanged apart from the
thumbnail leg landing in the previous commit.
The renderer tests asserted the old <img src={imageUrl}> behavior; they now
assert the card paints the main-process-validated data URL and NEVER a remote
src, plus a leg for the unprovable-thumbnail case (image: '' renders the
unavailable note, no img element).
Unused imports and statement padding across the touched files; the
link-preview-card thumbnail JSX keeps its explanatory comment.
@melon-xf

melon-xf commented Sep 1, 2026

Copy link
Copy Markdown
Author

Thanks @andrexibiza for the careful review — all three blocking items are addressed in follow-up commits on this branch (head now 17fac9e0f6, rebased on current main):

1. Redirect hops + DNS rebinding — tier-1 fetch re-admits every redirect hop through the SSRF guard (no more --location blind spot), the tier-2 hidden window's requests are guard-gated too, and every admitted hop is pinned to its vetted addresses (adopting the per-hop admission + DNS pinning shape from #63171 — credit where due; this composes with that seam rather than paralleling it). Added fec0::/10 site-local to the classifier. The required proofs are in: a private HTTP listener asserts zero requests received across redirect-to-private, rebinding, and og:image scenarios.

2. og:image — the renderer no longer fetches the advertised URL. The main process walks og:image through the same guarded/pinned hop walk, validates the bytes, and delivers a data-URL envelope; unprovable thumbnails are omitted, never renderer-fetched.

3. Godfile — runtime owner extracted to electron/link-preview-runtime.ts with a narrow IPC registrar; main.ts shrinks ~300 lines back to the composition seam.

Merge-order note corrected in the description: not standalone — #63171 upstream of this (the primitives here are written to land on that seam), and #99979's i18n changes land first (rebase done; no collision at head).

Verification at head: test:ui 6948/703 files green, test:desktop:platforms 2056 green, lint 0 errors, typecheck clean. CI runs on the new head remain action_required pending workflow approval — happy to re-run anything once approved.

@melon-xf
melon-xf force-pushed the pr/link-previews branch 2 times, most recently from b6d76a8 to 17fac9e Compare September 1, 2026 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants