Skip to content

fix(security): validate feed/URI fetch URLs and cap streamed download size#223

Merged
chhoumann merged 1 commit into
masterfrom
chhoumann/deepsec-ssrf-dos
Jun 29, 2026
Merged

fix(security): validate feed/URI fetch URLs and cap streamed download size#223
chhoumann merged 1 commit into
masterfrom
chhoumann/deepsec-ssrf-dos

Conversation

@chhoumann

Copy link
Copy Markdown
Owner

Hardens every fetch whose URL comes from untrusted feed or deep-link content. A
podcast's <enclosure url="..."> and the obsidian://podnotes?url=... deep link
are fully attacker-controlled, yet they flowed straight into requestUrl with no
scheme or host validation. This was a blind SSRF primitive (and, via the
transcription fetch, a file:/data: local-read/exfil path), inconsistent with
the project's own isSupportedChaptersUrl guard. The streaming download also had
no total-size cap, so a malicious media server could fill the disk.

Resolves four deepsec findings:

  • ssrf-2306e54f4d - feed streamUrl fetched in downloadEpisode.ts with no scheme/host check
  • ssrf-47eda4095e - same streamUrl reaching requestUrl in download/streaming.ts
  • ssrf-594c984897 - obsidian://podnotes url param triggering a one-click feed fetch
  • other-resource-exhaustion-54d62080a1 - no total-size cap on the streamed download

What changed

  • New src/utility/assertFetchableUrl.ts - parses with new URL(), allows
    only http:/https:, and rejects hosts in loopback / link-local / private /
    cloud-metadata ranges. It leans on WHATWG URL host normalization (verified in
    the Electron renderer, see below) so obfuscated IPv4 (2130706433, 0x7f...,
    0177.0.0.1), IPv4-mapped/embedded IPv6 ([::ffff:127.0.0.1]), and the
    absolute-FQDN form (localhost.) are all caught.
  • Routed every feed/URI-derived fetch through it: downloadFile and the
    getFileExtension HEAD probe (downloadEpisode.ts), probeAndFetchFirstChunk
    and writeStreamedFile (download/streaming.ts), and the feed-fetch branch of
    the obsidian://podnotes handler (URIHandler.ts).
  • Total-size cap (MAX_DOWNLOAD_SIZE, 2 GiB) in download/streaming.ts:
    reject an oversized advertised total or 200-fallback body up front, and abort
    the 206 loop once the running total exceeds the cap (the only stop for an
    unknown-total infinite-stream disk-fill).

Legitimate input is preserved: ordinary public http(s) podcast feeds and
enclosures (including public IPs and FQDNs) pass unchanged; the cap clears any
real episode.

Design notes

  • Host blocking covers loopback, link-local (incl. 169.254.169.254 metadata),
    private (10/8, 172.16/12, 192.168/16), and 0.0.0.0/8. This is the
    security-correct default for a podcast client; a niche LAN-self-hosted feed on a
    private IP would be refused, which is an acceptable trade and could become a
    setting later if requested.
  • The 2 GiB cap turns an unbounded write into a bounded, recoverable failure
    while clearing real episodes (long-form audio is hundreds of MB).

Known limitations (documented in the guard)

  • DNS rebinding and redirects: the guard validates the URL string, not the
    DNS-resolved address, and requestUrl follows redirects without a per-hop hook,
    so an allowed host could still resolve/302 into a blocked range. The scheme
    allowlist (the file:/data: vector) is unaffected by both.
  • 200-fallback OOM: when a server ignores Range and answers 200, requestUrl
    buffers the whole body before the cap can inspect it (an inherent requestUrl
    limitation); the cap still refuses to write it to disk and the disk-fill 206
    loop is fully bounded.
  • Out of scope for this PR (separate surface): chaptersUrl in
    fetchChapters.ts still does scheme-only validation and could be routed through
    this guard in a follow-up.

Validation

  • npm run lint, typecheck, build clean.
  • npm run test: 824 pass. New tests cover the guard (41 cases incl. obfuscation,
    IPv6, trailing-dot FQDN), the SSRF refusal at each call site, and the size cap.
    (PodcastView.integration.test.ts is a pre-existing timing-flaky test unrelated
    to these files; it passes in isolation.)
  • Runtime: loaded the plugin in an isolated Obsidian vault (no dev errors) and
    confirmed the Electron renderer's new URL() normalizes the obfuscated IPv4 /
    IPv4-mapped IPv6 / trailing-dot forms identically to Node, so the guard's
    host-classification assumption holds in the real runtime.
  • Adversarially self-reviewed (subagent attacked the guard); it found the
    localhost. trailing-dot bypass, which is fixed and now tested.

Feed enclosure URLs (<enclosure url>) and obsidian://podnotes deep-link
URLs are fully attacker-controlled, yet flowed straight into requestUrl
with no scheme or host validation - a blind SSRF primitive (plus a
file:/data: local-read/exfil path via the transcription fetch). The
streaming download also had no total-size cap, so a malicious media
server could fill the disk.

- Add src/utility/assertFetchableUrl.ts: allow only http(s); reject
  loopback/link-local/private/cloud-metadata hosts (IPv4 + IPv6,
  including obfuscated, IPv4-mapped, and absolute-FQDN forms).
- Route every feed/URI-derived fetch through it: downloadFile and the
  getFileExtension HEAD probe (downloadEpisode.ts), probeAndFetchFirstChunk
  and writeStreamedFile (download/streaming.ts), and the feed-fetch branch
  of the obsidian://podnotes handler (URIHandler.ts).
- Cap the streamed download at MAX_DOWNLOAD_SIZE (2 GiB): reject an
  oversized advertised total or 200-fallback body up front, and abort the
  206 loop once the running total exceeds the cap (stops the unknown-total
  infinite-stream disk-fill).

deepsec: ssrf-2306e54f4d, ssrf-47eda4095e, ssrf-594c984897,
other-resource-exhaustion-54d62080a1
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 196c508
Status: ✅  Deploy successful!
Preview URL: https://5e867589.podnotes.pages.dev
Branch Preview URL: https://chhoumann-deepsec-ssrf-dos.podnotes.pages.dev

View logs

@chhoumann chhoumann marked this pull request as ready for review June 29, 2026 07:19
@chhoumann chhoumann merged commit edef281 into master Jun 29, 2026
3 checks passed
@chhoumann chhoumann deleted the chhoumann/deepsec-ssrf-dos branch June 29, 2026 07:23
github-actions Bot pushed a commit that referenced this pull request Jul 9, 2026
## [2.17.3](2.17.2...2.17.3) (2026-07-09)

### Bug Fixes

* **feed/search:** parse feed once + content-based search cache ([#225](#225)) ([053d51f](053d51f)), closes [#149](#149)
* make episode identity key collision-resistant and prototype-safe ([#226](#226)) ([a5683db](a5683db))
* **opml:** correct import progress math and saved-count reporting ([#221](#221)) ([a79e529](a79e529))
* **security:** validate feed/URI URLs and cap download size ([#223](#223)) ([edef281](edef281))
* **template:** neutralize feed-controlled note injection ([#228](#228)) ([ef4ecbd](ef4ecbd))
* **timestamp:** escape live table-cell pipe after an escaped backslash ([#227](#227)) ([a34dfca](a34dfca))
* **transcription:** resolve three deepsec transcription-pipeline bugs ([#224](#224)) ([83c34e7](83c34e7))
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.17.3 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant