From 5589e15b253f5498056ce2f61a6480db28c34b8f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 14:47:20 +0200 Subject: [PATCH 1/6] test(privacy): reproduce private feed leaks in generated notes and aliases --- src/createPodcastNote.privacy.test.ts | 92 +++++++++++++++++++++++++++ src/utility/privateFeedUrl.test.ts | 22 +++++++ 2 files changed, 114 insertions(+) create mode 100644 src/createPodcastNote.privacy.test.ts diff --git a/src/createPodcastNote.privacy.test.ts b/src/createPodcastNote.privacy.test.ts new file mode 100644 index 00000000..16b5a2f8 --- /dev/null +++ b/src/createPodcastNote.privacy.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +import { SecretStorage, TFile } from "obsidian"; +import { afterEach, expect, test } from "vitest"; +import { DEFAULT_SETTINGS } from "./constants"; +import { createPodcastNoteFileIfNotExists } from "./createPodcastNote"; +import { FeedUrlRepository } from "./services/FeedUrlRepository"; +import { plugin } from "./store"; +import type { Episode } from "./types/Episode"; +import buildEpisodeResumeLink from "./utility/buildEpisodeResumeLink"; + +const address = "https://private.example.com/rss?auth=NOTE_SENTINEL"; +const placeholder = "podnotes-private-feed:Paid"; +const episode: Episode = { + title: "Private episode", + podcastName: "Paid", + feedUrl: address, + guid: "episode-1", + url: "https://private.example.com/episode?token=EPISODE_TOKEN", + streamUrl: "https://private.example.com/audio.mp3?token=MEDIA_SIGNATURE", + description: `Your feed: ${address}.`, + content: "", +}; + +function noteVault(subscribed: boolean) { + const written: Array<{ path: string; content: string }> = []; + const feedUrls = new FeedUrlRepository(new SecretStorage()); + const urlSecretId = feedUrls.store(address); + plugin.set({ + feedUrls, + settings: { + ...structuredClone(DEFAULT_SETTINGS), + savedFeeds: subscribed + ? { Paid: { title: "Paid", url: placeholder, urlSecretId, artworkUrl: "" } } + : {}, + }, + app: { + vault: { + getAbstractFileByPath: () => null, + getMarkdownFiles: () => [], + getRoot: () => null, + createFolder: async () => {}, + create: async (path: string, content: string) => { + written.push({ path, content }); + return Object.assign(new TFile(), { path }); + }, + }, + metadataCache: { getFileCache: () => null }, + }, + } as never); + return written; +} + +afterEach(() => plugin.set(undefined as never)); + +test.each([ + address, + "https://private.example.com./rss?auth=NOTE_SENTINEL", + "https:\\\\private.example.com/rss?auth=NOTE_SENTINEL", +])("a newly written note redacts a subscribed feed quoted as %s", async (quoted) => { + const written = noteVault(true); + await createPodcastNoteFileIfNotExists({ + ...episode, + feedUrl: placeholder, + description: `Your feed: ${quoted}.`, + }); + expect(written).toHaveLength(1); + expect(written[0].content).toContain("Your feed: podnotes-private-feed:Paid."); + expect(written[0].content).toContain(episode.url); + expect(written[0].content).toContain('feedUrl: "podnotes-private-feed:Paid"'); + expect(written[0].content).not.toContain("NOTE_SENTINEL"); +}); + +test("an unsubscribed private episode writes no feed address and keeps its live URL", async () => { + const written = noteVault(false); + await createPodcastNoteFileIfNotExists(episode); + expect(written).toHaveLength(1); + expect(written[0].content).toContain("# Private episode"); + expect(written[0].content).toContain(episode.url); + expect(written[0].content).not.toContain("NOTE_SENTINEL"); + expect(written[0].content).not.toMatch(/^feedUrl:/m); + expect(episode.feedUrl).toBe(address); +}); + +test.each([ + address, + "https://user:API_SENTINEL@private.example.com/rss", + "https://priv3.supercast.com/feeds/API_SENTINEL/feed.xml", + "https://subscribers.transistor.fm/API_SENTINEL", +])("the public resume-link builder refuses an unsubscribed private feed: %s", (feedUrl) => { + noteVault(false); + expect(buildEpisodeResumeLink({ ...episode, feedUrl })).toBe(""); +}); diff --git a/src/utility/privateFeedUrl.test.ts b/src/utility/privateFeedUrl.test.ts index 91b5b328..121a5dc5 100644 --- a/src/utility/privateFeedUrl.test.ts +++ b/src/utility/privateFeedUrl.test.ts @@ -125,6 +125,28 @@ describe("isCredentialBearingUrl for publishers that put the secret in the addre }); describe("redactPrivateFeedAddresses", () => { + it.each([ + "https://private.example.com./rss?auth=ALIAS_SENTINEL", + "https:\\\\private.example.com/rss?auth=ALIAS_SENTINEL", + "https:/\\private.example.com/rss?auth=ALIAS_SENTINEL", + "https:///%70rivate.example.com/rss?auth=ALIAS_SENTINEL", + ])("redacts standalone and quoted browser aliases: %s", (alias) => { + const known = new Map([ + ["https://private.example.com/rss?auth=ALIAS_SENTINEL", "podnotes-private-feed:Paid"], + ]); + const document = { + standalone: alias, + quoted: `Your feed: ${alias}.`, + episode: "https://private.example.com/episode?auth=EPISODE_TOKEN", + media: "https://private.example.com/audio.mp3?token=MEDIA_SIGNATURE", + }; + expect(redactPrivateFeedAddresses(document, known)).toEqual({ + ...document, + standalone: "podnotes-private-feed:Paid", + quoted: "Your feed: podnotes-private-feed:Paid.", + }); + }); + it.each([ ["https://private.example.com", "https://%70rivate.example.com"], ["https://xn--bcher-kva.example.com", "https://bücher.example.com"], From 2d5202cba49428ecb4d64d73a9cd447152ec6a90 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 15:10:19 +0200 Subject: [PATCH 2/6] test(privacy): cover capture and transcript timestamp leaks --- src/API/API.test.ts | 13 +++++ src/createPodcastNote.privacy.test.ts | 48 +++++++++++++++++++ .../publishedTranscript/render.test.ts | 16 +++++++ src/utility/buildEpisodeResumeLink.test.ts | 13 +++++ src/utility/formatEpisodeTimestamp.test.ts | 23 +++++++++ src/utility/privateFeedUrl.test.ts | 13 +++++ 6 files changed, 126 insertions(+) diff --git a/src/API/API.test.ts b/src/API/API.test.ts index 8c6ea9aa..6ae2254b 100644 --- a/src/API/API.test.ts +++ b/src/API/API.test.ts @@ -110,6 +110,19 @@ describe("API.getPodcastSegmentFormatted", () => { ); }); + test("captures from a private feed this device has not saved are plain text", () => { + const { api, player } = setup(); + player.restore({ + ...feedEpisode, + feedUrl: "https://pod.example.com/feed.xml?auth=CAPTURE_SENTINEL", + }); + + expect(api.getPodcastTimeFormatted("HH:mm:ss", true)).toBe("00:00:00"); + expect(api.getPodcastSegmentFormatted("HH:mm:ss", 115, 125, true)).toBe( + "00:01:55-00:02:05", + ); + }); + test("does not link invalid segment ranges", () => { const { api, player } = setup(); player.restore(feedEpisode); diff --git a/src/createPodcastNote.privacy.test.ts b/src/createPodcastNote.privacy.test.ts index 16b5a2f8..fd62adfc 100644 --- a/src/createPodcastNote.privacy.test.ts +++ b/src/createPodcastNote.privacy.test.ts @@ -5,6 +5,12 @@ import { DEFAULT_SETTINGS } from "./constants"; import { createPodcastNoteFileIfNotExists } from "./createPodcastNote"; import { FeedUrlRepository } from "./services/FeedUrlRepository"; import { plugin } from "./store"; +import { + FeedFilePathTemplateEngine, + FeedNoteTemplateEngine, + NoteTemplateEngine, + TranscriptTemplateEngine, +} from "./TemplateEngine"; import type { Episode } from "./types/Episode"; import buildEpisodeResumeLink from "./utility/buildEpisodeResumeLink"; @@ -90,3 +96,45 @@ test.each([ noteVault(false); expect(buildEpisodeResumeLink({ ...episode, feedUrl })).toBe(""); }); + +test("a note's path, title and identity carry no subscribed feed address they quote", async () => { + const written = noteVault(true); + await createPodcastNoteFileIfNotExists({ + ...episode, + feedUrl: placeholder, + title: `Feed ${address}`, + guid: `${address}#1`, + }); + expect(written).toHaveLength(1); + expect(written[0].path).toBe("PodNotes/Paid/Feed podnotes-private-feedPaid.md"); + expect(written[0].content).toContain('guid: "podnotes-private-feed:Paid"'); + expect(written[0].content).not.toContain("NOTE_SENTINEL"); +}); + +test("chapters and transcripts that quote a subscribed feed render its placeholder", () => { + noteVault(true); + const quoted = `Your feed: ${address}.`; + const chapters = [{ startTime: 0, title: quoted }]; + expect(NoteTemplateEngine("{{chapters}}", episode, { chapters })).toBe( + "- 0:00 Your feed: podnotes\\-private\\-feed:Paid\\.", + ); + expect(TranscriptTemplateEngine("{{transcript}}", episode, quoted)).toBe( + "Your feed: podnotes-private-feed:Paid.", + ); +}); + +test("a feed note for an unsubscribed private feed writes no feed address", () => { + noteVault(false); + const feed = { + title: "Paid", + url: address, + artworkUrl: "", + description: `Your feed: ${address}.`, + }; + expect(FeedNoteTemplateEngine("{{feedurl}}|{{description}}", feed)).toBe( + "|Your feed: podnotes-private-feed:.", + ); + expect(FeedFilePathTemplateEngine("{{title}}", { ...feed, title: `Paid ${address}` })).toBe( + "Paid podnotes-private-feed", + ); +}); diff --git a/src/services/publishedTranscript/render.test.ts b/src/services/publishedTranscript/render.test.ts index 77c669ae..18dbfde7 100644 --- a/src/services/publishedTranscript/render.test.ts +++ b/src/services/publishedTranscript/render.test.ts @@ -43,6 +43,22 @@ describe("renderTranscriptBody", () => { ); }); + test("plain times for a private feed this device has not saved", () => { + expect( + renderTranscriptBody( + { kind: "timed", cues }, + { + episode: { + ...episode, + feedUrl: "https://pod.example.com/feed.xml?auth=TRANSCRIPT_SENTINEL", + }, + speakerTemplate: "**{{speaker}}:** ", + timestamp: timestampStyle("- {{linktime}} "), + }, + ), + ).toBe("00:00:00 **Justin:** Hello there. Still Justin.\n\n00:00:10 **Michael:** Hi!"); + }); + test("plain times when the timestamp template asks for {{time}}", () => { expect(render("{{time}} ")).toBe( "00:00:00 **Justin:** Hello there. Still Justin.\n\n00:00:10 **Michael:** Hi!", diff --git a/src/utility/buildEpisodeResumeLink.test.ts b/src/utility/buildEpisodeResumeLink.test.ts index 58f22898..a3af816a 100644 --- a/src/utility/buildEpisodeResumeLink.test.ts +++ b/src/utility/buildEpisodeResumeLink.test.ts @@ -97,6 +97,19 @@ describe("buildEpisodeResumeLink (#35)", () => { expect(parsed.searchParams.has("time")).toBe(false); }); + test("addresses an unsaved private feed's episode by its download, never its feed", () => { + const privateEpisode: Episode = { + ...remoteEpisode, + feedUrl: "https://pod.example.com/feed.xml?auth=LINK_SENTINEL", + }; + + expect(buildEpisodeResumeLink(privateEpisode)).toBe(""); + downloadedEpisodes.addEpisode(privateEpisode, "Downloads/ep1.mp3", 1); + expect(new URL(buildEpisodeResumeLink(privateEpisode)).searchParams.get("url")).toBe( + "Downloads/ep1.mp3", + ); + }); + test("returns an empty string when there is no feed URL or file path to address", () => { const orphan: Episode = { ...remoteEpisode, feedUrl: undefined }; diff --git a/src/utility/formatEpisodeTimestamp.test.ts b/src/utility/formatEpisodeTimestamp.test.ts index 9f5f7c13..192f10d9 100644 --- a/src/utility/formatEpisodeTimestamp.test.ts +++ b/src/utility/formatEpisodeTimestamp.test.ts @@ -23,6 +23,29 @@ describe("formatEpisodeTimestamp", () => { expect(formatEpisodeTimestamp(episode, 65.9, "mm:ss", false)).toBe("01:05"); }); + test("renders the plain time for a private feed this device has not saved", () => { + const feedUrl = "https://pod.example.com/feed.xml?auth=TIMESTAMP_SENTINEL"; + expect(formatEpisodeTimestamp({ ...episode, feedUrl }, 65, "HH:mm:ss", true)).toBe( + "00:01:05", + ); + expect(formatEpisodeTimestamp({ ...episode, feedUrl }, 65, "HH:mm:ss", true, 70)).toBe( + "00:01:05-00:01:10", + ); + }); + + test("links a saved private feed by its placeholder", () => { + expect( + formatEpisodeTimestamp( + { ...episode, feedUrl: "podnotes-private-feed:Feed%20Podcast" }, + 65, + "HH:mm:ss", + true, + ), + ).toBe( + "[00:01:05](obsidian://podnotes?episodeName=Pilot%20%2B%20Q%26A&url=podnotes-private-feed%3AFeed%2520Podcast&time=65)", + ); + }); + test("renders the plain time for an episode PodNotes cannot link to", () => { expect( formatEpisodeTimestamp({ ...episode, feedUrl: undefined }, 65, "HH:mm:ss", true), diff --git a/src/utility/privateFeedUrl.test.ts b/src/utility/privateFeedUrl.test.ts index 121a5dc5..f56b3411 100644 --- a/src/utility/privateFeedUrl.test.ts +++ b/src/utility/privateFeedUrl.test.ts @@ -147,6 +147,19 @@ describe("redactPrivateFeedAddresses", () => { }); }); + it("redacts an address an HTML attribute quotes with &", () => { + const known = new Map([ + [ + "https://private.example.com/rss?auth=HTML_SENTINEL&show=1", + "podnotes-private-feed:Paid", + ], + ]); + const html = ''; + expect(redactPrivateFeedAddresses({ description: html }, known)).toEqual({ + description: '', + }); + }); + it.each([ ["https://private.example.com", "https://%70rivate.example.com"], ["https://xn--bcher-kva.example.com", "https://bücher.example.com"], From 2b0e67c098c3f215aae85ad37de5725ffc5fa4e5 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 15:10:19 +0200 Subject: [PATCH 3/6] fix(privacy): redact vault-bound metadata and private feed links --- src/TemplateEngine.ts | 59 +++++++++++++++++++++------ src/createPodcastNote.ts | 16 +++++--- src/utility/buildEpisodeResumeLink.ts | 5 ++- src/utility/formatEpisodeTimestamp.ts | 3 +- src/utility/privateFeedUrl.ts | 31 ++++++++++---- 5 files changed, 87 insertions(+), 27 deletions(-) diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index ed10df81..fdfec4a2 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -17,6 +17,8 @@ import type { PodcastSegmentTimes } from "./utility/podcastSegment"; import type { Chapter } from "./types/Chapter"; import { normalizeChapters } from "./utility/normalizeChapters"; import { isFetchableUrl } from "./utility/assertFetchableUrl"; +import { privateFeedAddresses } from "./services/privateFeeds"; +import { redactPrivateFeedAddresses, vaultSafeFeedUrl } from "./utility/privateFeedUrl"; // Each tag is either a literal string or a function taking at most one argument // (the raw text after the leading colon, e.g. the format in {{date:YYYY}}). The @@ -32,7 +34,7 @@ type ReplacerFn = (template: string) => string; // The optional argument group uses `.*?` (zero-or-more), so a bare trailing // colon ({{date:}}) is captured AS the argument group (params === ":") rather -// than being absorbed into the tag id — which previously yielded tagId "date:", +// than being absorbed into the tag id - which previously yielded tagId "date:", // an unknown tag, and a spurious "invalid tag" Notice (NT-05/CH-09). const TEMPLATE_TAG_REGEX = /\{\{(.*?)(:\s*.*?)?\}\}/g; @@ -46,6 +48,28 @@ export function templateHasTag(template: string, tag: Lowercase): boolea ); } +/** + * `value` with each private feed address this device knows, and an episode's own + * credential-bearing feed, replaced by its placeholder. Templates read this copy, + * because escaping, Markdown conversion and file-name stripping would disguise + * an address from the matcher. + */ +export function withoutPrivateFeedAddresses(value: T): T { + const instance = get(plugin); + return redactPrivateFeedAddresses( + value, + instance?.feedUrls && privateFeedAddresses(instance.settings.savedFeeds, instance.feedUrls), + ); +} + +/** + * `feed` as notes may show it, with its own credential-bearing URL counted as an + * address. Its placeholder names no feed, since the title may quote the URL. + */ +function withoutFeedAddresses(feed: PodcastFeed): PodcastFeed { + return withoutPrivateFeedAddresses({ ...feed, feedUrl: feed.url }); +} + function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> { const tags: Tags = {}; @@ -74,7 +98,7 @@ function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> { if (typeof tagValue === "function") { // Everything after the leading colon is a single argument. A bare // trailing colon ({{date:}}) means "use the tag default", same as a - // bare {{date}} — not an empty format string (NT-05/CH-09). Only an + // bare {{date}} - not an empty format string (NT-05/CH-09). Only an // EMPTY argument is collapsed; a whitespace argument ({{description: }}) // is preserved so the obscure space-prepend usage still works. const arg = params ? params.slice(1) : ""; @@ -128,7 +152,7 @@ function legalizedNameTag( * template engine: {{title}} and {{podcast}} (illegal-character-stripped, with an * optional whitespace-replacement arg), {{date}} (episode publish date), * {{currentdate}}, and {{episodenumber}}. NoteTemplateEngine intentionally does - * NOT use this — there {{title}} is the raw episode title, not a file name. + * NOT use this - there {{title}} is the raw episode title, not a file name. */ function addEpisodeFileNameTags( addTag: AddTagFn, @@ -279,10 +303,16 @@ function resolveEpisodeUrl(episode: Episode): string { export function NoteTemplateEngine( template: string, - episode: Episode, + liveEpisode: Episode, context: NoteTemplateContext = {}, ) { const [replacer, addTag] = useTemplateEngine(); + const [redacted, chapters] = withoutPrivateFeedAddresses([ + liveEpisode, + context.chapters, + ] as const); + // A placeholder made up for an unsaved feed names nothing, so such a feed gets no address. + const episode = { ...redacted, feedUrl: vaultSafeFeedUrl(liveEpisode.feedUrl) }; const episodeUrl = resolveEpisodeUrl(episode); @@ -332,7 +362,7 @@ export function NoteTemplateEngine( // Podcasting 2.0 chapters, fetched before note creation when the template // asks for them. Empty when the feed has no chapters URL or fetching fails. addTag("chapters", (prependToLines?: string) => - formatTemplateChapters(context.chapters, prependToLines), + formatTemplateChapters(chapters, prependToLines), ); addTag("podcast", replaceIllegalFileNameCharactersInString(episode.podcastName)); addTag("artwork", sanitizePortableResourceUrl(episode.artworkUrl ?? "")); @@ -410,7 +440,7 @@ export function FilePathTemplateEngine( ) { const [replacer, addTag] = useTemplateEngine(); - addEpisodeFileNameTags(addTag, episode, sanitizeFileName); + addEpisodeFileNameTags(addTag, withoutPrivateFeedAddresses(episode), sanitizeFileName); return replacer(template); } @@ -431,17 +461,21 @@ export function DownloadPathTemplateEngine(template: string, episode: Episode) { const [replacer, addTag] = useTemplateEngine(); - addEpisodeFileNameTags(addTag, episode); + addEpisodeFileNameTags(addTag, withoutPrivateFeedAddresses(episode)); return replacer(templateWithoutExtension); } export function TranscriptTemplateEngine( template: string, - episode: Episode, - transcription: string, + liveEpisode: Episode, + liveTranscription: string, ) { const [replacer, addTag] = useTemplateEngine(); + const [episode, transcription] = withoutPrivateFeedAddresses([ + liveEpisode, + liveTranscription, + ] as const); addEpisodeFileNameTags(addTag, episode); addTag("duration", (format?: string) => @@ -464,8 +498,9 @@ export function TranscriptTemplateEngine( return replacer(template); } -export function FeedNoteTemplateEngine(template: string, feed: PodcastFeed) { +export function FeedNoteTemplateEngine(template: string, liveFeed: PodcastFeed) { const [replacer, addTag] = useTemplateEngine(); + const feed = withoutFeedAddresses(liveFeed); const safeTitle = replaceIllegalFileNameCharactersInString(feed.title); @@ -480,7 +515,7 @@ export function FeedNoteTemplateEngine(template: string, feed: PodcastFeed) { // inside quoted YAML frontmatter scalars (the default feed template uses both). // A well-formed URL never contains the characters this neutralizes. addTag("url", sanitizeUrlForTemplate(feed.link ?? "")); - addTag("feedurl", sanitizeUrlForTemplate(feed.url)); + addTag("feedurl", sanitizeUrlForTemplate(vaultSafeFeedUrl(liveFeed.url) ?? "")); addTag("artwork", sanitizePortableResourceUrl(feed.artworkUrl ?? "")); addTag("feedartwork", sanitizePortableResourceUrl(feed.artworkUrl ?? "")); addTag("author", escapeMarkdownBodyText(feed.author ?? "")); @@ -506,7 +541,7 @@ export function FeedNoteTemplateEngine(template: string, feed: PodcastFeed) { export function FeedFilePathTemplateEngine(template: string, feed: PodcastFeed) { const [replacer, addTag] = useTemplateEngine(); - const nameTag = legalizedNameTag(feed.title); + const nameTag = legalizedNameTag(withoutFeedAddresses(feed).title); addTag("title", nameTag); addTag("podcast", nameTag); addTag("date", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD")); diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index 8689ec17..c6ccbf02 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -4,6 +4,7 @@ import { legacyReplaceIllegalFileNameCharactersInString, NoteTemplateEngine, templateHasTag, + withoutPrivateFeedAddresses, } from "./TemplateEngine"; import type { Episode } from "./types/Episode"; import type { Chapter } from "./types/Chapter"; @@ -16,6 +17,7 @@ import { fetchChapters } from "./utility/fetchChapters"; import { findUniqueTitleMatch } from "./utility/episodeTitleMatch"; import { findByGuid } from "./utility/findByGuid"; import { addFrontmatterFields } from "./utility/noteFrontmatter"; +import { vaultSafeFeedUrl } from "./utility/privateFeedUrl"; /** * Resolve the on-disk path of an episode's note from the configured template. @@ -100,12 +102,16 @@ export async function createPodcastNoteFileIfNotExists( if (arrivedFile) return { file: arrivedFile, created: false }; const content = NoteTemplateEngine(template, episode, { chapters }); + const feedUrl = vaultSafeFeedUrl(episode.feedUrl); const identified = - episode.guid && episode.feedUrl - ? addFrontmatterFields(content, [ - ["guid", episode.guid], - ["feedUrl", episode.feedUrl], - ]) + episode.guid && feedUrl + ? addFrontmatterFields( + content, + withoutPrivateFeedAddresses([ + ["guid", episode.guid], + ["feedUrl", feedUrl], + ]), + ) : content; return createFileIfNotExists(filePathDotMd, identified); diff --git a/src/utility/buildEpisodeResumeLink.ts b/src/utility/buildEpisodeResumeLink.ts index ea24c524..a022df46 100644 --- a/src/utility/buildEpisodeResumeLink.ts +++ b/src/utility/buildEpisodeResumeLink.ts @@ -2,6 +2,7 @@ import type { Episode } from "src/types/Episode"; import { downloadedEpisodes } from "src/store"; import encodePodnotesURI from "./encodePodnotesURI"; import { episodeLinkTarget } from "./episodeLinkTarget"; +import { vaultSafeFeedUrl } from "./privateFeedUrl"; /** * Build an `obsidian://podnotes` deep link that reopens `episode` in the PodNotes @@ -18,7 +19,9 @@ import { episodeLinkTarget } from "./episodeLinkTarget"; * address it, so a template degrades to an empty value rather than a broken link. */ export default function buildEpisodeResumeLink(episode: Episode): string { - const target = episodeLinkTarget(episode) ?? downloadedEpisodes.getEpisode(episode)?.filePath; + const target = + vaultSafeFeedUrl(episodeLinkTarget(episode)) ?? + downloadedEpisodes.getEpisode(episode)?.filePath; // URIHandler rejects an empty episodeName, so without a title there is no // working link to emit: degrade to "" rather than a dead link (UR-03). diff --git a/src/utility/formatEpisodeTimestamp.ts b/src/utility/formatEpisodeTimestamp.ts index d481ae47..aea157d1 100644 --- a/src/utility/formatEpisodeTimestamp.ts +++ b/src/utility/formatEpisodeTimestamp.ts @@ -3,6 +3,7 @@ import encodePodnotesURI from "./encodePodnotesURI"; import { episodeLinkTarget } from "./episodeLinkTarget"; import { formatSeconds } from "./formatSeconds"; import { formatPodcastSegment } from "./podcastSegment"; +import { vaultSafeFeedUrl } from "./privateFeedUrl"; export function formatEpisodeTimestamp( episode: Episode, @@ -15,7 +16,7 @@ export function formatEpisodeTimestamp( endSeconds === undefined ? formatSeconds(seconds, format) : formatPodcastSegment(seconds, endSeconds, format); - const target = linkify ? episodeLinkTarget(episode) : undefined; + const target = linkify ? vaultSafeFeedUrl(episodeLinkTarget(episode)) : undefined; return target ? `[${time}](${encodePodnotesURI(episode, target, seconds, endSeconds).href})` : time; diff --git a/src/utility/privateFeedUrl.ts b/src/utility/privateFeedUrl.ts index 2b06311b..cee6ecf6 100644 --- a/src/utility/privateFeedUrl.ts +++ b/src/utility/privateFeedUrl.ts @@ -92,11 +92,24 @@ export function parsePrivateFeedPlaceholder(value: string): string | null { } /** - * `url` as a request sends it, so the scheme and host in any case, and with or - * without a default port, name the same feed. As given when it is not a URL. + * `url` as a request sends it, so the scheme and host in any case, with or + * without a default port or a terminal dot, name the same feed. As given when + * it is not a URL. */ export function canonicalUrl(url: string): string { - return checkStringIsUrl(url)?.href ?? url; + const parsed = checkStringIsUrl(url); + if (!parsed) return url; + parsed.hostname = parsed.hostname.replace(/\.$/, ""); + return parsed.href; +} + +/** + * `address` unless it is a credential-bearing feed URL. A saved private feed's + * episodes carry its placeholder, so such a URL belongs to a feed with no + * placeholder on this device, and notes and links get no address for it. + */ +export function vaultSafeFeedUrl(address: string | undefined): string | undefined { + return address && isCredentialBearingUrl(address) ? undefined : address; } /** @@ -140,20 +153,22 @@ export function redactPrivateFeedAddresses( // plugin, so keep this untagged to let Obsidian reuse it across reloads. const candidate = new RegExp( hosts.map((host) => host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + - "|https?:\\/\\/[^\\s/?#]*[%\\\\\\[\\u0080-\\uFFFF]|https?:\\/\\/(?:[^\\s/?#@]*@)?[\\d/\\\\]", + "|https?:[/\\\\]{2}[^\\s/?#]*[%\\\\\\[\\u0080-\\uFFFF]|https?:[/\\\\]{2}(?:[^\\s/?#@]*@)?[\\d/\\\\]", "i", ); const matches = new Map(); const lookup = (address: string) => { if (matches.has(address)) return matches.get(address); - const replacement = placeholders.get(canonicalUrl(address).split("#")[0]); + const replacement = placeholders.get( + canonicalUrl(address.replace(/&/gi, "&")).split("#")[0], + ); matches.set(address, replacement); return replacement; }; return replaceStrings(document, (value) => { - if (!value.includes("://") || !candidate.test(value)) return value; - if (/^https?:\/\/[^\s<>"'`]+$/i.test(value)) return lookup(value) ?? value; - return value.replace(/https?:\/\/[^\s<>"'`]+/gi, (address) => { + if (!/:[/\\]{2}/.test(value) || !candidate.test(value)) return value; + if (/^https?:[/\\]{2}[^\s<>"'`]+$/i.test(value)) return lookup(value) ?? value; + return value.replace(/https?:[/\\]{2}[^\s<>"'`]+/gi, (address) => { const whole = lookup(address); if (whole) return whole; const bare = address.replace(/[.,;:!?)\]}]+$/, ""); From 7f0e62616ce9d23ea15b4486df9b7ea675233b05 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 15:20:23 +0200 Subject: [PATCH 4/6] fix(privacy): keep unsafe resume output empty and verify note writes --- src/TemplateEngine.ts | 17 +- src/utility/buildEpisodeResumeLink.test.ts | 6 +- src/utility/buildEpisodeResumeLink.ts | 10 +- src/utility/privateFeedUrl.ts | 10 -- tests/e2e/private-notes.test.ts | 195 +++++++++++++++++++++ 5 files changed, 205 insertions(+), 33 deletions(-) create mode 100644 tests/e2e/private-notes.test.ts diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index fdfec4a2..ad04a921 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -48,12 +48,6 @@ export function templateHasTag(template: string, tag: Lowercase): boolea ); } -/** - * `value` with each private feed address this device knows, and an episode's own - * credential-bearing feed, replaced by its placeholder. Templates read this copy, - * because escaping, Markdown conversion and file-name stripping would disguise - * an address from the matcher. - */ export function withoutPrivateFeedAddresses(value: T): T { const instance = get(plugin); return redactPrivateFeedAddresses( @@ -62,12 +56,8 @@ export function withoutPrivateFeedAddresses(value: T): T { ); } -/** - * `feed` as notes may show it, with its own credential-bearing URL counted as an - * address. Its placeholder names no feed, since the title may quote the URL. - */ function withoutFeedAddresses(feed: PodcastFeed): PodcastFeed { - return withoutPrivateFeedAddresses({ ...feed, feedUrl: feed.url }); + return withoutPrivateFeedAddresses([feed, { feedUrl: feed.url }] as const)[0]; } function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> { @@ -311,8 +301,7 @@ export function NoteTemplateEngine( liveEpisode, context.chapters, ] as const); - // A placeholder made up for an unsaved feed names nothing, so such a feed gets no address. - const episode = { ...redacted, feedUrl: vaultSafeFeedUrl(liveEpisode.feedUrl) }; + const episode = { ...redacted, feedUrl: liveEpisode.feedUrl }; const episodeUrl = resolveEpisodeUrl(episode); @@ -372,7 +361,7 @@ export function NoteTemplateEngine( // (which always describe the episode itself). See issue #163. addTag("episodeurl", episodeUrl); addTag("episodeartwork", sanitizePortableResourceUrl(episode.artworkUrl ?? "")); - addTag("feedurl", sanitizeUrlForTemplate(episode.feedUrl ?? "")); + addTag("feedurl", sanitizeUrlForTemplate(vaultSafeFeedUrl(episode.feedUrl) ?? "")); const parentFeed = get(plugin)?.settings?.savedFeeds?.[episode.podcastName]; addTag( "feedartwork", diff --git a/src/utility/buildEpisodeResumeLink.test.ts b/src/utility/buildEpisodeResumeLink.test.ts index a3af816a..bdc92233 100644 --- a/src/utility/buildEpisodeResumeLink.test.ts +++ b/src/utility/buildEpisodeResumeLink.test.ts @@ -97,7 +97,7 @@ describe("buildEpisodeResumeLink (#35)", () => { expect(parsed.searchParams.has("time")).toBe(false); }); - test("addresses an unsaved private feed's episode by its download, never its feed", () => { + test("omits an unsaved private feed link even when a download exists", () => { const privateEpisode: Episode = { ...remoteEpisode, feedUrl: "https://pod.example.com/feed.xml?auth=LINK_SENTINEL", @@ -105,9 +105,7 @@ describe("buildEpisodeResumeLink (#35)", () => { expect(buildEpisodeResumeLink(privateEpisode)).toBe(""); downloadedEpisodes.addEpisode(privateEpisode, "Downloads/ep1.mp3", 1); - expect(new URL(buildEpisodeResumeLink(privateEpisode)).searchParams.get("url")).toBe( - "Downloads/ep1.mp3", - ); + expect(buildEpisodeResumeLink(privateEpisode)).toBe(""); }); test("returns an empty string when there is no feed URL or file path to address", () => { diff --git a/src/utility/buildEpisodeResumeLink.ts b/src/utility/buildEpisodeResumeLink.ts index a022df46..b51699d3 100644 --- a/src/utility/buildEpisodeResumeLink.ts +++ b/src/utility/buildEpisodeResumeLink.ts @@ -15,13 +15,13 @@ import { vaultSafeFeedUrl } from "./privateFeedUrl"; * (the player refetches the feed and matches by title); a local file is * addressed by its on-disk vault path. As a last resort either kind falls back * to a downloaded copy's path, so an episode whose snapshot predates `feedUrl` - * still gets a working link instead of none. Returns "" only when nothing can - * address it, so a template degrades to an empty value rather than a broken link. + * still gets a working link instead of none. Returns "" when the target is a private feed + * without a subscription placeholder, or nothing can address the episode. */ export default function buildEpisodeResumeLink(episode: Episode): string { - const target = - vaultSafeFeedUrl(episodeLinkTarget(episode)) ?? - downloadedEpisodes.getEpisode(episode)?.filePath; + const target = vaultSafeFeedUrl( + episodeLinkTarget(episode) ?? downloadedEpisodes.getEpisode(episode)?.filePath, + ); // URIHandler rejects an empty episodeName, so without a title there is no // working link to emit: degrade to "" rather than a dead link (UR-03). diff --git a/src/utility/privateFeedUrl.ts b/src/utility/privateFeedUrl.ts index cee6ecf6..7e5a6c63 100644 --- a/src/utility/privateFeedUrl.ts +++ b/src/utility/privateFeedUrl.ts @@ -91,11 +91,6 @@ export function parsePrivateFeedPlaceholder(value: string): string | null { } } -/** - * `url` as a request sends it, so the scheme and host in any case, with or - * without a default port or a terminal dot, name the same feed. As given when - * it is not a URL. - */ export function canonicalUrl(url: string): string { const parsed = checkStringIsUrl(url); if (!parsed) return url; @@ -103,11 +98,6 @@ export function canonicalUrl(url: string): string { return parsed.href; } -/** - * `address` unless it is a credential-bearing feed URL. A saved private feed's - * episodes carry its placeholder, so such a URL belongs to a feed with no - * placeholder on this device, and notes and links get no address for it. - */ export function vaultSafeFeedUrl(address: string | undefined): string | undefined { return address && isCredentialBearingUrl(address) ? undefined : address; } diff --git a/tests/e2e/private-notes.test.ts b/tests/e2e/private-notes.test.ts new file mode 100644 index 00000000..26e52490 --- /dev/null +++ b/tests/e2e/private-notes.test.ts @@ -0,0 +1,195 @@ +import { expect, onTestFinished, test } from "vitest"; +import { SUPERCAST_FIXTURE_HOST } from "../../scripts/e2e-instance.mjs"; +import { createPodNotesE2EHarness } from "./harness"; +import { + click, + closeSettings, + openInReadingView, + openPodNotesSettings, + runCommand, + showAddPodcast, + showLibrary, + showPodNotes, + typeInto, + ui, +} from "./journey/actions"; +import { type FixtureFeed, startFixtureServer } from "./journey/fixtures"; +import { + feedCacheText, + readVaultText, + seedLibrary, + waitForLibrary, + waitForPlayback, + writeVaultFile, +} from "./journey/library"; +import { jsLiteral, RELOAD_OPTIONS } from "./runtime"; + +const getContext = createPodNotesE2EHarness("private-notes"); + +test("private descriptions and aliases stay out of cold and warm-cache episode notes", async () => { + const context = getContext(); + const { obsidian, plugin, sandbox } = context; + const episodes = ["Cold episode", "Warm episode"].map((title) => ({ + title, + seconds: 60, + description: "", + guid: title, + })); + const show: FixtureFeed = { + title: "Quoted Private", + slug: "quoted-private", + host: SUPERCAST_FIXTURE_HOST, + path: "/feeds/QUOTED_NOTE_SENTINEL/feed.xml", + artwork: false, + episodes, + }; + const server = await startFixtureServer([show]); + onTestFinished(() => server.stop()); + const address = server.feedUrl(show); + const dotted = address.replace(".com:", ".com.:"); + const backslashes = address.replace("http://", "http:\\\\"); + for (const episode of episodes) + episode.description = `Your feed: ${address}. Aliases: ${dotted}. ${backslashes}.`; + server.retitle(show, episodes[0], episodes[0].title); + await seedLibrary(context, server); + await showPodNotes(obsidian); + await showAddPodcast(obsidian); + await typeInto(obsidian, ui.podcastSearch, address); + await click(obsidian, ui.addPodcast(show.title)); + await waitForLibrary(plugin, (library) => library.subscriptions.length === 1, "subscribed"); + const secretId = await obsidian.dev.evalJson( + 'app.plugins.plugins.podnotes.settings.savedFeeds["Quoted Private"].urlSecretId', + ); + onTestFinished(() => + obsidian.dev.evalJson(`app.secretStorage.setSecret(${jsLiteral(secretId)}, ""), true`), + ); + + let coldFetches = 0; + for (const [index, episode] of episodes.entries()) { + if (index) await plugin.reload(RELOAD_OPTIONS); + await showPodNotes(obsidian); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(show.title)); + await click(obsidian, ui.episode(episode.title)); + await waitForPlayback(obsidian, (state) => state.playing && state.seconds > 0, "playing"); + await click(obsidian, ui.playbackToggle); + await runCommand(obsidian, "podnotes:create-podcast-note"); + const notePath = `PodNotes/${show.title}/${episode.title}.md`; + await sandbox.waitForExists(notePath, { timeoutMs: 10_000 }); + const note = await obsidian.waitFor( + async () => { + const text = await readVaultText(obsidian, sandbox.path(notePath)); + return text.includes("# " + episode.title) && text; + }, + { timeoutMs: 10_000, intervalMs: 100, message: "note content written" }, + ); + expect(note).toContain("Your feed: podnotes-private-feed:Quoted%20Private."); + expect(note).toContain('feedUrl: "podnotes-private-feed:Quoted%20Private"'); + expect(note).not.toContain("QUOTED_NOTE_SENTINEL"); + const fetches = server.requests().filter((path) => path === show.path).length; + if (index) expect(fetches).toBe(coldFetches); + else coldFetches = fetches; + } + expect(Object.keys(JSON.parse(await feedCacheText(obsidian)))).toEqual([ + "podnotes-private-feed:Quoted%20Private", + ]); +}); + +test("hostname and slash aliases are redacted from persisted templates and the settings export", async () => { + const context = getContext(); + const { obsidian, plugin, sandbox } = context; + await seedLibrary(context, null); + await plugin.updateDataAndReload>((data) => { + data.savedFeeds = { + Private: { + title: "Private", + url: "https://private.example.com/rss?auth=EXPORT_ALIAS_SENTINEL", + artworkUrl: "", + }, + }; + data.note = { + path: sandbox.path("{{title}}.md"), + template: [ + "https://private.example.com./rss?auth=EXPORT_ALIAS_SENTINEL", + "Quoted https:\\\\private.example.com/rss?auth=EXPORT_ALIAS_SENTINEL.", + "Quoted https:///%70rivate.example.com/rss?auth=EXPORT_ALIAS_SENTINEL.", + "https://private.example.com/audio.mp3?token=MEDIA_SIGNATURE", + ].join("\n"), + }; + }, RELOAD_OPTIONS); + const secretId = await obsidian.dev.evalJson( + "app.plugins.plugins.podnotes.settings.savedFeeds.Private.urlSecretId", + ); + onTestFinished(() => + obsidian.dev.evalJson(`app.secretStorage.setSecret(${jsLiteral(secretId)}, ""), true`), + ); + const persisted = JSON.stringify(await plugin.data().read()); + expect(persisted).not.toContain("EXPORT_ALIAS_SENTINEL"); + expect(persisted).toContain("MEDIA_SIGNATURE"); + await openPodNotesSettings(obsidian); + const name = "private-settings.json"; + await typeInto(obsidian, ui.settingControl("Export settings", "input"), sandbox.path(name), { + replace: true, + }); + await click(obsidian, ui.settingControl("Export settings", "button")); + await sandbox.waitForExists(name, { timeoutMs: 10_000 }); + await closeSettings(obsidian); + const exported = await readVaultText(obsidian, sandbox.path(name)); + expect(exported).toContain("podnotes-private-feed:Private"); + expect(exported).toContain("MEDIA_SIGNATURE"); + expect(exported).not.toContain("EXPORT_ALIAS_SENTINEL"); +}); + +test("an unsubscribed private link plays, creates a secret-free note and captures plain timestamps", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + const episode = { title: "Unsubscribed episode", seconds: 60, guid: "unsubscribed-1" }; + const show: FixtureFeed = { + title: "Unsubscribed Private", + slug: "unsubscribed-private", + host: SUPERCAST_FIXTURE_HOST, + path: "/feeds/UNSUBSCRIBED_NOTE_SENTINEL/feed.xml", + artwork: false, + episodes: [episode], + }; + const server = await startFixtureServer([show]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server); + const address = server.feedUrl(show); + const uri = `obsidian://podnotes?episodeName=${encodeURIComponent(episode.title)}&url=${encodeURIComponent(address)}&time=12`; + const legacyPath = sandbox.path("legacy-link.md"); + await writeVaultFile(obsidian, legacyPath, `[Play private episode](${uri})\n`); + await openInReadingView(obsidian, legacyPath); + await click(obsidian, ui.readingLink("Play private episode")); + await waitForPlayback(obsidian, (state) => state.playing && state.seconds >= 12, "URI playing"); + await runCommand(obsidian, "podnotes:create-podcast-note"); + const notePath = `PodNotes/${show.title}/${episode.title}.md`; + await sandbox.waitForExists(notePath, { timeoutMs: 10_000 }); + await obsidian.dev.evalJsonAsync( + '(async () => { await app.workspace.detachLeavesOfType("markdown"); return true; })()', + ); + await runCommand(obsidian, "podnotes:capture-timestamp"); + await obsidian.waitFor( + async () => /- 00:00:\d\d/.test(await readVaultText(obsidian, sandbox.path(notePath))), + { timeoutMs: 10_000, intervalMs: 100, message: "timestamp written" }, + ); + await runCommand(obsidian, "podnotes:capture-segment-10s"); + await obsidian.waitFor( + async () => + /- 00:00:\d\d-00:00:\d\d/.test(await readVaultText(obsidian, sandbox.path(notePath))), + { timeoutMs: 10_000, intervalMs: 100, message: "plain timestamp and segment saved" }, + ); + const note = await readVaultText(obsidian, sandbox.path(notePath)); + expect(note).toContain("# Unsubscribed episode"); + expect(note).not.toContain("UNSUBSCRIBED_NOTE_SENTINEL"); + expect(note).not.toContain("obsidian://podnotes"); + expect(note).not.toMatch(/^feedUrl:/m); + expect( + await obsidian.dev.evalJson("app.plugins.plugins.podnotes.api.podcast.feedUrl"), + ).toBe(address); + await waitForPlayback( + obsidian, + (state) => state.playing && state.seconds > 12, + "still playing", + ); +}); From 4b2824f64f8f74ca2a2eed3d0d979ca5149ee7c1 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 15:37:24 +0200 Subject: [PATCH 5/6] test(privacy): reproduce transformed metadata and URI identity leaks --- src/createPodcastNote.privacy.test.ts | 78 +++++++++++++++---- .../TranscriptionService.published.test.ts | 36 +++++++++ src/utility/privateFeedUrl.test.ts | 3 + 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/src/createPodcastNote.privacy.test.ts b/src/createPodcastNote.privacy.test.ts index fd62adfc..d49a4370 100644 --- a/src/createPodcastNote.privacy.test.ts +++ b/src/createPodcastNote.privacy.test.ts @@ -4,6 +4,7 @@ import { afterEach, expect, test } from "vitest"; import { DEFAULT_SETTINGS } from "./constants"; import { createPodcastNoteFileIfNotExists } from "./createPodcastNote"; import { FeedUrlRepository } from "./services/FeedUrlRepository"; +import FeedDocumentParser from "./parser/feedDocumentParser"; import { plugin } from "./store"; import { FeedFilePathTemplateEngine, @@ -13,6 +14,7 @@ import { } from "./TemplateEngine"; import type { Episode } from "./types/Episode"; import buildEpisodeResumeLink from "./utility/buildEpisodeResumeLink"; +import { formatEpisodeTimestamp } from "./utility/formatEpisodeTimestamp"; const address = "https://private.example.com/rss?auth=NOTE_SENTINEL"; const placeholder = "podnotes-private-feed:Paid"; @@ -27,7 +29,7 @@ const episode: Episode = { content: "", }; -function noteVault(subscribed: boolean) { +function noteVault(subscribed: boolean, artworkUrl = "") { const written: Array<{ path: string; content: string }> = []; const feedUrls = new FeedUrlRepository(new SecretStorage()); const urlSecretId = feedUrls.store(address); @@ -36,7 +38,7 @@ function noteVault(subscribed: boolean) { settings: { ...structuredClone(DEFAULT_SETTINGS), savedFeeds: subscribed - ? { Paid: { title: "Paid", url: placeholder, urlSecretId, artworkUrl: "" } } + ? { Paid: { title: "Paid", url: placeholder, urlSecretId, artworkUrl } } : {}, }, app: { @@ -73,6 +75,9 @@ test.each([ expect(written[0].content).toContain("Your feed: podnotes-private-feed:Paid."); expect(written[0].content).toContain(episode.url); expect(written[0].content).toContain('feedUrl: "podnotes-private-feed:Paid"'); + expect(written[0].content).toContain( + "[Resume in PodNotes](obsidian://podnotes?episodeName=Private%20episode&url=podnotes-private-feed%3APaid&guid=episode-1)", + ); expect(written[0].content).not.toContain("NOTE_SENTINEL"); }); @@ -97,18 +102,63 @@ test.each([ expect(buildEpisodeResumeLink({ ...episode, feedUrl })).toBe(""); }); -test("a note's path, title and identity carry no subscribed feed address they quote", async () => { - const written = noteVault(true); - await createPodcastNoteFileIfNotExists({ - ...episode, - feedUrl: placeholder, - title: `Feed ${address}`, - guid: `${address}#1`, - }); - expect(written).toHaveLength(1); - expect(written[0].path).toBe("PodNotes/Paid/Feed podnotes-private-feedPaid.md"); - expect(written[0].content).toContain('guid: "podnotes-private-feed:Paid"'); - expect(written[0].content).not.toContain("NOTE_SENTINEL"); +test.each([ + ["title", { title: `Feed ${address}` }, "PodNotes/Paid/Feed podnotes-private-feedPaid.md"], + ["GUID", { guid: `${address}#1` }, "PodNotes/Paid/Private episode.md"], +])( + "an episode whose %s quotes a subscribed feed gets no link or identity", + async (_, quoting, path) => { + const written = noteVault(true); + const quoted: Episode = { ...episode, feedUrl: placeholder, ...quoting }; + expect(formatEpisodeTimestamp(quoted, 65, "HH:mm:ss", true)).toBe("00:01:05"); + expect(formatEpisodeTimestamp(quoted, 65, "HH:mm:ss", true, 70)).toBe("00:01:05-00:01:10"); + expect(buildEpisodeResumeLink(quoted)).toBe(""); + await createPodcastNoteFileIfNotExists(quoted); + expect(written).toHaveLength(1); + expect(written[0].path).toBe(path); + expect(written[0].content).toContain("[Resume in PodNotes]()"); + expect(written[0].content).not.toMatch(/^(guid|feedUrl):/m); + expect(written[0].content).not.toContain("NOTE_SENTINEL"); + }, +); + +test.each([`Your feed: ${address.replace("_", "_")}`, `Your feed: <${address}>`])( + "a description that spells a subscribed feed with entities is redacted: %s", + async (html) => { + const written = noteVault(true); + const rss = `PaidPrivate episode${html}

]]>
`; + const [item] = new FeedDocumentParser().parseEpisodes(rss).episodes; + await createPodcastNoteFileIfNotExists({ + ...item, + url: episode.url, + podcastName: "Paid", + feedUrl: placeholder, + }); + expect(written).toHaveLength(1); + expect(written[0].content).toContain("Your feed: "); + expect(written[0].content).toContain("podnotes-private-feed:Paid"); + expect(written[0].content).not.toContain("NOTE_SENTINEL"); + }, +); + +test.each([ + [address, ""], + [ + "https://cdn.example.com/art.jpg?token=ART_SIGNATURE", + "https://cdn.example.com/art.jpg?token=ART_SIGNATURE", + ], +])("{{feedartwork}} for saved artwork %s renders %j", (artworkUrl, expected) => { + noteVault(true, artworkUrl); + expect(NoteTemplateEngine("{{feedartwork}}", { ...episode, feedUrl: placeholder })).toBe( + expected, + ); +}); + +test("a template that quotes a subscribed feed renders its placeholder", () => { + noteVault(true); + expect( + NoteTemplateEngine(`Feed: ${address}\n{{title}}`, { ...episode, feedUrl: placeholder }), + ).toBe("Feed: podnotes-private-feed:Paid\nPrivate episode"); }); test("chapters and transcripts that quote a subscribed feed render its placeholder", () => { diff --git a/src/services/TranscriptionService.published.test.ts b/src/services/TranscriptionService.published.test.ts index fa324bea..a7d878af 100644 --- a/src/services/TranscriptionService.published.test.ts +++ b/src/services/TranscriptionService.published.test.ts @@ -309,4 +309,40 @@ describe("published transcripts", () => { ); } }); + + test.each([ + [ + "timed", + { url: "1.vtt", type: "text/vtt" }, + (feed: string) => + `WEBVTT\n\n00:00:00.480 --> 00:00:04.410\nYour feed: ${feed}\n`, + "# Test Episode\n\n00:00:00 **Host podnotes-private-feed:Test\\%20Podcast:** Your feed: podnotes-private-feed:Test\\%20Podcast", + ], + [ + "untimed", + { url: "1.txt", type: "text/plain" }, + (feed: string) => `Your feed: ${feed}`, + "# Test Episode\n\nYour feed: podnotes-private-feed:Test\\%20Podcast", + ], + ])( + "a %s transcript quoting the episode's private feed keeps no address", + async (_, link, body, note) => { + const secretFeed = "https://private.example.com/rss?auth=REVIEW%2FSENTINEL"; + fetchTextMock.mockResolvedValue(body(secretFeed)); + const plugin = createMockPlugin({ + openAIKey: "", + podcast: { ...mockEpisode, feedUrl: secretFeed }, + transcripts: [link], + }); + const service = new TranscriptionService(plugin); + + await service.transcribeCurrentEpisode(); + await finished(service, "Saved the transcript the feed publishes."); + + expect(plugin.app.vault.create).toHaveBeenCalledExactlyOnceWith( + "Transcripts/Test Podcast/Test Episode.md", + note, + ); + }, + ); }); diff --git a/src/utility/privateFeedUrl.test.ts b/src/utility/privateFeedUrl.test.ts index f56b3411..2a20a0bc 100644 --- a/src/utility/privateFeedUrl.test.ts +++ b/src/utility/privateFeedUrl.test.ts @@ -130,6 +130,9 @@ describe("redactPrivateFeedAddresses", () => { "https:\\\\private.example.com/rss?auth=ALIAS_SENTINEL", "https:/\\private.example.com/rss?auth=ALIAS_SENTINEL", "https:///%70rivate.example.com/rss?auth=ALIAS_SENTINEL", + "https:/private.example.com/rss?auth=ALIAS_SENTINEL", + "https:\\private.example.com/rss?auth=ALIAS_SENTINEL", + "HTTPS:private.example.com/rss?auth=ALIAS_SENTINEL", ])("redacts standalone and quoted browser aliases: %s", (alias) => { const known = new Map([ ["https://private.example.com/rss?auth=ALIAS_SENTINEL", "podnotes-private-feed:Paid"], From 5230a97af544a584b452392c3daf63bc85313102 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 15:37:24 +0200 Subject: [PATCH 6/6] fix(privacy): protect decoded metadata and URI identity at vault outputs --- src/TemplateEngine.ts | 55 ++++++++++------------ src/createPodcastNote.ts | 16 +++---- src/services/privateFeeds.ts | 26 ++++++++++ src/services/publishedTranscript/render.ts | 5 +- src/utility/buildEpisodeResumeLink.ts | 9 ++-- src/utility/formatEpisodeTimestamp.ts | 4 +- src/utility/privateFeedUrl.ts | 12 ++--- 7 files changed, 73 insertions(+), 54 deletions(-) diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index ad04a921..8d498592 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -17,8 +17,7 @@ import type { PodcastSegmentTimes } from "./utility/podcastSegment"; import type { Chapter } from "./types/Chapter"; import { normalizeChapters } from "./utility/normalizeChapters"; import { isFetchableUrl } from "./utility/assertFetchableUrl"; -import { privateFeedAddresses } from "./services/privateFeeds"; -import { redactPrivateFeedAddresses, vaultSafeFeedUrl } from "./utility/privateFeedUrl"; +import { vaultSafeLinkTarget, withoutPrivateFeedAddresses } from "./services/privateFeeds"; // Each tag is either a literal string or a function taking at most one argument // (the raw text after the leading colon, e.g. the format in {{date:YYYY}}). The @@ -48,14 +47,6 @@ export function templateHasTag(template: string, tag: Lowercase): boolea ); } -export function withoutPrivateFeedAddresses(value: T): T { - const instance = get(plugin); - return redactPrivateFeedAddresses( - value, - instance?.feedUrls && privateFeedAddresses(instance.settings.savedFeeds, instance.feedUrls), - ); -} - function withoutFeedAddresses(feed: PodcastFeed): PodcastFeed { return withoutPrivateFeedAddresses([feed, { feedUrl: feed.url }] as const)[0]; } @@ -68,7 +59,7 @@ function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> { } function replacer(template: string): string { - return template.replace( + return withoutPrivateFeedAddresses(template).replace( TEMPLATE_TAG_REGEX, (match: string, tagId: string, params: string) => { const tagValue = tags[tagId.toLowerCase()]; @@ -247,7 +238,7 @@ function neutralizeMarkdownEmbeds(markdown: string): string { * rewritten here to avoid mangling legitimate inline code in programming show * notes. */ -function feedHtmlToMarkdown(html: string): string { +function feedHtmlToMarkdown(html: string, source: object): string { const document = new DOMParser().parseFromString(html, "text/html"); for (const element of document.querySelectorAll( "img, audio, video, source, iframe, object, embed, link", @@ -255,6 +246,13 @@ function feedHtmlToMarkdown(html: string): string { const alt = element.instanceOf(HTMLImageElement) ? element.alt.trim() : ""; element.replaceWith(document.createTextNode(alt)); } + // Entities can spell out a private feed address, so redact the decoded text. + const nodes = [document.body, ...document.body.querySelectorAll("*")].flatMap((element) => [ + ...element.attributes, + ...element.childNodes, + ]); + const [, values] = withoutPrivateFeedAddresses([source, nodes.map((node) => node.nodeValue)]); + nodes.forEach((node, index) => (node.nodeValue = values[index])); return neutralizeMarkdownEmbeds( neutralizeExecutableCodeBlocks(htmlToMarkdown(document.body.innerHTML)), ); @@ -297,18 +295,19 @@ export function NoteTemplateEngine( context: NoteTemplateContext = {}, ) { const [replacer, addTag] = useTemplateEngine(); - const [redacted, chapters] = withoutPrivateFeedAddresses([ + const parentFeed = get(plugin)?.settings?.savedFeeds?.[liveEpisode.podcastName]; + const [episode, chapters, feedArtwork] = withoutPrivateFeedAddresses([ liveEpisode, context.chapters, + parentFeed?.artworkUrl, ] as const); - const episode = { ...redacted, feedUrl: liveEpisode.feedUrl }; const episodeUrl = resolveEpisodeUrl(episode); addTag("title", escapeMarkdownBodyText(episode.title)); addTag("description", (prependToLines?: string) => { // reduce multiple new lines - const sanitizeDescription = feedHtmlToMarkdown(episode.description).replace( + const sanitizeDescription = feedHtmlToMarkdown(episode.description, liveEpisode).replace( /\n{3,}/g, "\n\n", ); @@ -323,13 +322,13 @@ export function NoteTemplateEngine( }); addTag("content", (prependToLines?: string) => { if (prependToLines) { - return feedHtmlToMarkdown(episode.content) + return feedHtmlToMarkdown(episode.content, liveEpisode) .split("\n") .map((str) => `${prependToLines}${str}`) .join("\n"); } - return feedHtmlToMarkdown(episode.content); + return feedHtmlToMarkdown(episode.content, liveEpisode); }); addTag("safetitle", replaceIllegalFileNameCharactersInString(episode.title)); addTag("stream", sanitizePortableResourceUrl(episode.streamUrl)); @@ -361,12 +360,11 @@ export function NoteTemplateEngine( // (which always describe the episode itself). See issue #163. addTag("episodeurl", episodeUrl); addTag("episodeartwork", sanitizePortableResourceUrl(episode.artworkUrl ?? "")); - addTag("feedurl", sanitizeUrlForTemplate(vaultSafeFeedUrl(episode.feedUrl) ?? "")); - const parentFeed = get(plugin)?.settings?.savedFeeds?.[episode.podcastName]; addTag( - "feedartwork", - sanitizePortableResourceUrl(parentFeed?.artworkUrl ?? episode.artworkUrl ?? ""), + "feedurl", + sanitizeUrlForTemplate(vaultSafeLinkTarget(liveEpisode, liveEpisode.feedUrl) ?? ""), ); + addTag("feedartwork", sanitizePortableResourceUrl(feedArtwork ?? episode.artworkUrl ?? "")); // A ready-made wikilink to the parent feed's note, pointing at the same file // createFeedNote writes (derived from the feed-note path setting). addTag("podcastlink", getFeedNoteWikilink(episode.podcastName)); @@ -375,7 +373,7 @@ export function NoteTemplateEngine( // never been played). The resume point is resolved at click time, not baked // in here, so a single templated link always jumps to where you left off. // Empty when the episode has no feed/file URL to address it by. See issue #35. - addTag("episodelink", buildEpisodeResumeLink(episode)); + addTag("episodelink", buildEpisodeResumeLink(liveEpisode)); return replacer(template); } @@ -473,13 +471,13 @@ export function TranscriptTemplateEngine( addTag("transcript", transcription); addTag("description", (prependToLines?: string) => { if (prependToLines) { - return feedHtmlToMarkdown(episode.description) + return feedHtmlToMarkdown(episode.description, liveEpisode) .split("\n") .map((str) => `${prependToLines}${str}`) .join("\n"); } - return feedHtmlToMarkdown(episode.description); + return feedHtmlToMarkdown(episode.description, liveEpisode); }); addTag("url", resolveEpisodeUrl(episode)); addTag("artwork", sanitizePortableResourceUrl(episode.artworkUrl ?? "")); @@ -504,15 +502,14 @@ export function FeedNoteTemplateEngine(template: string, liveFeed: PodcastFeed) // inside quoted YAML frontmatter scalars (the default feed template uses both). // A well-formed URL never contains the characters this neutralizes. addTag("url", sanitizeUrlForTemplate(feed.link ?? "")); - addTag("feedurl", sanitizeUrlForTemplate(vaultSafeFeedUrl(liveFeed.url) ?? "")); + addTag("feedurl", sanitizeUrlForTemplate(vaultSafeLinkTarget(liveFeed, liveFeed.url) ?? "")); addTag("artwork", sanitizePortableResourceUrl(feed.artworkUrl ?? "")); addTag("feedartwork", sanitizePortableResourceUrl(feed.artworkUrl ?? "")); addTag("author", escapeMarkdownBodyText(feed.author ?? "")); addTag("description", (prependToLines?: string) => { - const sanitizeDescription = feedHtmlToMarkdown(feed.description ?? "").replace( - /\n{3,}/g, - "\n\n", - ); + const sanitizeDescription = feedHtmlToMarkdown(feed.description ?? "", { + feedUrl: liveFeed.url, + }).replace(/\n{3,}/g, "\n\n"); if (prependToLines) { return sanitizeDescription .split("\n") diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index 3b398e5f..d2041d58 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -4,7 +4,6 @@ import { legacyReplaceIllegalFileNameCharactersInString, NoteTemplateEngine, templateHasTag, - withoutPrivateFeedAddresses, } from "./TemplateEngine"; import type { Episode } from "./types/Episode"; import type { Chapter } from "./types/Chapter"; @@ -17,7 +16,7 @@ import { fetchChapters } from "./utility/fetchChapters"; import { findUniqueTitleMatch } from "./utility/episodeTitleMatch"; import { findByGuid } from "./utility/findByGuid"; import { addFrontmatterFields } from "./utility/noteFrontmatter"; -import { vaultSafeFeedUrl } from "./utility/privateFeedUrl"; +import { vaultSafeLinkTarget } from "./services/privateFeeds"; /** * Resolve the on-disk path of an episode's note from the configured template. @@ -102,16 +101,13 @@ export async function createPodcastNoteFileIfNotExists( if (arrivedFile) return { file: arrivedFile, created: false }; const content = NoteTemplateEngine(template, episode, { chapters }); - const feedUrl = vaultSafeFeedUrl(episode.feedUrl); + const feedUrl = vaultSafeLinkTarget(episode, episode.feedUrl); const identified = episode.guid && feedUrl - ? addFrontmatterFields( - content, - withoutPrivateFeedAddresses([ - ["guid", episode.guid], - ["feedUrl", feedUrl], - ]), - ) + ? addFrontmatterFields(content, [ + ["guid", episode.guid], + ["feedUrl", feedUrl], + ]) : content; return createFileIfNotExists(filePathDotMd, identified); diff --git a/src/services/privateFeeds.ts b/src/services/privateFeeds.ts index 83b0fa14..1eadd5c4 100644 --- a/src/services/privateFeeds.ts +++ b/src/services/privateFeeds.ts @@ -1,4 +1,7 @@ import { Notice } from "obsidian"; +import { get } from "svelte/store"; +import { plugin } from "src/store"; +import type { Episode } from "src/types/Episode"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import type { PodcastFeed } from "src/types/PodcastFeed"; import { ownRecord } from "src/utility/ownRecord"; @@ -167,3 +170,26 @@ export function privateFeedAddresses( } return addresses; } + +export function withoutPrivateFeedAddresses(value: T): T { + const instance = get(plugin); + return redactPrivateFeedAddresses( + value, + instance?.feedUrls && privateFeedAddresses(instance.settings.savedFeeds, instance.feedUrls), + ); +} + +/** + * `target` when a note may address `episode` by it, so a PodNotes link or note + * identity can carry it with the episode's title and GUID. Undefined when the + * target is a private feed's own address, or any of them quotes one, because a + * redacted title or GUID would no longer find the episode. + */ +export function vaultSafeLinkTarget( + episode: Pick, + target: string | undefined, +): string | undefined { + if (!target || isCredentialBearingUrl(target)) return undefined; + const fields = [target, episode.title, episode.guid]; + return withoutPrivateFeedAddresses(fields) === fields ? target : undefined; +} diff --git a/src/services/publishedTranscript/render.ts b/src/services/publishedTranscript/render.ts index ba65318a..27f30d60 100644 --- a/src/services/publishedTranscript/render.ts +++ b/src/services/publishedTranscript/render.ts @@ -1,4 +1,5 @@ import type { Episode } from "src/types/Episode"; +import { withoutPrivateFeedAddresses } from "src/services/privateFeeds"; import { DEFAULT_SPEAKER_TEMPLATE, formatSpeakerLabel } from "src/services/diarization/segments"; import { formatEpisodeTimestamp } from "src/utility/formatEpisodeTimestamp"; import { collapseWhitespace, type ParsedTranscript, type TranscriptCue } from "./parse"; @@ -97,9 +98,11 @@ function splitText(text: string, max: number): string[] { } export function renderTranscriptBody( - transcript: ParsedTranscript, + parsed: ParsedTranscript, options: { episode: Episode; speakerTemplate: string; timestamp: TimestampStyle }, ): string { + // Escaping puts backslashes inside an address, so redact before it. + const [, transcript] = withoutPrivateFeedAddresses([options.episode, parsed] as const); if (transcript.kind === "untimed") { return transcript.paragraphs .flatMap((text) => splitText(escapeTranscriptText(text), MAX_PARAGRAPH_LENGTH)) diff --git a/src/utility/buildEpisodeResumeLink.ts b/src/utility/buildEpisodeResumeLink.ts index b51699d3..8102e72c 100644 --- a/src/utility/buildEpisodeResumeLink.ts +++ b/src/utility/buildEpisodeResumeLink.ts @@ -2,7 +2,7 @@ import type { Episode } from "src/types/Episode"; import { downloadedEpisodes } from "src/store"; import encodePodnotesURI from "./encodePodnotesURI"; import { episodeLinkTarget } from "./episodeLinkTarget"; -import { vaultSafeFeedUrl } from "./privateFeedUrl"; +import { vaultSafeLinkTarget } from "src/services/privateFeeds"; /** * Build an `obsidian://podnotes` deep link that reopens `episode` in the PodNotes @@ -15,11 +15,12 @@ import { vaultSafeFeedUrl } from "./privateFeedUrl"; * (the player refetches the feed and matches by title); a local file is * addressed by its on-disk vault path. As a last resort either kind falls back * to a downloaded copy's path, so an episode whose snapshot predates `feedUrl` - * still gets a working link instead of none. Returns "" when the target is a private feed - * without a subscription placeholder, or nothing can address the episode. + * still gets a working link instead of none. Returns "" when nothing can address the + * episode, or the link would carry a private feed address (see vaultSafeLinkTarget). */ export default function buildEpisodeResumeLink(episode: Episode): string { - const target = vaultSafeFeedUrl( + const target = vaultSafeLinkTarget( + episode, episodeLinkTarget(episode) ?? downloadedEpisodes.getEpisode(episode)?.filePath, ); diff --git a/src/utility/formatEpisodeTimestamp.ts b/src/utility/formatEpisodeTimestamp.ts index aea157d1..e2785214 100644 --- a/src/utility/formatEpisodeTimestamp.ts +++ b/src/utility/formatEpisodeTimestamp.ts @@ -3,7 +3,7 @@ import encodePodnotesURI from "./encodePodnotesURI"; import { episodeLinkTarget } from "./episodeLinkTarget"; import { formatSeconds } from "./formatSeconds"; import { formatPodcastSegment } from "./podcastSegment"; -import { vaultSafeFeedUrl } from "./privateFeedUrl"; +import { vaultSafeLinkTarget } from "src/services/privateFeeds"; export function formatEpisodeTimestamp( episode: Episode, @@ -16,7 +16,7 @@ export function formatEpisodeTimestamp( endSeconds === undefined ? formatSeconds(seconds, format) : formatPodcastSegment(seconds, endSeconds, format); - const target = linkify ? vaultSafeFeedUrl(episodeLinkTarget(episode)) : undefined; + const target = linkify ? vaultSafeLinkTarget(episode, episodeLinkTarget(episode)) : undefined; return target ? `[${time}](${encodePodnotesURI(episode, target, seconds, endSeconds).href})` : time; diff --git a/src/utility/privateFeedUrl.ts b/src/utility/privateFeedUrl.ts index 7e5a6c63..ca407498 100644 --- a/src/utility/privateFeedUrl.ts +++ b/src/utility/privateFeedUrl.ts @@ -98,10 +98,6 @@ export function canonicalUrl(url: string): string { return parsed.href; } -export function vaultSafeFeedUrl(address: string | undefined): string | undefined { - return address && isCredentialBearingUrl(address) ? undefined : address; -} - /** * `document` with no private feed address left in it. A snapshot's * credential-bearing `feedUrl` is a private feed address, as is each key of @@ -143,7 +139,7 @@ export function redactPrivateFeedAddresses( // plugin, so keep this untagged to let Obsidian reuse it across reloads. const candidate = new RegExp( hosts.map((host) => host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + - "|https?:[/\\\\]{2}[^\\s/?#]*[%\\\\\\[\\u0080-\\uFFFF]|https?:[/\\\\]{2}(?:[^\\s/?#@]*@)?[\\d/\\\\]", + "|https?:[/\\\\]*[^\\s/?#]*[%\\\\\\[\\u0080-\\uFFFF]|https?:[/\\\\]*(?:[^\\s/?#@]*@)?\\d", "i", ); const matches = new Map(); @@ -156,9 +152,9 @@ export function redactPrivateFeedAddresses( return replacement; }; return replaceStrings(document, (value) => { - if (!/:[/\\]{2}/.test(value) || !candidate.test(value)) return value; - if (/^https?:[/\\]{2}[^\s<>"'`]+$/i.test(value)) return lookup(value) ?? value; - return value.replace(/https?:[/\\]{2}[^\s<>"'`]+/gi, (address) => { + if (!/https?:/i.test(value) || !candidate.test(value)) return value; + if (/^https?:[^\s<>"'`]+$/i.test(value)) return lookup(value) ?? value; + return value.replace(/https?:[^\s<>"'`]+/gi, (address) => { const whole = lookup(address); if (whole) return whole; const bare = address.replace(/[.,;:!?)\]}]+$/, "");