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() {
"##;
- 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() {
"##;
- 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)  ";
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` |