Skip to content

fix: pin RSS feed fetches against DNS-rebinding TOCTOU (#2046)#2069

Merged
atomantic merged 3 commits into
mainfrom
claim/issue-2046
Jul 2, 2026
Merged

fix: pin RSS feed fetches against DNS-rebinding TOCTOU (#2046)#2069
atomantic merged 3 commits into
mainfrom
claim/issue-2046

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Closes #2046. (Catalog CDP ingest rebinding pin split out to #2068 — see below.)

Problem

server/services/feeds.js#fetchFeedXml resolved a feed hostname, verified it wasn't private via isHostSafe, then made a separate, unpinned fetchWithTimeout. undici re-resolves at connect time, so an attacker's DNS could answer public during validation and private (home-network / 169.254.169.254 cloud-metadata / loopback) at the actual TCP connect — a classic DNS-rebinding TOCTOU. The redirect hop had the same gap, and an inline comment falsely claimed it "prevents rebinding attacks."

The pinned guard server/lib/safeUrlFetch.js (added in #1859, already used by moodBoard/Pinterest) closes exactly this: it resolves the hostname once and pins the undici connection to that vetted IP via a per-request Agent whose connect.lookup returns the validated address, re-validating and re-pinning on every redirect hop.

What shipped

  • feeds.js#fetchFeedXml now routes through fetchPublicText from safeUrlFetch. Removed the now-dead isHostSafe / isPrivateIP / dual-resolve4+resolve6 helpers and the dns / net / fetchWithTimeout imports from feeds.js. Fixed the false "prevents rebinding" comment.
  • safeUrlFetch gained a strict blockPrivate mode — see policy decision below.
  • safeUrlFetch gained a throwOnUnsafe option (default true, preserving the route-context 400 contract that assertPublicHttpUrl / Pinterest rely on). feeds pass throwOnUnsafe: false so an unsafe/private feed URL returns null → the existing friendly "Could not fetch feed — check the URL" message instead of a bubbled 400.

Policy decision — private/LAN feeds

safeUrlFetch's default posture intentionally allows private/LAN hosts (Tailscale peers, home wikis). feeds.js historically blocked all private IPs. I preserved feeds' stricter posture by adding an opt-in blockPrivate mode (full RFC1918 / CGNAT-adjacent / IPv6 ULA fc00::/7 rejection, for both host literals and DNS-resolved addresses) rather than loosening feeds. Rationale: a feed URL is arbitrary user-supplied input; silently allowing it to be pointed at the home network would be a behavior regression and a broader SSRF surface than the fix intends. This keeps feeds' observable behavior identical while adding the connect-time pin. The default LAN-allowing posture is unchanged for existing callers (Pinterest).

Note: the pin structurally supersedes feeds' old parallel A/AAAA "happy-eyeballs" defense — since we resolve once and connect to exactly that address, undici can't prefer a private AAAA at connect time. Those dual-lookup tests were replaced with single-resolve pinning tests.

Catalog CDP ingest — split out (#2068)

catalogIngestSources.js#fetchUrlMainText already pre-checks the URL and re-asserts the final landed URL post-navigation, but the fetch itself is done by Chrome via CDP, which re-resolves DNS independently — we can't hand Chrome a per-request pinned lookup the way we can undici. Closing that properly needs --host-resolver-rules / CDP request interception (substantial, higher-risk). Per the issue's own "track as a separate sub-task" note, filed as #2068 rather than bloating this PR.

Test plan

  • server/lib/safeUrlFetch.test.js — added blockPrivate coverage (private literal / resolved / IPv6-ULA rejected; public still allowed) and throwOnUnsafe coverage (default throws 400; false returns null with no fetch).
  • server/services/feeds.test.js — reworked the DNS mock from resolve4/resolve6 to dns.lookup and updated the SSRF suite to the single-resolve pinning model (literal private rejected without resolving, resolve-to-private rejected, fail-closed on unresolvable, redirect-to-private dropped, connection pinned via dispatcher).
  • server/services/moodBoard/pinterest.test.js — unchanged, still green (default posture preserved).
  • All 4 suites pass locally (safeUrlFetch, feeds, pinterest, catalogIngestSources), plus lib/index.test.js barrel check.

https://claude.ai/code/session_01TSuyZ5XG9JiijEibeJjL6T

atomantic added 3 commits July 2, 2026 10:03
Migrate feeds.js#fetchFeedXml from an unpinned isHostSafe-then-fetch to the
shared safeUrlFetch guard, which resolves the hostname once and pins the undici
connection to that vetted IP (re-validated + re-pinned per redirect hop),
closing the DNS-rebinding TOCTOU. Adds a strict `blockPrivate` mode to
safeUrlFetch so feeds keep their historical block-all-private posture, and a
`throwOnUnsafe: false` option so the feeds path returns null (friendly error)
instead of a bubbled 400. Removes the now-dead isHostSafe/isPrivateIP/dual-DNS
helpers from feeds.js. Catalog CDP ingest rebinding pin tracked in #2068.
…ate (#2046)

Review-gate (codex) findings: isPrivateAddress under the strict blockPrivate
posture missed the CGNAT 100.64.0.0/10 range (Tailscale et al.) and the
deprecated IPv4-compatible IPv6 form (new URL('http://[::192.168.1.1]')
normalizes to [::c0a8:101], the ffff-less compatible form) — both slipped
through as 'public'. Unified the embedded-v4 decode to mirror
catalogValidation.isBlockedIngestHost and added the CGNAT range, with
regression tests.
Review-gate (codex) low finding: new URL(location, url) in fetchGuarded could
throw on a malformed redirect Location header, bypassing the return-null-on-
failure contract that feeds.js (throwOnUnsafe:false) relies on. Guarded the
parse to return null instead, matching isSafeIngestUrl's pattern; added a test.
@atomantic

Copy link
Copy Markdown
Owner Author

Review gate

  • codex: 3 rounds. Round 1 found two real gaps in the strict blockPrivate classifier — missing CGNAT 100.64.0.0/10 and the deprecated IPv4-compatible IPv6 form (new URL('http://[::192.168.1.1]')[::c0a8:101]) slipping through as public. Round 2 found one Low — a malformed redirect Location could throw from new URL(location, url), bypassing the null contract feeds rely on. All fixed with regression tests; round 3 = "No issues found."
  • claude (headless -p): produced no usable output in this environment on either round (empty stdout); relied on codex + local self-review.

Behavior nuance

The strict blockPrivate posture now also blocks CGNAT 100.64.0.0/10 (Tailscale's native range). feeds.js already blocked 10/172.16/192.168, so LAN-hosted feeds were never supported; this closes the one remaining private-ish gap and makes "block all private" consistent. Default posture (Pinterest/catalog LAN access) is unchanged.

All suites green: safeUrlFetch, feeds, pinterest, catalogIngestSources (109 tests).

@atomantic atomantic merged commit d89e91b into main Jul 2, 2026
2 checks passed
@atomantic atomantic deleted the claim/issue-2046 branch July 2, 2026 17:24
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.

SSRF: migrate feeds.js + catalogIngestSources.js to the pinned safeUrlFetch guard (residual DNS-rebinding TOCTOU)

1 participant