| 1 |
1 |
|
use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, TagEnd, html};
|
| 2 |
2 |
|
|
| 3 |
3 |
|
use crate::sanitize::SanitizePreset;
|
|
4 |
+ |
use crate::toc::AnchorGen;
|
| 4 |
5 |
|
|
| 5 |
6 |
|
/// Returns true if the URL uses a scheme not in the safe allowlist.
|
| 6 |
7 |
|
///
|
| 40 |
41 |
|
strip_images: bool,
|
| 41 |
42 |
|
strip_raw_html: bool,
|
| 42 |
43 |
|
dangerous_scheme_filter: bool,
|
|
44 |
+ |
heading_ids: bool,
|
| 43 |
45 |
|
sanitize: SanitizePreset,
|
| 44 |
46 |
|
}
|
| 45 |
47 |
|
|
|
48 |
+ |
/// Give every heading the anchor [`extract_toc`](crate::extract_toc) will point
|
|
49 |
+ |
/// at, so a rendered TOC actually navigates.
|
|
50 |
+ |
///
|
|
51 |
+ |
/// `id` is assigned in document order through the same [`AnchorGen`] the TOC
|
|
52 |
+ |
/// uses, which is what keeps the two in agreement — including the `-1`, `-2`
|
|
53 |
+ |
/// suffixes on repeated headings.
|
|
54 |
+ |
fn inject_heading_ids(mut events: Vec<Event<'_>>) -> Vec<Event<'_>> {
|
|
55 |
+ |
let mut anchors = AnchorGen::default();
|
|
56 |
+ |
let mut i = 0;
|
|
57 |
+ |
while i < events.len() {
|
|
58 |
+ |
if !matches!(events[i], Event::Start(Tag::Heading { .. })) {
|
|
59 |
+ |
i += 1;
|
|
60 |
+ |
continue;
|
|
61 |
+ |
}
|
|
62 |
+ |
// Heading text is only known once its inner events have been seen.
|
|
63 |
+ |
let mut text = String::new();
|
|
64 |
+ |
let mut j = i + 1;
|
|
65 |
+ |
while j < events.len() {
|
|
66 |
+ |
match &events[j] {
|
|
67 |
+ |
Event::End(TagEnd::Heading(_)) => break,
|
|
68 |
+ |
Event::Text(t) => text.push_str(t),
|
|
69 |
+ |
Event::Code(c) => text.push_str(c),
|
|
70 |
+ |
_ => {}
|
|
71 |
+ |
}
|
|
72 |
+ |
j += 1;
|
|
73 |
+ |
}
|
|
74 |
+ |
let anchor = anchors.next(&text);
|
|
75 |
+ |
if let Event::Start(Tag::Heading { id, .. }) = &mut events[i] {
|
|
76 |
+ |
// Always None in practice: ENABLE_HEADING_ATTRIBUTES is never set,
|
|
77 |
+ |
// so markdown cannot carry its own `{#id}`.
|
|
78 |
+ |
*id = Some(CowStr::Boxed(anchor.into_boxed_str()));
|
|
79 |
+ |
}
|
|
80 |
+ |
i = j + 1;
|
|
81 |
+ |
}
|
|
82 |
+ |
events
|
|
83 |
+ |
}
|
|
84 |
+ |
|
| 46 |
85 |
|
impl Renderer {
|
| 47 |
86 |
|
/// GFM features, default ammonia sanitization. Suitable for trusted content
|
| 48 |
87 |
|
/// like docs and blog posts.
|
| 56 |
95 |
|
strip_images: false,
|
| 57 |
96 |
|
strip_raw_html: false,
|
| 58 |
97 |
|
dangerous_scheme_filter: false,
|
|
98 |
+ |
heading_ids: false,
|
| 59 |
99 |
|
sanitize: SanitizePreset::Permissive,
|
| 60 |
100 |
|
}
|
| 61 |
101 |
|
}
|
| 72 |
112 |
|
strip_images: true,
|
| 73 |
113 |
|
strip_raw_html: false,
|
| 74 |
114 |
|
dangerous_scheme_filter: false,
|
|
115 |
+ |
heading_ids: false,
|
| 75 |
116 |
|
sanitize: SanitizePreset::Standard,
|
| 76 |
117 |
|
}
|
| 77 |
118 |
|
}
|
| 88 |
129 |
|
strip_images: true,
|
| 89 |
130 |
|
strip_raw_html: true,
|
| 90 |
131 |
|
dangerous_scheme_filter: true,
|
|
132 |
+ |
heading_ids: false,
|
| 91 |
133 |
|
sanitize: SanitizePreset::Strict,
|
| 92 |
134 |
|
}
|
| 93 |
135 |
|
}
|
| 104 |
146 |
|
strip_images: false,
|
| 105 |
147 |
|
strip_raw_html: false,
|
| 106 |
148 |
|
dangerous_scheme_filter: false,
|
|
149 |
+ |
heading_ids: false,
|
| 107 |
150 |
|
sanitize: SanitizePreset::Permissive,
|
| 108 |
151 |
|
}
|
| 109 |
152 |
|
}
|
| 153 |
196 |
|
self
|
| 154 |
197 |
|
}
|
| 155 |
198 |
|
|
|
199 |
+ |
/// Emit `id` on every heading, matching the anchors [`extract_toc`] emits,
|
|
200 |
+ |
/// so a rendered table of contents navigates instead of going nowhere.
|
|
201 |
+ |
///
|
|
202 |
+ |
/// Off by default, and deliberately opt-in: it is the only setting that
|
|
203 |
+ |
/// lets an `id` attribute survive sanitization, and an attacker-chosen `id`
|
|
204 |
+ |
/// on a page is a DOM-clobbering primitive (an element whose `id` shadows a
|
|
205 |
+ |
/// global the page's own script reads). Generated anchors are slugified to
|
|
206 |
+ |
/// alphanumerics, hyphens, and underscores, but raw-HTML headings in the
|
|
207 |
+ |
/// source can also carry an `id` once this is on. Enable it for trusted
|
|
208 |
+ |
/// content — docs, your own long-form — not for arbitrary UGC.
|
|
209 |
+ |
///
|
|
210 |
+ |
/// [`extract_toc`]: crate::extract_toc
|
|
211 |
+ |
pub fn with_heading_ids(mut self, enabled: bool) -> Self {
|
|
212 |
+ |
self.heading_ids = enabled;
|
|
213 |
+ |
self
|
|
214 |
+ |
}
|
|
215 |
+ |
|
| 156 |
216 |
|
fn build_options(&self) -> Options {
|
| 157 |
217 |
|
let mut opts = Options::empty();
|
| 158 |
218 |
|
if self.tables {
|
| 179 |
239 |
|
return String::new();
|
| 180 |
240 |
|
}
|
| 181 |
241 |
|
let html_output = self.render_raw(input);
|
| 182 |
|
- |
self.sanitize.clean(&html_output)
|
|
242 |
+ |
self.sanitize.clean_with(&html_output, self.heading_ids)
|
| 183 |
243 |
|
}
|
| 184 |
244 |
|
|
| 185 |
245 |
|
/// Render markdown to sanitized HTML with metadata.
|
| 229 |
289 |
|
});
|
| 230 |
290 |
|
|
| 231 |
291 |
|
let mut output = String::new();
|
| 232 |
|
- |
html::push_html(&mut output, filtered);
|
|
292 |
+ |
if self.heading_ids {
|
|
293 |
+ |
// Buffering is required: a heading's anchor depends on text that
|
|
294 |
+ |
// only arrives after its Start event.
|
|
295 |
+ |
html::push_html(&mut output, inject_heading_ids(filtered.collect()).into_iter());
|
|
296 |
+ |
} else {
|
|
297 |
+ |
html::push_html(&mut output, filtered);
|
|
298 |
+ |
}
|
| 233 |
299 |
|
output
|
| 234 |
300 |
|
}
|
| 235 |
301 |
|
}
|
| 535 |
601 |
|
fn result_has_rel(html: &str, rel_value: &str) -> bool {
|
| 536 |
602 |
|
html.contains(rel_value)
|
| 537 |
603 |
|
}
|
|
604 |
+ |
|
|
605 |
+ |
// ===== heading ids =====
|
|
606 |
+ |
|
|
607 |
+ |
#[test]
|
|
608 |
+ |
fn heading_ids_are_off_by_default() {
|
|
609 |
+ |
assert!(Renderer::permissive().render("## Section").contains("<h2>Section</h2>"));
|
|
610 |
+ |
assert!(!Renderer::permissive().render("## Section").contains("id="));
|
|
611 |
+ |
}
|
|
612 |
+ |
|
|
613 |
+ |
#[test]
|
|
614 |
+ |
fn heading_ids_survive_sanitization_when_enabled() {
|
|
615 |
+ |
// Ammonia strips `id` by default, so this asserts the sanitizer opt-in
|
|
616 |
+ |
// is wired, not just the event injection.
|
|
617 |
+ |
let html = Renderer::permissive()
|
|
618 |
+ |
.with_heading_ids(true)
|
|
619 |
+ |
.render("## Section Title");
|
|
620 |
+ |
assert!(html.contains(r#"<h2 id="section-title">"#), "got: {html}");
|
|
621 |
+ |
}
|
|
622 |
+ |
|
|
623 |
+ |
#[test]
|
|
624 |
+ |
fn every_toc_anchor_has_a_matching_heading_id() {
|
|
625 |
+ |
// The bug this fixes: TOC emitted href="#anchor" and nothing on the
|
|
626 |
+ |
// page carried that id. Pin the two together, repeats included.
|
|
627 |
+ |
let md = "# Guide\n\n## Setup\n\n### Notes\n\n## Usage\n\n### Notes\n\n## render_raw";
|
|
628 |
+ |
let html = Renderer::permissive().with_heading_ids(true).render(md);
|
|
629 |
+ |
let toc = crate::extract_toc(md);
|
|
630 |
+ |
assert_eq!(toc.len(), 6);
|
|
631 |
+ |
for entry in &toc {
|
|
632 |
+ |
assert!(
|
|
633 |
+ |
html.contains(&format!(r#"id="{}""#, entry.anchor)),
|
|
634 |
+ |
"TOC points at #{} but no heading carries it: {html}",
|
|
635 |
+ |
entry.anchor
|
|
636 |
+ |
);
|
|
637 |
+ |
}
|
|
638 |
+ |
}
|
|
639 |
+ |
|
|
640 |
+ |
#[test]
|
|
641 |
+ |
fn heading_ids_enabled_on_other_presets_too() {
|
|
642 |
+ |
let html = Renderer::strict().with_heading_ids(true).render("## Section");
|
|
643 |
+ |
assert!(html.contains(r#"id="section""#), "got: {html}");
|
|
644 |
+ |
}
|
|
645 |
+ |
|
|
646 |
+ |
#[test]
|
|
647 |
+ |
fn heading_ids_do_not_open_other_attributes() {
|
|
648 |
+ |
// The opt-in must widen the sanitizer for `id` on headings only.
|
|
649 |
+ |
let html = Renderer::permissive()
|
|
650 |
+ |
.with_heading_ids(true)
|
|
651 |
+ |
.render(r#"<h2 id="ok" onclick="evil()" class="x">T</h2>"#);
|
|
652 |
+ |
assert!(!html.contains("onclick"), "event handler must stay stripped: {html}");
|
|
653 |
+ |
let html = Renderer::permissive()
|
|
654 |
+ |
.with_heading_ids(true)
|
|
655 |
+ |
.render(r#"<p id="para">text</p>"#);
|
|
656 |
+ |
assert!(!html.contains(r#"id="para""#), "id on non-headings stays stripped: {html}");
|
|
657 |
+ |
}
|
| 538 |
658 |
|
}
|