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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
below each term, preserving code blocks without requiring a definition-list extension.
Nested lists beginning with an empty item are separated from preceding text. Hard
breaks inside headings and link labels are flattened so they cannot close the
container before later inline code.
container before later inline code. Redundant nested bold, italic and
strikethrough wrappers no longer emit delimiters that close their outer span,
and adjacent bracketed nodes cannot combine with following text into a link.

- PDF stream guards honor escaped dictionary keys and bound compressed prefixes
before image filters, including when OCR is disabled.
Expand Down
Binary file not shown.
Binary file not shown.
29 changes: 25 additions & 4 deletions crates/markkit/src/render/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const MAX_TABLE_GRID_CELLS: usize = 1_000_000;
/// Span clamps applied at layout time (HTML limits; IR may come from anywhere).
const MAX_COLSPAN: usize = 1000;
const MAX_ROWSPAN: usize = 65534;
const WRAP_BOLD: u8 = 1;
const WRAP_ITALIC: u8 = 2;
const WRAP_STRIKETHROUGH: u8 = 4;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Trip {
Expand All @@ -53,6 +56,9 @@ pub struct MarkdownRenderer {
in_link: bool,
/// Rendering inline content that must stay on one Markdown line.
single_line: bool,
/// Active emphasis delimiters. Repeating the same delimiter would close the
/// outer span, so redundant nested wrappers are omitted.
active_wrappers: u8,
}

impl MarkdownRenderer {
Expand All @@ -66,6 +72,7 @@ impl MarkdownRenderer {
in_table: false,
in_link: false,
single_line: false,
active_wrappers: 0,
}
}

Expand All @@ -76,6 +83,7 @@ impl MarkdownRenderer {
self.in_table = false;
self.in_link = false;
self.single_line = false;
self.active_wrappers = 0;
self.render_nodes(&doc.nodes, 0);
normalize_output(&self.buf)
}
Expand Down Expand Up @@ -463,11 +471,18 @@ impl MarkdownRenderer {
} else {
Position::Inline
};
// A footnote reference followed by a new text node beginning
// with `(` would combine into an inline link (`[^x](...)`).
if self.buf.ends_with(']') && t.starts_with('(') {
self.buf.push('\\');
}
self.line_start = escape::push_text(&mut self.buf, t, pos);
}
Node::Bold { children } => self.render_wrapped("**", children),
Node::Italic { children } => self.render_wrapped("*", children),
Node::Strikethrough { children } => self.render_wrapped("~~", children),
Node::Bold { children } => self.render_wrapped("**", WRAP_BOLD, children),
Node::Italic { children } => self.render_wrapped("*", WRAP_ITALIC, children),
Node::Strikethrough { children } => {
self.render_wrapped("~~", WRAP_STRIKETHROUGH, children);
}
Node::InlineCode(code) => {
escape::push_code_span(&mut self.buf, code, self.in_table);
self.line_start = false;
Expand Down Expand Up @@ -553,11 +568,17 @@ impl MarkdownRenderer {
}
}

