Skip to content

feat(security): keep private feed URLs out of synced data#295

Merged
chhoumann merged 2 commits into
masterfrom
feat/private-feed-secrets
Jul 11, 2026
Merged

feat(security): keep private feed URLs out of synced data#295
chhoumann merged 2 commits into
masterfrom
feat/private-feed-secrets

Conversation

@chhoumann

Copy link
Copy Markdown
Owner

A credentialed subscription URL - userinfo (user:pass@) or an auth-bearing query parameter like Patreon's ?auth=... - is a durable subscriber secret. Persisting it in data.json leaks it through vault sync, git-versioned vaults, and shared vaults. This is PR 3 of the plan that replaced the capability architecture: the same protection goal, solved with a feed ID + secret reference instead of opaque handles and sealed records.

Design. Private feeds persist a non-fetchable placeholder (podnotes-private-feed:<name> - deliberately not http(s), so the network gate refuses it if it ever leaks into a fetch) plus a urlSecretId reference. The real URL lives in Obsidian's vault-local SecretStorage under content-free podnotes-feed-url IDs and is resolved only immediately before retrieval, confined to the fetch call.

  • Ingress interns everywhere a feed enters: subscribe (pasted URL), OPML import, and a load-time migration for existing subscriptions (best-effort per feed - a SecretStorage failure keeps that feed exactly as it was).
  • Egress resolves at every fetch seam: episode refresh, feed-note enrichment, and obsidian://podnotes links, whose placeholder resolution is local-only (the name must match a saved feed on this device; the resolved URL still passes the gate).
  • Episode projection is the subtle part: FeedParser now projects private-feed episodes against the SAVED feed, so episode.feedUrl/episode.url - which persist via the episode cache and currentEpisode - carry the placeholder, never the resolved secret. Regression-tested on both the refresh and URI-handler paths.
  • Lifecycle: unsubscribe deletes the secret; an orphan sweep on load (via listSecrets) reaps references dropped by wholesale settings imports. A second synced device gets an actionable notice and re-adds the feed - SecretStorage is device-local by design and secrets are never synced.
  • OPML export is a deliberate, user-initiated portability action and exports the real URL (a placeholder export would be useless); unresolvable feeds export an empty xmlUrl.

Documented residuals: Supercast-style path-embedded tokens are indistinguishable from public URLs and keep today's behavior (a future "mark as private" toggle can cover them); enclosure URLs inside cached episodes are typically short-lived CDN signatures and out of scope.

Verification. 1,116 tests (26 new + parser leak regressions), lint/format/typecheck/build green. Live in an isolated Obsidian vault: seeded a credentialed URL into data.json, loaded the plugin, and confirmed the secret was scrubbed into SecretStorage (placeholder + reference left behind), the feed fetches through the resolved URL (119 ms, real network), an unknown placeholder link shows the device-availability notice, unsubscribe clears the secret, and dev:errors stayed clean throughout.

A credentialed subscription URL (userinfo or an auth-bearing query
parameter, e.g. Patreon's ?auth=...) is a durable secret; persisting it
in data.json leaks it through vault sync, git-versioned vaults, and
shared vaults. Private feeds now persist a non-fetchable placeholder
plus a reference into Obsidian's vault-local SecretStorage, and the real
URL is resolved only immediately before retrieval.

- FeedUrlRepository: write-verified storage under content-free
  podnotes-feed-url IDs, deletion on unsubscribe, and an orphan sweep on
  load (covers settings imports replacing savedFeeds wholesale).
- Existing subscriptions migrate on load, best-effort per feed; a
  SecretStorage failure keeps that feed exactly as it was.
- Every ingress interns (subscribe, OPML import, load); every fetch seam
  resolves (episode refresh, feed-note enrichment, URI links).
- FeedParser projects private-feed episodes against the SAVED feed so the
  persisted episode cache and currentEpisode carry the placeholder, never
  the resolved secret - including the URI-handler path.
- A device without the secret gets an actionable notice; OPML export (a
  deliberate portability action) exports the real URL.

Known residual: Supercast-style path-embedded tokens are not detectable
and keep the status quo; enclosure URLs inside cached episodes are
short-lived signatures and out of scope.
@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: 64a3235
Status: ✅  Deploy successful!
Preview URL: https://ca391940.podnotes.pages.dev
Branch Preview URL: https://feat-private-feed-secrets.podnotes.pages.dev

View logs

@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: 7c87c76668

ℹ️ 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/main.ts
Comment thread src/ui/settings/PodcastQueryGrid.svelte Outdated
Comment thread src/types/PodcastFeed.ts
Adversarial review (Codex on-PR + local skeptic) found real gaps; all
confirmed findings addressed:

- Episode snapshots: migration now scrubs url/feedUrl in played history,
  queue, favorites, playlists, local files, downloads, and currentEpisode
  (episodes stamp feed URLs at fetch time and persist them), and evicts
  the migrated URL's entry from the local feed cache.
- Cross-device references: secret IDs are now UUID-based. urlSecretId
  syncs through data.json while SecretStorage is device-local; sequential
  IDs could make device A's reference resolve to device B's unrelated
  secret and silently fetch the wrong private feed.
- Settings transfer: urlSecretId is stripped on export AND import
  (references are device-local capabilities, not transfer data), and a
  raw data.json import interns credential-bearing URLs BEFORE the strict
  persist instead of waiting for the next reload.
- Both-fields records: a feed carrying a stale reference plus a raw
  credentialed URL is re-interned instead of bypassing protection.
- Unsubscribe keeps a secret still referenced by another feed; the orphan
  sweep trims persisted references like resolve() does and can no longer
  abort plugin loading.
- URI links: a vault file named like a placeholder can't shadow private
  links; migration failures now name the affected feeds in a notice; OPML
  exports warn when they contain plaintext private URLs; detector covers
  jwt/credential parameters.
@chhoumann chhoumann merged commit 80557d1 into master Jul 11, 2026
6 checks passed
@chhoumann chhoumann deleted the feat/private-feed-secrets branch July 11, 2026 12:59
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