Skip to content

fix(security): consolidate all outbound requests behind one hardened gate#290

Merged
chhoumann merged 2 commits into
masterfrom
fix/harden-fetch-boundary
Jul 11, 2026
Merged

fix(security): consolidate all outbound requests behind one hardened gate#290
chhoumann merged 2 commits into
masterfrom
fix/harden-fetch-boundary

Conversation

@chhoumann

Copy link
Copy Markdown
Owner

Routes every outbound request PodNotes makes - episode download, streaming, chapter fetch, iTunes search, Deepgram diarization, feed documents, and universal-link resolution - through a single requestWithTimeout gate in src/utility/networkRequest.ts. The gate classifies the target, bounds request and response sizes, applies timeouts and abort support, and returns redacted typed errors that never contain the raw URL.

Why: podcast feeds are hostile input, and until now each call site independently decided whether to invoke the URL guard. This makes "every request is classified" a structural property: a future call site that skips the gate has no requestUrl import to reach for. This is the consolidation follow-up to #288 - PR 2 of the three-PR plan that replaced the capability architecture.

What folds in: LiteralTargetClassifier (the last preserved remnant of the deleted capability stack) is absorbed into assertFetchableUrl and removed. The guard now covers the full IANA special-purpose IPv4/IPv6 registries, parses literals only in canonical form (the WHATWG parser normalizes obfuscated encodings first), rejects credentials in URLs, and enforces target-size bounds.

What this deliberately does not do: chase guarantees requestUrl cannot give. No per-hop redirect inspection, no DNS pinning, no native cancellation - those are the accepted design ceiling documented in #288. Timeouts are logical (the buffered transfer is observed, not killed).

Also: concurrent downloads that resolve to the same destination file now dedupe on the normalized destination path with ownership validation against the downloads store, replacing the previous unguarded race.

Review focus:

  • assertFetchableUrl.ts classification completeness (172 focused tests)
  • networkRequest.ts settled-once timeout/abort semantics
  • call-site migrations preserving behavior (streaming Range path, Deepgram auth header, chapters)

Gates: lint, format, typecheck, build, full suite green locally. Bundle 447.37 kB (-38 kB vs pre-#288 baseline).

Merge order: holds as draft until #287/#289 land, then rebases onto master.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 11, 2026

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 881e961
Status: ✅  Deploy successful!
Preview URL: https://3b0344ca.podnotes.pages.dev
Branch Preview URL: https://fix-harden-fetch-boundary.podnotes.pages.dev

View logs

@chhoumann chhoumann marked this pull request as ready for review July 11, 2026 10:33

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e9596763e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/downloadEpisode.ts Outdated
…gate

Route every fetch call site (episode download, streaming, chapters, iTunes
search, Deepgram diarization, feed documents, universal links) through a
single requestWithTimeout gate that classifies the target, bounds request
and response sizes, applies timeouts, and returns redacted typed errors.

Fold LiteralTargetClassifier's stricter address classification into
assertFetchableUrl (full IANA special-purpose IPv4/IPv6 registries,
canonical-form-only literal parsing) and delete the module - the last
remnant of the removed capability architecture.

Replace ad hoc per-site guards with one chokepoint so new call sites
cannot bypass classification. requestUrl's documented limitations (no
per-hop redirect inspection, no native cancellation, buffered bodies)
remain the accepted design ceiling per #288.

Concurrent downloads to the same destination now dedupe on the normalized
destination path with ownership validation instead of racing.
@chhoumann chhoumann force-pushed the fix/harden-fetch-boundary branch from e959676 to c6d2fde Compare July 11, 2026 10:56
… path

The fast path threw a destination collision when a foreign file occupied
the provisional URL-extension path, but that path is only a guess - the
probe can resolve a different final extension whose destination is free.
The fast path now returns early only for the registered owner and
otherwise falls through; the collision check runs on the resolved final
path, where it already existed. Costs one Range probe before the error
in the true-collision case. (Codex review finding on #290.)
@chhoumann chhoumann merged commit 26c208d into master Jul 11, 2026
6 checks passed
@chhoumann chhoumann deleted the fix/harden-fetch-boundary branch July 11, 2026 11:20
chhoumann added a commit that referenced this pull request Jul 11, 2026
Review follow-up: sanitizePortableResourceUrl blocks literal private and
credentialed targets but a public hostname that DNS-resolves to a private
address still passes - the same ceiling #290 documents, unfixable without
DNS resolution the render layer can't do. Document it rather than imply
complete protection.
chhoumann added a commit that referenced this pull request Jul 11, 2026
…sts (#297)

* fix(security): keep generated notes from making browser-managed requests

Feed-controlled show-notes HTML could embed active media elements (img,
audio, video, source, iframe, object, embed, link) whose requests fire
when a generated note renders - reaching feed-selected local/private
targets without ever passing PodNotes' network gate. Markdown image
syntax in feed text formed the same live embeds after conversion.

feedHtmlToMarkdown now strips active elements before htmlToMarkdown
(keeping image alt text), neutralizes Markdown embeds afterwards with
balanced escaping, and the portable resource tags ({{artwork}},
{{episodeartwork}}, {{feedartwork}}, {{stream}}) emit only public
http(s) URLs without embedded credentials - anything else renders as an
empty string.

Deliberate UX change: show-note images render as their alt text instead
of live embeds; a note render is an ambient fetch the reader never
chose, unlike clicking a link (links are preserved).

Ported from the salvaged pre-#288 hardening pass (salvage branch
0bf89a4) onto the current gate API.

* docs: name the DNS-rebinding residual on portable resource URLs

Review follow-up: sanitizePortableResourceUrl blocks literal private and
credentialed targets but a public hostname that DNS-resolves to a private
address still passes - the same ceiling #290 documents, unfixable without
DNS resolution the render layer can't do. Document it rather than imply
complete protection.
chhoumann added a commit that referenced this pull request Jul 11, 2026
* fix(player): show an error state when media fails to load

isLoading was cleared only by loadedmetadata, and the media elements had
no error handler - a blocked, dead, or unreachable stream URL left the
player spinning forever. Both media elements now handle error by
replacing the spinner with a visible error overlay; the state resets on
the next episode switch. Found while gating all outbound requests (#290):
a refused stream URL looked identical to a slow one.

* fix(player): guard persistence separately from the loading spinner

Addressing review: onMediaError cleared isLoading, but isLoading also
gated persistPlaybackPosition, so a failed load (which never restores the
saved position) could let a later pause/destroy write the pre-restore 0
over the stored resume point. Split the concerns: isLoading stays a pure
display flag; a dedicated playbackRestored guard, set only by a real
metadata load, gates persistence. Regression test: a failed load followed
by unmount keeps the saved position intact.
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.

1 participant