Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/TemplateEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
'<p>Introduction <img src="http://127.0.0.1/private.png" alt="cover art"><iframe src="http://169.254.169.254/"></iframe></p>',
};
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("<img");
expect(rendered).not.toContain("<iframe");
});

it("renders raw Markdown image and vault-embed syntax inert in feed text", () => {
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);
Expand Down
76 changes: 59 additions & 17 deletions src/TemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<pre><code class="language-dataviewjs">` ->
* ```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 {
Expand Down Expand Up @@ -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") : "",
Expand All @@ -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).
Expand Down Expand Up @@ -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);
}
Expand All @@ -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(
Expand Down Expand Up @@ -531,6 +554,25 @@ function sanitizeUrlForTemplate(url: string): string {
});
}

/**
* 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 "";
Comment thread
chhoumann marked this conversation as resolved.
const parsed = new URL(rawUrl);
if (parsed.username || parsed.password) return "";
return sanitizeUrlForTemplate(rawUrl);
}

export function replaceIllegalFileNameCharactersInString(string: string) {
return (
string
Expand Down