Skip to content

fix(template): neutralize feed-controlled note injection#228

Merged
chhoumann merged 2 commits into
masterfrom
chhoumann/deepsec-note-injection
Jun 29, 2026
Merged

fix(template): neutralize feed-controlled note injection#228
chhoumann merged 2 commits into
masterfrom
chhoumann/deepsec-note-injection

Conversation

@chhoumann

Copy link
Copy Markdown
Owner

Summary

Feed-controlled fields are written into generated notes by the template engines in src/TemplateEngine.ts without escaping. A malicious RSS feed can therefore inject Markdown, external/tracking images, phishing links, Obsidian [[wikilinks]], and (speculatively) executable Dataview code blocks into a reader's vault.

This fixes two deepsec-confirmed findings, both scoped to src/TemplateEngine.ts:

  • other-markdown-injection (adb1a853c0) - raw {{title}}/URL tags (artwork/stream/url/feedurl/feed*/episode*) and htmlToMarkdown'd {{description}}/{{content}} injected verbatim. The headline attack: a crafted artworkUrl like x)![pwn](http://attacker/leak.png) [click](http://phish) breaks out of the default template's ![]({{artwork}}) to inject external tracking images / phishing links.
  • other-wikilink-injection (7a7cf39683) - replaceIllegalFileNameCharactersInString did not strip [/], so a feed <title> such as Real]] [[Victims Private Note injects an extra wikilink into every episode note's podcast: frontmatter via {{podcastlink}}.

What changed (src/TemplateEngine.ts)

  • Raw inline text ({{title}}, feed {{title}}/{{author}}) - new sanitizeInlineText: collapses control characters to spaces (no line/block breakout from # {{title}}) and neutralizes link/image/inline-code/raw-HTML metacharacters ([ ] < > \``), while keeping ordinary punctuation readable (deliberately lighter than escapeMarkdownText`).
  • URL tags - broadened sanitizeUrlForTemplate percent-encodes the characters that would break a Markdown link/image target, a bare-line autolink, or a quoted YAML scalar (control/space/"'\()[]<>\), losslessly for real URLs. {{url}}/{{episodeurl}} still pass a trusted **local-file** vault wikilink through verbatim (isLocalFile` gate); feed URLs are sanitized.
  • Wikilink sanitizer - replaceIllegalFileNameCharactersInString now also strips [/], closing the wikilink injection through {{podcast}}/{{safetitle}}/{{podcastlink}}.
  • Executable code blocks - feedHtmlToMarkdown renders injected Dataview fences (```dataviewjs / ```dataview, ~~~, in any container: top-level, blockquote, callout, or list item) inert. A 3+ fence run followed by the language token only ever occurs as a code-fence info string, so the container-agnostic rewrite is always safe.

Applied consistently across NoteTemplateEngine, FeedNoteTemplateEngine, and TranscriptTemplateEngine.

Deliberately preserved (not bugs)

  • Legitimate show-note rich text in {{description}}/{{content}} (links, images, ordinary code blocks) is the feature, so it is kept. A feed can still place an external image (readers can disable external-image loading in Obsidian) or a literal [[wikilink]] in this free-text body; unlike the structured tags these are accepted as show-note content.
  • Inline Dataview queries (`= `/`$= `) are a documented residual of the same speculative, Dataview-must-be-installed class - not rewritten to avoid mangling legitimate inline code in programming show notes.

Behavior-change note

Adding [/] to the file-name sanitizer also affects derived download/note paths: a title like Episode [Bonus] now yields Episode Bonus. Acceptable for the wikilink-injection fix.

Tests

New/updated unit tests in src/TemplateEngine.test.ts prove each malicious input is neutralized and legitimate input is preserved: artwork ![]() breakout, feed {{url}}/{{stream}} injection, raw {{title}} newline/Markdown injection, dataviewjs fences (incl. blockquote/callout, 4-space list indent, and long-fence-desync regressions from adversarial review), wikilink injection via podcastName, a feed-forged [[...]] url, and local-file wikilink passthrough.

Validation

  • Gates (Node 22): npm run lint, typecheck, build, test - all green (872 tests).
  • Adversarially self-reviewed with two subagents (one actively trying to bypass the guards). The fence-container bypasses they found (blockquote/callout, list-indent, fence-length desync) were fixed by switching to the container-agnostic regex; regression tests added.
  • Isolated worktree Obsidian vault: confirmed the plugin loads with the change (v2.17.2). Full end-to-end note creation for a synthetically injected current episode is not scriptable in this harness (a benign control episode fails identically - it is a harness limitation in injecting a store-backed episode, independent of this change); the real htmlToMarkdown output shape was validated against Obsidian's Turndown-based converter during review.

Do not merge yet - opening as draft for maintainer review.

Feed-controlled fields are written into generated notes by the template
engines without escaping, letting a malicious RSS feed inject Markdown,
external/tracking images, phishing links, Obsidian wikilinks, and
(speculatively) executable Dataview code blocks into a reader's vault.

- Escape raw {{title}}/{{author}} via sanitizeInlineText: collapse control
  characters to spaces (no line/block breakout from `# {{title}}`) and
  neutralize the link/image/inline-code/raw-HTML metacharacters, while
  keeping ordinary punctuation readable.
- Percent-encode all URL tags (artwork/stream/url/feedurl/feed*/episode*)
  via a broadened sanitizeUrlForTemplate so a crafted value cannot break
  out of `![](...)`/`[](...)`, inject a line, or terminate a quoted YAML
  scalar. {{url}}/{{episodeurl}} still pass a trusted local-file vault
  wikilink through verbatim (isLocalFile gate).
- Strip `[` and `]` in replaceIllegalFileNameCharactersInString so a feed
  <title> cannot inject a wikilink through {{podcast}}/{{safetitle}}/
  {{podcastlink}} (getFeedNoteWikilink).
- Render injected Dataview code fences (```dataviewjs/```dataview, ~~~,
  any blockquote/callout/list container) inert via feedHtmlToMarkdown.
  Legitimate show-note rich text (links, images, normal code blocks) is
  preserved by design.

Applied consistently across NoteTemplateEngine, FeedNoteTemplateEngine,
and TranscriptTemplateEngine. Adds unit tests proving each malicious input
is neutralized and legitimate input is preserved.

Fixes deepsec findings other-markdown-injection and other-wikilink-injection.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3f2ae1c
Status: ✅  Deploy successful!
Preview URL: https://b22d7db2.podnotes.pages.dev
Branch Preview URL: https://chhoumann-deepsec-note-injec.podnotes.pages.dev

View logs

@chhoumann chhoumann marked this pull request as ready for review June 29, 2026 12:36

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

ℹ️ 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/TemplateEngine.ts Outdated
Address PR #228 review (Codex P1): isLocalFile() alone keys off
podcastName === "local file", which the feed parser sets from the channel
<title>. A feed titled exactly "local file" would make NoteTemplateEngine /
TranscriptTemplateEngine treat its episode as a trusted local file and skip
sanitizeUrlForTemplate for {{url}}/{{episodeurl}}, bypassing the injection
fix on the bare {{url}} line.

Gate the verbatim passthrough on episode.filePath in addition to
isLocalFile: filePath is set only by getContextMenuHandler for genuine local
files and never by feedParser, so a feed cannot forge it. Factored into a
resolveEpisodeUrl helper shared by both engines. Adds a regression test for
the forged-"local file" feed title and updates local-file fixtures to carry
the realistic filePath.
@chhoumann chhoumann merged commit ef4ecbd into master Jun 29, 2026
3 checks passed
@chhoumann chhoumann deleted the chhoumann/deepsec-note-injection branch June 29, 2026 19:32
github-actions Bot pushed a commit that referenced this pull request Jul 9, 2026
## [2.17.3](2.17.2...2.17.3) (2026-07-09)

### Bug Fixes

* **feed/search:** parse feed once + content-based search cache ([#225](#225)) ([053d51f](053d51f)), closes [#149](#149)
* make episode identity key collision-resistant and prototype-safe ([#226](#226)) ([a5683db](a5683db))
* **opml:** correct import progress math and saved-count reporting ([#221](#221)) ([a79e529](a79e529))
* **security:** validate feed/URI URLs and cap download size ([#223](#223)) ([edef281](edef281))
* **template:** neutralize feed-controlled note injection ([#228](#228)) ([ef4ecbd](ef4ecbd))
* **timestamp:** escape live table-cell pipe after an escaped backslash ([#227](#227)) ([a34dfca](a34dfca))
* **transcription:** resolve three deepsec transcription-pipeline bugs ([#224](#224)) ([83c34e7](83c34e7))
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.17.3 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant