From 054d7c9ad62c302894a35f122bdaaf8e497a21c7 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 11 Jul 2026 15:14:30 +0200 Subject: [PATCH 1/2] 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. --- src/TemplateEngine.test.ts | 57 +++++++++++++++++++++++++++++++ src/TemplateEngine.ts | 70 +++++++++++++++++++++++++++++--------- 2 files changed, 110 insertions(+), 17 deletions(-) diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index b174e7c..04f6797 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -175,6 +175,63 @@ describe("empty tag arguments (NT-05/CH-09)", () => { }); }); +describe("note-render hardening (browser-managed requests from generated notes)", () => { + it("omits private and local resource targets from portable artwork tags", () => { + for (const artworkUrl of [ + "http://127.0.0.1/private.png", + "http://169.254.169.254/latest/meta-data/", + "file:///Users/example/secret.png", + ]) { + expect(NoteTemplateEngine("{{artwork}}", { ...demoEpisode, artworkUrl })).toBe(""); + } + }); + + it("omits credential-bearing artwork URLs (userinfo never reaches a note)", () => { + expect( + NoteTemplateEngine("{{artwork}}", { + ...demoEpisode, + artworkUrl: "https://user:pass@example.com/art.png", + }), + ).toBe(""); + }); + + it("keeps ordinary public artwork URLs", () => { + expect( + NoteTemplateEngine("{{artwork}}", { + ...demoEpisode, + artworkUrl: "https://cdn.example.com/art.png", + }), + ).toBe("https://cdn.example.com/art.png"); + }); + + it("removes active media elements from feed descriptions while preserving alt text", () => { + const malicious: Episode = { + ...demoEpisode, + description: + '

Introduction cover art

', + }; + const rendered = NoteTemplateEngine("{{description}}", malicious); + expect(rendered).toContain("Introduction"); + expect(rendered).toContain("cover art"); + expect(rendered).not.toContain("127.0.0.1"); + expect(rendered).not.toContain("169.254.169.254"); + expect(rendered).not.toContain(" { + const malicious: Episode = { + ...demoEpisode, + description: + "![private](http://127.0.0.1/admin) ![[Secrets.md]] \\![metadata](http://169.254.169.254/)", + }; + const rendered = NoteTemplateEngine("{{description}}", malicious); + expect(rendered).not.toMatch(/(^|[^\\])!\[/); + expect(rendered).toContain("private"); + expect(rendered).toContain("Secrets.md"); + }); +}); + describe("NoteTemplateEngine feed-scoped tags (#163)", () => { it("keeps {{url}} and {{artwork}} pointing at the episode itself", () => { plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never); diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 5cdee85..3134ee6 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -16,6 +16,7 @@ import { isLocalFile } from "./utility/isLocalFile"; import type { PodcastSegmentTimes } from "./utility/podcastSegment"; import type { Chapter } from "./types/Chapter"; import { normalizeChapters } from "./utility/normalizeChapters"; +import { isFetchableUrl } from "./utility/assertFetchableUrl"; // 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 @@ -199,24 +200,46 @@ function neutralizeExecutableCodeBlocks(markdown: string): string { return markdown.replace(/(`{3,}|~{3,})[ \t]*(?:dataviewjs|dataview)\b/gi, "$1text"); } +/** + * Escape `![` so feed text cannot form a live Markdown embed. Balanced escaping: + * an even run of backslashes before `![` leaves the embed live, so one more is + * added; an odd run already escapes it and is left alone. + */ +function neutralizeMarkdownEmbeds(markdown: string): string { + return markdown.replace(/\\*!\[/g, (match) => { + const slashCount = match.length - 2; + const safeSlashCount = slashCount % 2 === 0 ? slashCount + 1 : slashCount; + return `${"\\".repeat(safeSlashCount)}![`; + }); +} + /** * Convert feed-controlled HTML to Markdown and render any executable Dataview code * fence inert. `htmlToMarkdown` strips active HTML (scripts, `javascript:`), and * this additionally closes the `
` ->
  * ```dataviewjs code-execution path.
  *
- * Legitimate rich-text formatting in show notes (links, images, ordinary code
- * blocks) is intentionally preserved: rendering the feed's own show notes is the
- * purpose of {{description}}/{{content}}. That means a feed can still place an
- * external image (a reader can disable external image loading in Obsidian) or a
- * literal [[wikilink]] in this free-text body; unlike the structured tags
- * ({{title}}, {{podcastlink}}, the URL tags) these are accepted as show-note
- * content. Inline Dataview queries (`= ...`/`$= ...`) are a known residual of the
- * same speculative, Dataview-must-be-installed class and are not rewritten here to
- * avoid mangling legitimate inline code in programming show notes.
+ * Legitimate text formatting, links, and ordinary code blocks are preserved.
+ * Active media elements (img/audio/video/source/iframe/object/embed/link) are
+ * removed before conversion, and Markdown image embeds are neutralized after it,
+ * so opening a generated note cannot make a browser-managed request to a
+ * feed-selected local/private target - those renders never pass PodNotes'
+ * network gate. Inline Dataview queries (`= ...`/`$= ...`) are a known residual
+ * of the same speculative, Dataview-must-be-installed class and are not
+ * rewritten here to avoid mangling legitimate inline code in programming show
+ * notes.
  */
 function feedHtmlToMarkdown(html: string): string {
-	return neutralizeExecutableCodeBlocks(htmlToMarkdown(html));
+	const document = new DOMParser().parseFromString(html, "text/html");
+	for (const element of document.querySelectorAll(
+		"img, audio, video, source, iframe, object, embed, link",
+	)) {
+		const alt = element instanceof HTMLImageElement ? element.alt.trim() : "";
+		element.replaceWith(document.createTextNode(alt));
+	}
+	return neutralizeMarkdownEmbeds(
+		neutralizeExecutableCodeBlocks(htmlToMarkdown(document.body.innerHTML)),
+	);
 }
 
 function formatTemplateChapters(chapters: Chapter[] | undefined, prependToLines?: string): string {
@@ -286,7 +309,7 @@ export function NoteTemplateEngine(
 		return feedHtmlToMarkdown(episode.content);
 	});
 	addTag("safetitle", replaceIllegalFileNameCharactersInString(episode.title));
-	addTag("stream", sanitizeUrlForTemplate(episode.streamUrl));
+	addTag("stream", sanitizePortableResourceUrl(episode.streamUrl));
 	addTag("url", episodeUrl);
 	addTag("date", (format?: string) =>
 		episode.episodeDate ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "",
@@ -308,18 +331,18 @@ export function NoteTemplateEngine(
 		formatTemplateChapters(context.chapters, prependToLines),
 	);
 	addTag("podcast", replaceIllegalFileNameCharactersInString(episode.podcastName));
-	addTag("artwork", sanitizeUrlForTemplate(episode.artworkUrl ?? ""));
+	addTag("artwork", sanitizePortableResourceUrl(episode.artworkUrl ?? ""));
 
 	// Feed-scoped tags so an episode note can reference its parent podcast feed
 	// without changing the meaning of the existing {{url}}/{{artwork}} tags
 	// (which always describe the episode itself). See issue #163.
 	addTag("episodeurl", episodeUrl);
-	addTag("episodeartwork", sanitizeUrlForTemplate(episode.artworkUrl ?? ""));
+	addTag("episodeartwork", sanitizePortableResourceUrl(episode.artworkUrl ?? ""));
 	addTag("feedurl", sanitizeUrlForTemplate(episode.feedUrl ?? ""));
 	const parentFeed = get(plugin)?.settings?.savedFeeds?.[episode.podcastName];
 	addTag(
 		"feedartwork",
-		sanitizeUrlForTemplate(parentFeed?.artworkUrl ?? episode.artworkUrl ?? ""),
+		sanitizePortableResourceUrl(parentFeed?.artworkUrl ?? 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).
@@ -428,7 +451,7 @@ export function TranscriptTemplateEngine(
 		return feedHtmlToMarkdown(episode.description);
 	});
 	addTag("url", resolveEpisodeUrl(episode));
-	addTag("artwork", sanitizeUrlForTemplate(episode.artworkUrl ?? ""));
+	addTag("artwork", sanitizePortableResourceUrl(episode.artworkUrl ?? ""));
 
 	return replacer(template);
 }
@@ -450,8 +473,8 @@ export function FeedNoteTemplateEngine(template: string, feed: PodcastFeed) {
 	// A well-formed URL never contains the characters this neutralizes.
 	addTag("url", sanitizeUrlForTemplate(feed.link ?? ""));
 	addTag("feedurl", sanitizeUrlForTemplate(feed.url));
-	addTag("artwork", sanitizeUrlForTemplate(feed.artworkUrl ?? ""));
-	addTag("feedartwork", sanitizeUrlForTemplate(feed.artworkUrl ?? ""));
+	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(
@@ -531,6 +554,19 @@ function sanitizeUrlForTemplate(url: string): string {
 	});
 }
 
+/**
+ * URL for a browser-managed media/artwork reference in a generated note. The
+ * note renderer fetches these outside PodNotes' network gate, so only a
+ * public http(s) target without embedded credentials may be emitted; anything
+ * else becomes "".
+ */
+function sanitizePortableResourceUrl(rawUrl: string): string {
+	if (!rawUrl || !isFetchableUrl(rawUrl)) return "";
+	const parsed = new URL(rawUrl);
+	if (parsed.username || parsed.password) return "";
+	return sanitizeUrlForTemplate(rawUrl);
+}
+
 export function replaceIllegalFileNameCharactersInString(string: string) {
 	return (
 		string

From a3aad19a8f5d3f5e58c11f2a768f4189e38e6224 Mon Sep 17 00:00:00 2001
From: Christian Bager Bach Houmann 
Date: Sat, 11 Jul 2026 15:25:54 +0200
Subject: [PATCH 2/2] 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.
---
 src/TemplateEngine.ts | 14 ++++++++++----
 1 file changed, 10 insertions(+), 4 deletions(-)

diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts
index 3134ee6..81031dc 100644
--- a/src/TemplateEngine.ts
+++ b/src/TemplateEngine.ts
@@ -555,10 +555,16 @@ function sanitizeUrlForTemplate(url: string): string {
 }
 
 /**
- * URL for a browser-managed media/artwork reference in a generated note. The
- * note renderer fetches these outside PodNotes' network gate, so only a
- * public http(s) target without embedded credentials may be emitted; anything
- * else becomes "".
+ * URL for a browser-managed media/artwork reference in a generated note (the
+ * default template embeds {{artwork}} as a live image). The note renderer
+ * fetches these outside PodNotes' network gate, so only a public http(s)
+ * target without embedded credentials is emitted; anything else becomes "".
+ *
+ * Residual (same ceiling as the network gate, PR #290): a public HOSTNAME that
+ * resolves via DNS to a private/loopback address still passes, because that can
+ * only be caught by resolving DNS - which a synchronous template function, and
+ * Obsidian's renderer fetch, cannot do. Literal private/loopback/link-local
+ * targets and credentialed URLs, the cases fully decidable here, are blocked.
  */
 function sanitizePortableResourceUrl(rawUrl: string): string {
 	if (!rawUrl || !isFetchableUrl(rawUrl)) return "";