From e3aa66bd68fe3c324928d54a1c003571337a9a52 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 28 Oct 2026 21:33:58 +0000 Subject: [PATCH] fix(render): prevent footnotes from creating unsafe reference links --- CHANGELOG.md | 6 +++++ crates/markkit/src/markdown.rs | 2 +- crates/markkit/src/render/markdown.rs | 5 +++- crates/markkit/tests/html_integration.rs | 7 +++-- crates/markkit/tests/output_safety.rs | 34 ++++++++++++++++++++++++ knowledge/log.md | 4 +++ knowledge/security/threat-model.md | 2 +- 7 files changed, 55 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bfad2c..d235fd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- Footnote bodies cannot become unsafe reference-link destinations in Markdown readers + without footnote support. Bodies start after a blank line and remain valid footnotes + in readers that support them (nightly renderer fuzz regression). + ### Added - `markkit_qa` LLM eval (`just eval-llm`): models answer 23 questions about the eval pages from diff --git a/crates/markkit/src/markdown.rs b/crates/markkit/src/markdown.rs index 405d926..c04e9e8 100644 --- a/crates/markkit/src/markdown.rs +++ b/crates/markkit/src/markdown.rs @@ -618,7 +618,7 @@ mod tests { assert!(out.contains("| :--- | :---: | ---: |"), "{out}"); assert!(out.contains("- [x] done"), "{out}"); assert!(out.contains("Note[^n]."), "{out}"); - assert!(out.contains("[^n]: The note."), "{out}"); + assert!(out.contains("[^n]:\n\n The note."), "{out}"); } #[test] diff --git a/crates/markkit/src/render/markdown.rs b/crates/markkit/src/render/markdown.rs index f28ef8e..9be3c9b 100644 --- a/crates/markkit/src/render/markdown.rs +++ b/crates/markkit/src/render/markdown.rs @@ -275,7 +275,10 @@ impl MarkdownRenderer { self.buf.push_str("[^"); self.buf.push_str(&footnote_label(label)); self.buf.push_str("]:"); - push_hanging(&mut self.buf, &inner, " ", " "); + // A blank line prevents readers without footnote support from + // treating the body as a reference-link destination (S17). + self.buf.push_str("\n\n"); + push_hanging(&mut self.buf, &inner, " ", " "); } // Inline nodes rendered at block level get wrapped in a line diff --git a/crates/markkit/tests/html_integration.rs b/crates/markkit/tests/html_integration.rs index 1d65a47..c35f625 100644 --- a/crates/markkit/tests/html_integration.rs +++ b/crates/markkit/tests/html_integration.rs @@ -826,7 +826,7 @@ fn footnotes_github_markup() {

Footnotes

  1. Source. ↩

"##; - assert_eq!(convert(html), "Claim[^1].\n\n[^1]: Source.\n"); + assert_eq!(convert(html), "Claim[^1].\n\n[^1]:\n\n Source.\n"); } #[test] @@ -835,7 +835,10 @@ fn footnotes_pandoc_markup() {

  1. First.

    Second.↩︎

"##; - assert_eq!(convert(html), "Text[^a]\n\n[^a]: First.\n\n Second.\n"); + assert_eq!( + convert(html), + "Text[^a]\n\n[^a]:\n\n First.\n\n Second.\n" + ); } #[test] diff --git a/crates/markkit/tests/output_safety.rs b/crates/markkit/tests/output_safety.rs index 7c712fd..feb0f60 100644 --- a/crates/markkit/tests/output_safety.rs +++ b/crates/markkit/tests/output_safety.rs @@ -80,6 +80,40 @@ fn block_tags(md: &str) -> Vec { // ─── Text escaping ─────────────────────────────────────────────────── +#[test] +fn footnote_body_cannot_become_reference_link_destination() { + for body in ["javascript:alert(1)", "vbscript:run", "file:///etc/passwd"] { + let md = render(vec![ + Node::paragraph(vec![Node::FootnoteReference { + label: "note".into(), + }]), + Node::FootnoteDefinition { + label: "note".into(), + children: vec![para(vec![text(body)])], + source: None, + }, + ]); + assert_safe(&md); + assert!( + events(&md) + .iter() + .all(|event| !matches!(event, Event::Start(Tag::Link { .. }))) + ); + let footnotes = Options::ENABLE_FOOTNOTES; + let enabled: Vec<_> = Parser::new_ext(&md, footnotes).collect(); + assert!( + enabled + .iter() + .any(|event| matches!(event, Event::Start(Tag::FootnoteDefinition(_)))) + ); + assert!( + enabled + .iter() + .any(|event| matches!(event, Event::Text(value) if value.contains(body))) + ); + } +} + #[test] fn text_with_markdown_syntax_stays_text() { let s = "# not heading *x* _y_ `z` ~~w~~ [t](u) ![i](j) "; diff --git a/knowledge/log.md b/knowledge/log.md index 7c8a573..2ac99c6 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -1,5 +1,9 @@ # Markkit Knowledge Update Log +## 2026-10-28 + +* **Footnote safety**: a blank line separates footnote markers from their indented bodies, preventing Markdown readers without footnotes from treating body text as a reference-link destination. Fixes the nightly renderer fuzz failure; regression checks both reader modes. See [Threat Model](security/threat-model.md) S17. + ## 2026-10-27 * **Embedder API (#11)**: `Markkit` derives `Clone` + `Debug`; warnings are structured (`Warning { code: WarningCode, detail }`) instead of `"code: detail"` strings. The agent tool keeps string warnings. See [Format Support](formats/format-support.md) and [Tool Contract](integrations/tool-contract.md). diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 22990e9..2abca6c 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -563,7 +563,7 @@ shared renderer under `max_output_size` (`tests/registry.rs`). | S14 | Returned Markdown never exceeds `max_output_size` | `limits::*output_cap*` | | S15 | No configuration of `max_nesting_depth` can overflow the stack | `limits::*hard_ceiling_survives_small_stack` | | S16 | Extraction stops with `ResourceLimit` once `timeout` passes (checked at least after each non-interruptible third-party step) | `limits::*zero_timeout_rejects`, `limits::tests::*` | -| S17 | Rendered markdown has no raw HTML, no non-allowlisted link/image scheme, and document text cannot create Markdown structure | `output_safety::*` (pulldown-cmark oracle + proptests) | +| S17 | Rendered markdown has no raw HTML, no non-allowlisted link/image scheme, and document text cannot create Markdown structure; footnote bodies are separated by a blank line so non-footnote readers cannot interpret them as reference destinations | `output_safety::*`, including `footnote_body_cannot_become_reference_link_destination` (both reader modes), pulldown-cmark oracle + proptests | | S18 | Text, Markdown, CSV/TSV, notebook and email output never contains C0 controls (except tab/newline); Markdown raw HTML (blocks, inline tags) is never copied | `text_integration::security_*`, `csv_integration::security_*`, `notebook_integration::security_*`, `email_integration::security_*` | | S19 | No PDF stream is decoded past `max_pdf_stream_size`, by markkit or hayro | `pdf_security::*bomb*`, `pdf_security::chained_filters_are_measured_after_every_stage` | | S20 | Hidden content is not rendered by default (HTML, DOCX, ODT, EPUB) | `hidden_content::*`, `odt_integration::security_hidden_content_skipped_by_default`, `epub_integration::security_hidden_content_skipped_by_default` |