fix: pin RSS feed fetches against DNS-rebinding TOCTOU (#2046)#2069
Merged
Conversation
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.
Owner
Author
Review gate
Behavior nuanceThe strict All suites green: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2046. (Catalog CDP ingest rebinding pin split out to #2068 — see below.)
Problem
server/services/feeds.js#fetchFeedXmlresolved a feed hostname, verified it wasn't private viaisHostSafe, then made a separate, unpinnedfetchWithTimeout. undici re-resolves at connect time, so an attacker's DNS could answer public during validation and private (home-network /169.254.169.254cloud-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-requestAgentwhoseconnect.lookupreturns the validated address, re-validating and re-pinning on every redirect hop.What shipped
feeds.js#fetchFeedXmlnow routes throughfetchPublicTextfromsafeUrlFetch. Removed the now-deadisHostSafe/isPrivateIP/ dual-resolve4+resolve6helpers and thedns/net/fetchWithTimeoutimports from feeds.js. Fixed the false "prevents rebinding" comment.safeUrlFetchgained a strictblockPrivatemode — see policy decision below.safeUrlFetchgained athrowOnUnsafeoption (defaulttrue, preserving the route-context 400 contract thatassertPublicHttpUrl/ Pinterest rely on). feeds passthrowOnUnsafe: falseso 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-inblockPrivatemode (full RFC1918 / CGNAT-adjacent / IPv6 ULAfc00::/7rejection, 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#fetchUrlMainTextalready 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— addedblockPrivatecoverage (private literal / resolved / IPv6-ULA rejected; public still allowed) andthrowOnUnsafecoverage (default throws 400;falsereturns null with no fetch).server/services/feeds.test.js— reworked the DNS mock fromresolve4/resolve6todns.lookupand 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).safeUrlFetch,feeds,pinterest,catalogIngestSources), pluslib/index.test.jsbarrel check.https://claude.ai/code/session_01TSuyZ5XG9JiijEibeJjL6T