fn render_wrapped(&mut self, delim: &str, children: &[Node]) {
fn render_wrapped(&mut self, delim: &str, wrapper: u8, children: &[Node]) {
if self.active_wrappers & wrapper != 0 {
self.render_inline(children);
return;
}
self.active_wrappers |= wrapper;
self.buf.push_str(delim);
self.line_start = false;
self.render_inline(children);
self.buf.push_str(delim);
self.active_wrappers &= !wrapper;
}

/// Render a block node as single-line inline content.
Expand Down
2 changes: 1 addition & 1 deletion crates/markkit/tests/fixtures/html/malformed.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ Next paragraph

Mismatched **bold with no close**

**Entities: & \< > " ' Nested *italic **and bold***** crossed** **
**Entities: & \< > " ' Nested *italic and bold* crossed **
67 changes: 67 additions & 0 deletions crates/markkit/tests/output_safety.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,73 @@ fn blockquote_heading_inline_blocks_cannot_expose_html() {
assert!(visible_text(&md).contains("<<<!t``"), "{md}");
}

#[test]
fn repeated_emphasis_wrappers_cannot_expose_inline_blocks() {
let md = render(vec![Node::Heading {
source: None,
level: 4,
children: vec![Node::Bold {
children: vec![
Node::Heading {
source: None,
level: 6,
children: vec![
text(" "),
Node::FootnoteReference {
label: "\u{1f}".into(),
},
text("(<<0<w"),
Node::Bold {
children: vec![Node::CodeBlock {
language: None,
code: ")<<!E<<<<".into(),
source: None,
}],
},
],
},
Node::Image {
src: String::new(),
alt: String::new(),
},
Node::Heading {
source: None,
level: 1,
children: vec![],
},
],
}],
}]);
assert_safe(&md);
assert!(visible_text(&md).contains(")<<!E<<<<"), "{md}");
}

#[test]
fn footnote_reference_cannot_become_inline_link() {
let md = render(vec![para(vec![
Node::FootnoteReference { label: "x".into() },
text("(<<0<w"),
Node::Bold {
children: vec![
Node::CodeBlock {
language: None,
code: ")\u{7f}\0\0\0\0\0\0\u{13}<!t".into(),
source: None,
},
Node::FootnoteReference {
label: String::new(),
},
Node::Image {
src: String::new(),
alt: String::new(),
},
],
},
])]);
assert_safe(&md);
assert!(visible_text(&md).contains("<!t"), "{md}");
}

#[test]
fn empty_destination_cannot_swallow_title() {
let md = render(vec![para(vec![Node::Link {
Expand Down
4 changes: 4 additions & 0 deletions knowledge/foundations/ir.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ At render time, the `normalize_output` function:
- Inline content concatenated directly
- Hard breaks are preserved in paragraphs and flattened to spaces in headings,
link labels, tables, and block nodes rendered in inline context.
- Redundant nested bold, italic, and strikethrough wrappers render once so their
delimiters cannot close an enclosing span.
- Text beginning with `(` is escaped after a preceding `]` so separately rendered
nodes, including footnote references, cannot combine into an inline link.
- List item bodies (continuation lines, nested lists, sub-blocks) indented by the marker
width (`- ` = 2, `1. ` = 3)
- BlockQuote lines prefixed with `> `
Expand Down
2 changes: 1 addition & 1 deletion knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## 2026-10-31

* **Container fence safety**: task checkboxes render on their own line; definition bodies use ordinary GFM list items below their term. This prevents markers from consuming code fences and exposing raw HTML, including empty leading paragraphs and nested definitions. A nested list with an empty first item also requires a blank line before it; the renderer checks actual output once, within the remaining output budget. Headings, link labels and block nodes flattened into inline content now collapse hard breaks to spaces, preventing a break from closing the container before later inline code. Exact nightly crash seeds and content/grouping regressions cover these paths. See [Threat Model](security/threat-model.md) T1 and [IR Specification](foundations/ir.md).
* **Container fence safety**: task checkboxes render on their own line; definition bodies use ordinary GFM list items below their term. This prevents markers from consuming code fences and exposing raw HTML, including empty leading paragraphs and nested definitions. A nested list with an empty first item also requires a blank line before it; the renderer checks actual output once, within the remaining output budget. Headings, link labels and block nodes flattened into inline content collapse hard breaks to spaces, preventing a break from closing the container before later inline code. Redundant nested bold, italic and strikethrough wrappers render once so an inner delimiter cannot close its outer span. A bracketed node followed by text beginning with `(` is separated so it cannot form an unintended inline link. Exact nightly crash seeds and content/grouping regressions cover these paths. See [Threat Model](security/threat-model.md) T1 and [IR Specification](foundations/ir.md).

## 2026-10-28

Expand Down
5 changes: 4 additions & 1 deletion knowledge/security/fuzzing.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ the Actions cache; crashes upload as `fuzz-crashes-<target>` artifacts.
links, adjacent code spans merging, and code fences consumed as paragraph
text after task-checkbox, nested definition markers, or a nested ordered list
with an empty first item. A hard break inside a link label nested in a heading
also closed both containers before later inline code. Exact crash seeds for
also closed both containers before later inline code; repeating a bold wrapper
inside bold closed the outer span with the same result. A footnote reference
followed by text beginning with `(` could also combine into an inline link and
consume later delimiters. Exact crash seeds for
these cases are retained in `fuzz/seeds/render_ir/` and `fuzz/seeds/markdown/`.
- XLS (calamine 0.37): unchecked reads in FILEPASS, CODEPAGE, DATEMODE, BOF,
BOUNDSHEET, LBL (incl. defined-name tokens), EXTERNSHEET, SST (cRun /
Expand Down
5 changes: 3 additions & 2 deletions knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ that renders as headings, links, code fences or tables).
inside headings and link labels; list item bodies
are indented by the marker width (`1. ` = 3, `10. ` = 4); a block always starts
on its own line after a blank line; hard breaks never produce a blank line;
nested links render as text; empty destinations are written `<>`; link titles
escape `"`, `\` and `<`.
nested links render as text; redundant nested emphasis wrappers render once;
adjacent bracketed nodes and opening parentheses are separated; empty
destinations are written `<>`; link titles escape `"`, `\` and `<`.
- Task checkboxes occupy their own line before the item body so they cannot
turn a leading code fence into paragraph text. Definition lists render as
a term paragraph and ordinary GFM list items, keeping nested blocks safe
Expand Down
Loading