fix(security): validate feed/URI fetch URLs and cap streamed download size#223
Merged
Conversation
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
Deploying podnotes with
|
| Latest commit: |
196c508
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://5e867589.podnotes.pages.dev |
| Branch Preview URL: | https://chhoumann-deepsec-ssrf-dos.podnotes.pages.dev |
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))
Contributor
|
🎉 This PR is included in version 2.17.3 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
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.
Hardens every fetch whose URL comes from untrusted feed or deep-link content. A
podcast's
<enclosure url="...">and theobsidian://podnotes?url=...deep linkare fully attacker-controlled, yet they flowed straight into
requestUrlwith noscheme or host validation. This was a blind SSRF primitive (and, via the
transcription fetch, a
file:/data:local-read/exfil path), inconsistent withthe project's own
isSupportedChaptersUrlguard. The streaming download also hadno total-size cap, so a malicious media server could fill the disk.
Resolves four deepsec findings:
ssrf-2306e54f4d- feedstreamUrlfetched indownloadEpisode.tswith no scheme/host checkssrf-47eda4095e- samestreamUrlreachingrequestUrlindownload/streaming.tsssrf-594c984897-obsidian://podnotesurlparam triggering a one-click feed fetchother-resource-exhaustion-54d62080a1- no total-size cap on the streamed downloadWhat changed
src/utility/assertFetchableUrl.ts- parses withnew URL(), allowsonly
http:/https:, and rejects hosts in loopback / link-local / private /cloud-metadata ranges. It leans on WHATWG
URLhost normalization (verified inthe Electron renderer, see below) so obfuscated IPv4 (
2130706433,0x7f...,0177.0.0.1), IPv4-mapped/embedded IPv6 ([::ffff:127.0.0.1]), and theabsolute-FQDN form (
localhost.) are all caught.downloadFileand thegetFileExtensionHEAD probe (downloadEpisode.ts),probeAndFetchFirstChunkand
writeStreamedFile(download/streaming.ts), and the feed-fetch branch ofthe
obsidian://podnoteshandler (URIHandler.ts).MAX_DOWNLOAD_SIZE, 2 GiB) indownload/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
169.254.169.254metadata),private (10/8, 172.16/12, 192.168/16), and
0.0.0.0/8. This is thesecurity-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.
while clearing real episodes (long-form audio is hundreds of MB).
Known limitations (documented in the guard)
DNS-resolved address, and
requestUrlfollows 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.Rangeand answers 200,requestUrlbuffers the whole body before the cap can inspect it (an inherent
requestUrllimitation); the cap still refuses to write it to disk and the disk-fill 206
loop is fully bounded.
chaptersUrlinfetchChapters.tsstill does scheme-only validation and could be routed throughthis guard in a follow-up.
Validation
npm run lint,typecheck,buildclean.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.tsis a pre-existing timing-flaky test unrelatedto these files; it passes in isolation.)
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.
localhost.trailing-dot bypass, which is fixed and now tested.