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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/markkit/src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 4 additions & 1 deletion crates/markkit/src/render/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions crates/markkit/tests/html_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -826,7 +826,7 @@ fn footnotes_github_markup() {
<section data-footnotes class="footnotes"><h2 class="sr-only">Footnotes</h2>
<ol><li id="user-content-fn-1"><p>Source. <a href="#user-content-fnref-1" data-footnote-backref class="data-footnote-backref">↩</a></p></li></ol>
</section>"##;
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]
Expand All @@ -835,7 +835,10 @@ fn footnotes_pandoc_markup() {
<section id="footnotes" class="footnotes footnotes-end-of-document" role="doc-endnotes"><hr />
<ol><li id="fn1"><p>First.</p><p>Second.<a href="#fnref1" class="footnote-back" role="doc-backlink">↩︎</a></p></li></ol>
</section>"##;
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]
Expand Down
34 changes: 34 additions & 0 deletions crates/markkit/tests/output_safety.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,40 @@ fn block_tags(md: &str) -> Vec<String> {

// ─── 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) <b>";
Expand Down
4 changes: 4 additions & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
2 changes: 1 addition & 1 deletion knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading