25.5 KBRaw
| 1 | //! Rendering markdown, for READMEs and later for posts. |
| 2 | //! |
| 3 | //! **Raw HTML in the source is never passed through.** Today only a repository's owner |
| 4 | //! can push to it, so a README is trusted content — but Milestone 7 adds other users, |
| 5 | //! and markdown rendered with HTML passthrough on the same origin as the session |
| 6 | //! cookie is stored XSS. Turning it off now costs nothing; retrofitting a sanitiser |
| 7 | //! later is a security migration. Do not "improve" this back on. |
| 8 | //! |
| 9 | //! The guarantee is structural rather than a filter: this module walks the parser's |
| 10 | //! event stream and writes the HTML itself, so the set of tags that can reach a page is |
| 11 | //! exactly the set spelled out in [`Writer`]. A `<script>` in the source is not |
| 12 | //! stripped — it arrives as [`Event::Html`] and is written as *text*, so a reader sees |
| 13 | //! what was written and a browser sees nothing to execute. |
| 14 | //! |
| 15 | //! Writing the HTML by hand rather than calling `pulldown_cmark::html::push_html` is |
| 16 | //! also forced: `pulldown-cmark` is depended on with `default-features = false`, which |
| 17 | //! turns its `html` module off. Escaping is not hand-rolled — it goes through |
| 18 | //! [`HtmlContext`], the same escaper the `view!` macro writes every dynamic value |
| 19 | //! through. |
| 20 | //! |
| 21 | //! The output is [`Unescaped`], which is how it reaches a `view!`: everything in a view |
| 22 | //! is escaped by default, and this is the deliberate hatch. That hatch is exactly why |
| 23 | //! raw HTML in the source has to be off — the two decisions are one decision. |
| 24 | |
| 25 | use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; |
| 26 | use topcoat::view::{Formatter, HtmlContext, Unescaped}; |
| 27 | |
| 28 | /// URL schemes a link or image may use. |
| 29 | /// |
| 30 | /// An allowlist, not a `javascript:` denylist: a denylist has to anticipate every |
| 31 | /// scripting scheme (`vbscript:`, `data:text/html`, whatever a browser adds next), |
| 32 | /// while an allowlist only has to name the ones that are useful in prose. |
| 33 | const ALLOWED_SCHEMES: &[&str] = &["http", "https", "mailto", "ftp", "ftps", "tel"]; |
| 34 | |
| 35 | /// Renders markdown to HTML, leaving relative links exactly as written. |
| 36 | pub fn render(source: &str) -> Unescaped<String> { |
| 37 | render_with_links(source, |_| None) |
| 38 | } |
| 39 | |
| 40 | /// Renders markdown, giving `resolve` a chance to rewrite relative links. |
| 41 | /// |
| 42 | /// `resolve` is called only for a destination that is relative — no scheme, and not |
| 43 | /// starting with `/` or `#` — and returning `None` leaves it untouched. It is a |
| 44 | /// parameter rather than something this module knows how to do because what a relative |
| 45 | /// link means depends on where the markdown came from: inside a repository it is |
| 46 | /// another file in the tree, and in a post it will be something else. |
| 47 | /// |
| 48 | /// Resolution happens *before* the scheme check, so a resolver cannot hand back a |
| 49 | /// `javascript:` URL and have it reach the page. |
| 50 | pub fn render_with_links( |
| 51 | source: &str, |
| 52 | resolve: impl Fn(&str) -> Option<String>, |
| 53 | ) -> Unescaped<String> { |
| 54 | let mut buf = String::with_capacity(source.len()); |
| 55 | let mut writer = Writer::new(&mut buf, resolve); |
| 56 | |
| 57 | for event in Parser::new_ext(source, options()) { |
| 58 | writer.event(event); |
| 59 | } |
| 60 | |
| 61 | Unescaped::new_unchecked(buf) |
| 62 | } |
| 63 | |
| 64 | /// The dialect Steid understands. |
| 65 | /// |
| 66 | /// Tables, strikethrough, task lists and footnotes are the parts of GitHub-flavoured |
| 67 | /// markdown a README actually uses. Smart punctuation is deliberately *off*: it turns |
| 68 | /// `--flag` into an en dash, which silently corrupts command-line flags written in |
| 69 | /// prose — the exact thing a README is full of. |
| 70 | fn options() -> Options { |
| 71 | Options::ENABLE_TABLES |
| 72 | | Options::ENABLE_STRIKETHROUGH |
| 73 | | Options::ENABLE_TASKLISTS |
| 74 | | Options::ENABLE_FOOTNOTES |
| 75 | } |
| 76 | |
| 77 | /// What a link destination turned out to be. |
| 78 | enum Destination { |
| 79 | /// Safe to put in an `href` or `src`. |
| 80 | Allowed(String), |
| 81 | /// A scheme that is not in [`ALLOWED_SCHEMES`]. The attribute is dropped entirely. |
| 82 | Refused, |
| 83 | } |
| 84 | |
| 85 | /// Decides whether a destination may appear in an attribute, and in what form. |
| 86 | /// |
| 87 | /// `resolve_relative` is off for an image: a relative image points at a file that has |
| 88 | /// to be served as its own bytes, and rewriting it to a page URL would produce a |
| 89 | /// broken image rather than a working one. |
| 90 | fn destination( |
| 91 | raw: &str, |
| 92 | resolve_relative: bool, |
| 93 | resolve: &impl Fn(&str) -> Option<String>, |
| 94 | ) -> Destination { |
| 95 | // Browsers strip ASCII control characters — tab, newline, carriage return among |
| 96 | // them — out of a URL before parsing it, so `java	script:alert(1)` is a |
| 97 | // `javascript:` URL by the time it matters. Judge the stripped form *and* emit it, |
| 98 | // or the check and the browser would be looking at different strings. Interior |
| 99 | // spaces are left alone: they are legal in a `<...>` destination and are not a way |
| 100 | // to hide a scheme, since a scheme cannot contain one. |
| 101 | let cleaned: String = raw |
| 102 | .trim() |
| 103 | .chars() |
| 104 | .filter(|c| !c.is_ascii_control()) |
| 105 | .collect(); |
| 106 | |
| 107 | let resolved = match scheme_of(&cleaned) { |
| 108 | Some(_) => cleaned, |
| 109 | // No scheme. An absolute path and a fragment already point at this origin; only |
| 110 | // a genuinely relative destination is the caller's to reinterpret. |
| 111 | None if cleaned.starts_with('/') || cleaned.starts_with('#') || cleaned.is_empty() => { |
| 112 | cleaned |
| 113 | } |
| 114 | None if resolve_relative => resolve(&cleaned).unwrap_or(cleaned), |
| 115 | None => cleaned, |
| 116 | }; |
| 117 | |
| 118 | match scheme_of(&resolved) { |
| 119 | Some(scheme) if !ALLOWED_SCHEMES.contains(&scheme.as_str()) => Destination::Refused, |
| 120 | _ => Destination::Allowed(resolved), |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | /// The URL's scheme, lowercased, or `None` when it has none. |
| 125 | /// |
| 126 | /// A colon only introduces a scheme when it comes before any `/`, `?` or `#` and what |
| 127 | /// precedes it is a legal scheme name — otherwise `docs/a:b.md` and `#a:b` would read |
| 128 | /// as schemes and be refused. |
| 129 | fn scheme_of(url: &str) -> Option<String> { |
| 130 | let end = url.find([':', '/', '?', '#'])?; |
| 131 | |
| 132 | if url.as_bytes()[end] != b':' { |
| 133 | return None; |
| 134 | } |
| 135 | |
| 136 | let scheme = &url[..end]; |
| 137 | let mut chars = scheme.chars(); |
| 138 | |
| 139 | if !chars.next()?.is_ascii_alphabetic() { |
| 140 | return None; |
| 141 | } |
| 142 | if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) { |
| 143 | return None; |
| 144 | } |
| 145 | |
| 146 | Some(scheme.to_ascii_lowercase()) |
| 147 | } |
| 148 | |
| 149 | /// An image being assembled. |
| 150 | /// |
| 151 | /// An image's alt text arrives as the events *between* its start and end tags, so it |
| 152 | /// has to be collected before the `<img>` can be written at all. |
| 153 | struct Image { |
| 154 | destination: Destination, |
| 155 | title: String, |
| 156 | alt: String, |
| 157 | } |
| 158 | |
| 159 | /// Writes an event stream out as HTML. |
| 160 | /// |
| 161 | /// Every tag this can emit is written literally below, which is what makes "raw HTML |
| 162 | /// cannot reach the page" a property of the code rather than of a filter that has to be |
| 163 | /// kept correct. The classes are Tailwind utilities in theme tokens only — a hardcoded |
| 164 | /// colour would follow neither a palette change nor the colour scheme — and they live |
| 165 | /// here as literals so Tailwind's scan of `./src/**/*.rs` finds them. |
| 166 | struct Writer<'a, F> { |
| 167 | out: Formatter<'a>, |
| 168 | resolve: F, |
| 169 | image: Option<Image>, |
| 170 | alignments: Vec<Alignment>, |
| 171 | column: usize, |
| 172 | in_head: bool, |
| 173 | } |
| 174 | |
| 175 | impl<'a, F: Fn(&str) -> Option<String>> Writer<'a, F> { |
| 176 | fn new(buf: &'a mut String, resolve: F) -> Self { |
| 177 | Self { |
| 178 | out: Formatter::new(buf), |
| 179 | resolve, |
| 180 | image: None, |
| 181 | alignments: Vec::new(), |
| 182 | column: 0, |
| 183 | in_head: false, |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | /// Markup this module chose. Never anything derived from the source. |
| 188 | fn raw(&mut self, markup: &str) { |
| 189 | self.out.write_str(markup); |
| 190 | } |
| 191 | |
| 192 | /// Content from the source, as a text node — or as alt text while inside an image. |
| 193 | fn text(&mut self, value: &str) { |
| 194 | if let Some(image) = self.image.as_mut() { |
| 195 | image.alt.push_str(value); |
| 196 | return; |
| 197 | } |
| 198 | |
| 199 | HtmlContext::Text.writer(&mut self.out).write_str(value); |
| 200 | } |
| 201 | |
| 202 | /// Content from the source, inside a double-quoted attribute value. |
| 203 | fn attribute(&mut self, name: &str, value: &str) { |
| 204 | self.out.write_str(" "); |
| 205 | self.out.write_str(name); |
| 206 | self.out.write_str("=\""); |
| 207 | HtmlContext::AttributeValue |
| 208 | .writer(&mut self.out) |
| 209 | .write_str(value); |
| 210 | self.out.write_str("\""); |
| 211 | } |
| 212 | |
| 213 | fn event(&mut self, event: Event<'_>) { |
| 214 | match event { |
| 215 | Event::Start(tag) => self.start(tag), |
| 216 | Event::End(tag) => self.end(tag), |
| 217 | Event::Text(text) => self.text(&text), |
| 218 | Event::Code(code) => { |
| 219 | self.raw( |
| 220 | "<code class=\"rounded border border-border bg-surface px-1 py-0.5 font-mono text-[0.85em]\">", |
| 221 | ); |
| 222 | self.text(&code); |
| 223 | self.raw("</code>"); |
| 224 | } |
| 225 | // The security-critical case, and the reason this module exists in this |
| 226 | // shape. HTML in the source is written as text: visible, inert, and |
| 227 | // honest about what the author wrote. |
| 228 | Event::Html(html) | Event::InlineHtml(html) => self.text(&html), |
| 229 | Event::SoftBreak => self.raw("\n"), |
| 230 | Event::HardBreak => self.raw("<br />"), |
| 231 | Event::Rule => self.raw("<hr class=\"my-6 border-t border-border\" />"), |
| 232 | Event::FootnoteReference(label) => { |
| 233 | self.raw("<sup><a class=\"text-primary hover:underline\""); |
| 234 | self.attribute("href", &format!("#fn-{label}")); |
| 235 | self.raw(">"); |
| 236 | self.text(&label); |
| 237 | self.raw("</a></sup>"); |
| 238 | } |
| 239 | Event::TaskListMarker(checked) => { |
| 240 | self.raw("<input type=\"checkbox\" disabled class=\"mr-2 align-middle\""); |
| 241 | if checked { |
| 242 | self.raw(" checked"); |
| 243 | } |
| 244 | self.raw(" />"); |
| 245 | } |
| 246 | // Only reachable with options this module does not enable. |
| 247 | Event::InlineMath(value) | Event::DisplayMath(value) => self.text(&value), |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | fn start(&mut self, tag: Tag<'_>) { |
| 252 | match tag { |
| 253 | Tag::Paragraph => self.raw("<p class=\"my-3 leading-relaxed\">"), |
| 254 | Tag::Heading { level, .. } => self.raw(heading_open(level)), |
| 255 | Tag::BlockQuote(_) => self.raw( |
| 256 | "<blockquote class=\"my-4 border-l-2 border-border pl-4 text-muted-foreground\">", |
| 257 | ), |
| 258 | Tag::CodeBlock(_) => self.raw( |
| 259 | "<pre class=\"my-4 overflow-x-auto rounded-lg border border-border bg-surface px-4 py-3 font-mono text-xs leading-relaxed\"><code>", |
| 260 | ), |
| 261 | // The block itself is nothing; its lines arrive as `Event::Html` and are |
| 262 | // written as text, so they need somewhere to sit. |
| 263 | Tag::HtmlBlock => self.raw("<p class=\"my-3 leading-relaxed\">"), |
| 264 | Tag::List(None) => self.raw("<ul class=\"my-3 list-disc space-y-1 pl-6\">"), |
| 265 | Tag::List(Some(first)) => { |
| 266 | self.raw("<ol class=\"my-3 list-decimal space-y-1 pl-6\""); |
| 267 | if first != 1 { |
| 268 | self.attribute("start", &first.to_string()); |
| 269 | } |
| 270 | self.raw(">"); |
| 271 | } |
| 272 | Tag::Item => self.raw("<li>"), |
| 273 | Tag::FootnoteDefinition(label) => { |
| 274 | self.raw("<div class=\"mt-2 text-xs text-muted-foreground\""); |
| 275 | self.attribute("id", &format!("fn-{label}")); |
| 276 | self.raw("><span class=\"mr-2 font-mono\">"); |
| 277 | self.text(&label); |
| 278 | self.raw("</span>"); |
| 279 | } |
| 280 | Tag::Table(alignments) => { |
| 281 | self.alignments = alignments; |
| 282 | self.raw( |
| 283 | "<div class=\"my-4 overflow-x-auto\"><table class=\"w-full border-collapse text-sm\">", |
| 284 | ); |
| 285 | } |
| 286 | Tag::TableHead => { |
| 287 | self.in_head = true; |
| 288 | self.column = 0; |
| 289 | self.raw("<thead><tr>"); |
| 290 | } |
| 291 | Tag::TableRow => { |
| 292 | self.column = 0; |
| 293 | self.raw("<tr>"); |
| 294 | } |
| 295 | Tag::TableCell => { |
| 296 | let alignment = self.alignments.get(self.column).copied(); |
| 297 | |
| 298 | if self.in_head { |
| 299 | // A header with no stated alignment is left-aligned rather than |
| 300 | // left to the browser, which centres `<th>` by default and makes a |
| 301 | // plain markdown table look deliberately centred when it is not. |
| 302 | self.raw("<th class=\"border-b border-border px-3 py-2 font-medium "); |
| 303 | self.raw(align_class(alignment).unwrap_or("text-left")); |
| 304 | } else { |
| 305 | self.raw("<td class=\"border-b border-border px-3 py-2 "); |
| 306 | self.raw(align_class(alignment).unwrap_or("")); |
| 307 | } |
| 308 | |
| 309 | self.raw("\">"); |
| 310 | } |
| 311 | Tag::Emphasis => self.raw("<em>"), |
| 312 | Tag::Strong => self.raw("<strong class=\"font-semibold\">"), |
| 313 | Tag::Strikethrough => self.raw("<del>"), |
| 314 | Tag::Superscript => self.raw("<sup>"), |
| 315 | Tag::Subscript => self.raw("<sub>"), |
| 316 | Tag::Link { |
| 317 | dest_url, title, .. |
| 318 | } => { |
| 319 | self.raw("<a"); |
| 320 | |
| 321 | // A refused destination keeps its text but loses its `href` — and its |
| 322 | // link styling with it, so it does not read as a link that silently |
| 323 | // does nothing. Dropping the element entirely would swallow what the |
| 324 | // author wrote. |
| 325 | if let Destination::Allowed(url) = destination(&dest_url, true, &self.resolve) { |
| 326 | self.raw(" class=\"text-primary hover:underline\""); |
| 327 | self.attribute("href", &url); |
| 328 | } |
| 329 | if !title.is_empty() { |
| 330 | self.attribute("title", &title); |
| 331 | } |
| 332 | |
| 333 | self.raw(">"); |
| 334 | } |
| 335 | Tag::Image { |
| 336 | dest_url, title, .. |
| 337 | } => { |
| 338 | self.image = Some(Image { |
| 339 | destination: destination(&dest_url, false, &self.resolve), |
| 340 | title: title.into_string(), |
| 341 | alt: String::new(), |
| 342 | }); |
| 343 | } |
| 344 | // Only reachable with options this module does not enable. |
| 345 | Tag::DefinitionList |
| 346 | | Tag::DefinitionListTitle |
| 347 | | Tag::DefinitionListDefinition |
| 348 | | Tag::MetadataBlock(_) => {} |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | fn end(&mut self, tag: TagEnd) { |
| 353 | match tag { |
| 354 | TagEnd::Paragraph | TagEnd::HtmlBlock => self.raw("</p>"), |
| 355 | TagEnd::Heading(level) => self.raw(heading_close(level)), |
| 356 | TagEnd::BlockQuote(_) => self.raw("</blockquote>"), |
| 357 | TagEnd::CodeBlock => self.raw("</code></pre>"), |
| 358 | TagEnd::List(true) => self.raw("</ol>"), |
| 359 | TagEnd::List(false) => self.raw("</ul>"), |
| 360 | TagEnd::Item => self.raw("</li>"), |
| 361 | TagEnd::FootnoteDefinition => self.raw("</div>"), |
| 362 | TagEnd::Table => self.raw("</table></div>"), |
| 363 | TagEnd::TableHead => { |
| 364 | self.in_head = false; |
| 365 | self.raw("</tr></thead><tbody>"); |
| 366 | } |
| 367 | TagEnd::TableRow => self.raw("</tr>"), |
| 368 | TagEnd::TableCell => { |
| 369 | self.column += 1; |
| 370 | if self.in_head { |
| 371 | self.raw("</th>"); |
| 372 | } else { |
| 373 | self.raw("</td>"); |
| 374 | } |
| 375 | } |
| 376 | TagEnd::Emphasis => self.raw("</em>"), |
| 377 | TagEnd::Strong => self.raw("</strong>"), |
| 378 | TagEnd::Strikethrough => self.raw("</del>"), |
| 379 | TagEnd::Superscript => self.raw("</sup>"), |
| 380 | TagEnd::Subscript => self.raw("</sub>"), |
| 381 | TagEnd::Link => self.raw("</a>"), |
| 382 | TagEnd::Image => self.image(), |
| 383 | TagEnd::DefinitionList |
| 384 | | TagEnd::DefinitionListTitle |
| 385 | | TagEnd::DefinitionListDefinition |
| 386 | | TagEnd::MetadataBlock(_) => {} |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | /// Writes the image whose alt text has just finished arriving. |
| 391 | /// |
| 392 | /// A refused source is not rendered as a broken image: the alt text is written on |
| 393 | /// its own, which is the same thing a reader with images off would get. |
| 394 | fn image(&mut self) { |
| 395 | let Some(image) = self.image.take() else { |
| 396 | return; |
| 397 | }; |
| 398 | |
| 399 | let Destination::Allowed(url) = image.destination else { |
| 400 | self.text(&image.alt); |
| 401 | return; |
| 402 | }; |
| 403 | |
| 404 | self.raw("<img class=\"my-4 max-w-full rounded-lg border border-border\""); |
| 405 | self.attribute("src", &url); |
| 406 | self.attribute("alt", &image.alt); |
| 407 | if !image.title.is_empty() { |
| 408 | self.attribute("title", &image.title); |
| 409 | } |
| 410 | self.raw(" />"); |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | /// The class for a column's stated alignment, or `None` when it stated none. |
| 415 | fn align_class(alignment: Option<Alignment>) -> Option<&'static str> { |
| 416 | match alignment? { |
| 417 | Alignment::Center => Some("text-center"), |
| 418 | Alignment::Right => Some("text-right"), |
| 419 | Alignment::Left => Some("text-left"), |
| 420 | Alignment::None => None, |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | /// Headings step down in weight as well as size, so a README's `##` sections read as |
| 425 | /// sections rather than as six sizes of the same thing. |
| 426 | fn heading_open(level: HeadingLevel) -> &'static str { |
| 427 | match level { |
| 428 | HeadingLevel::H1 => "<h1 class=\"mt-8 mb-3 text-2xl font-semibold tracking-tight\">", |
| 429 | HeadingLevel::H2 => { |
| 430 | "<h2 class=\"mt-8 mb-3 border-b border-border pb-2 text-xl font-semibold tracking-tight\">" |
| 431 | } |
| 432 | HeadingLevel::H3 => "<h3 class=\"mt-6 mb-2 text-lg font-semibold\">", |
| 433 | HeadingLevel::H4 => "<h4 class=\"mt-6 mb-2 text-base font-semibold\">", |
| 434 | HeadingLevel::H5 => "<h5 class=\"mt-4 mb-2 text-sm font-semibold\">", |
| 435 | HeadingLevel::H6 => "<h6 class=\"mt-4 mb-2 text-sm font-semibold text-muted-foreground\">", |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | fn heading_close(level: HeadingLevel) -> &'static str { |
| 440 | match level { |
| 441 | HeadingLevel::H1 => "</h1>", |
| 442 | HeadingLevel::H2 => "</h2>", |
| 443 | HeadingLevel::H3 => "</h3>", |
| 444 | HeadingLevel::H4 => "</h4>", |
| 445 | HeadingLevel::H5 => "</h5>", |
| 446 | HeadingLevel::H6 => "</h6>", |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | #[cfg(test)] |
| 451 | mod tests { |
| 452 | use super::*; |
| 453 | |
| 454 | /// The rendered HTML, for a test to look inside. |
| 455 | fn html(source: &str) -> String { |
| 456 | render(source).as_str().to_owned() |
| 457 | } |
| 458 | |
| 459 | /// The same, with a resolver for relative links. |
| 460 | fn html_with(source: &str, resolve: impl Fn(&str) -> Option<String>) -> String { |
| 461 | render_with_links(source, resolve).as_str().to_owned() |
| 462 | } |
| 463 | |
| 464 | #[test] |
| 465 | fn headings_become_headings() { |
| 466 | assert!(html("# Steid").contains("<h1 class=\"")); |
| 467 | assert!(html("# Steid").contains(">Steid</h1>")); |
| 468 | assert!(html("### Deeper").contains(">Deeper</h3>")); |
| 469 | } |
| 470 | |
| 471 | #[test] |
| 472 | fn lists_keep_their_kind() { |
| 473 | let bullets = html("- one\n- two\n"); |
| 474 | assert!(bullets.contains("<ul class=")); |
| 475 | assert!(bullets.contains("<li>one</li>")); |
| 476 | |
| 477 | let numbers = html("3. three\n4. four\n"); |
| 478 | assert!(numbers.contains("<ol class=")); |
| 479 | assert!(numbers.contains("start=\"3\"")); |
| 480 | } |
| 481 | |
| 482 | #[test] |
| 483 | fn a_fenced_code_block_is_a_pre_and_its_contents_are_text() { |
| 484 | let rendered = html("```rust\nlet x = 1 < 2;\n```\n"); |
| 485 | |
| 486 | assert!(rendered.contains("<pre class=")); |
| 487 | assert!(rendered.contains("<code>let x = 1 < 2;\n</code></pre>")); |
| 488 | } |
| 489 | |
| 490 | #[test] |
| 491 | fn inline_code_is_a_code_element() { |
| 492 | assert!(html("use `cargo test`").contains("<code class=\"rounded")); |
| 493 | } |
| 494 | |
| 495 | #[test] |
| 496 | fn a_table_renders_as_a_table_with_alignment() { |
| 497 | let rendered = html("| a | b |\n|:-:|--:|\n| 1 | 2 |\n"); |
| 498 | |
| 499 | assert!(rendered.contains("<table class=")); |
| 500 | assert!( |
| 501 | rendered.contains( |
| 502 | "<th class=\"border-b border-border px-3 py-2 font-medium text-center\">" |
| 503 | ) |
| 504 | ); |
| 505 | assert!(rendered.contains("text-right")); |
| 506 | assert!(rendered.contains("<tbody><tr><td")); |
| 507 | } |
| 508 | |
| 509 | #[test] |
| 510 | fn a_header_with_no_stated_alignment_is_left_aligned() { |
| 511 | // A browser centres `<th>` on its own, which reads as a deliberate choice. |
| 512 | let rendered = html("| a |\n|---|\n| 1 |\n"); |
| 513 | |
| 514 | assert!(rendered.contains("font-medium text-left\">"), "{rendered}"); |
| 515 | } |
| 516 | |
| 517 | #[test] |
| 518 | fn task_lists_render_as_disabled_checkboxes() { |
| 519 | let rendered = html("- [x] done\n- [ ] not\n"); |
| 520 | |
| 521 | assert!(rendered.contains("type=\"checkbox\" disabled")); |
| 522 | assert!(rendered.contains(" checked />")); |
| 523 | } |
| 524 | |
| 525 | // --- Safety --------------------------------------------------------------------- |
| 526 | |
| 527 | #[test] |
| 528 | fn a_script_tag_in_the_source_is_text_and_not_a_tag() { |
| 529 | let rendered = html("<script>alert(1)</script>\n"); |
| 530 | |
| 531 | assert!( |
| 532 | !rendered.contains("<script"), |
| 533 | "a script tag reached the page: {rendered}" |
| 534 | ); |
| 535 | assert!(rendered.contains("<script>alert(1)</script>")); |
| 536 | } |
| 537 | |
| 538 | #[test] |
| 539 | fn an_inline_event_handler_is_text_and_not_a_tag() { |
| 540 | let rendered = html("Look: <img src=x onerror=\"alert(1)\"> at that.\n"); |
| 541 | |
| 542 | // The words are still there — they are *text* now. What matters is that no |
| 543 | // element was created for the handler to hang off. |
| 544 | assert!( |
| 545 | !rendered.contains("<img"), |
| 546 | "an image tag reached the page: {rendered}" |
| 547 | ); |
| 548 | assert!(rendered.contains("<img src=x onerror=")); |
| 549 | } |
| 550 | |
| 551 | #[test] |
| 552 | fn an_html_comment_cannot_hide_markup() { |
| 553 | let rendered = html("<!-- <script>alert(1)</script> -->\n"); |
| 554 | |
| 555 | assert!(!rendered.contains("<script"), "{rendered}"); |
| 556 | assert!(!rendered.contains("<!--"), "{rendered}"); |
| 557 | } |
| 558 | |
| 559 | #[test] |
| 560 | fn a_javascript_link_loses_its_href() { |
| 561 | let rendered = html("[click](javascript:alert(1))"); |
| 562 | |
| 563 | assert!(!rendered.contains("javascript"), "{rendered}"); |
| 564 | assert!(rendered.contains("<a>click</a>"), "{rendered}"); |
| 565 | } |
| 566 | |
| 567 | #[test] |
| 568 | fn a_javascript_url_split_by_a_control_character_still_loses_its_href() { |
| 569 | // A browser strips the tab before parsing the URL, so the check has to as well. |
| 570 | let rendered = html("[click](<java	script:alert(1)>)"); |
| 571 | |
| 572 | assert!(!rendered.contains("href"), "{rendered}"); |
| 573 | } |
| 574 | |
| 575 | #[test] |
| 576 | fn an_uppercase_scheme_is_still_refused() { |
| 577 | assert!(!html("[click](JaVaScRiPt:alert(1))").contains("href")); |
| 578 | } |
| 579 | |
| 580 | #[test] |
| 581 | fn a_data_url_image_is_refused_and_leaves_its_alt_text() { |
| 582 | let rendered = html(""); |
| 583 | |
| 584 | assert!(!rendered.contains("<img"), "{rendered}"); |
| 585 | assert!(rendered.contains("a logo")); |
| 586 | } |
| 587 | |
| 588 | #[test] |
| 589 | fn ordinary_links_and_images_survive() { |
| 590 | assert!( |
| 591 | html("[home](https://example.com/a?b=1)") |
| 592 | .contains("href=\"https://example.com/a?b=1\"") |
| 593 | ); |
| 594 | assert!(html("[mail](mailto:ada@example.com)").contains("href=\"mailto:ada@example.com\"")); |
| 595 | assert!( |
| 596 | html("") |
| 597 | .contains("src=\"https://example.com/l.png\"") |
| 598 | ); |
| 599 | assert!(html("[anchor](#usage)").contains("href=\"#usage\"")); |
| 600 | } |
| 601 | |
| 602 | #[test] |
| 603 | fn text_that_needs_escaping_is_escaped() { |
| 604 | let rendered = html("5 < 6 & \"quoted\" > 4\n"); |
| 605 | |
| 606 | assert!(rendered.contains("5 < 6 & \"quoted\" > 4")); |
| 607 | } |
| 608 | |
| 609 | #[test] |
| 610 | fn a_title_cannot_break_out_of_its_attribute() { |
| 611 | let rendered = html("[x](https://example.com \"a \\\" onmouseover=alert(1)\")"); |
| 612 | |
| 613 | assert!(rendered.contains("""), "{rendered}"); |
| 614 | assert!(!rendered.contains("\" onmouseover"), "{rendered}"); |
| 615 | } |
| 616 | |
| 617 | // --- Relative links ------------------------------------------------------------- |
| 618 | |
| 619 | #[test] |
| 620 | fn a_relative_link_is_handed_to_the_resolver() { |
| 621 | let rendered = html_with("[c](./CONTRIBUTING.md)", |dest| { |
| 622 | Some(format!("/tree/{dest}")) |
| 623 | }); |
| 624 | |
| 625 | assert!( |
| 626 | rendered.contains("href=\"/tree/./CONTRIBUTING.md\""), |
| 627 | "{rendered}" |
| 628 | ); |
| 629 | } |
| 630 | |
| 631 | #[test] |
| 632 | fn absolute_paths_anchors_and_urls_are_not_resolved() { |
| 633 | let resolve = |_: &str| Some("/rewritten".to_owned()); |
| 634 | |
| 635 | for source in ["[a](/already)", "[a](#anchor)", "[a](https://example.com/)"] { |
| 636 | let rendered = html_with(source, resolve); |
| 637 | assert!(!rendered.contains("/rewritten"), "{source}: {rendered}"); |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | #[test] |
| 642 | fn a_resolver_cannot_smuggle_in_a_dangerous_url() { |
| 643 | // The scheme check runs after resolution, on purpose. |
| 644 | let rendered = html_with("[a](whatever.md)", |_| { |
| 645 | Some("javascript:alert(1)".to_owned()) |
| 646 | }); |
| 647 | |
| 648 | assert!(!rendered.contains("href"), "{rendered}"); |
| 649 | } |
| 650 | |
| 651 | #[test] |
| 652 | fn a_relative_image_is_never_resolved() { |
| 653 | // A tree URL serves a *page*, so rewriting an image source to one would swap a |
| 654 | // 404 for a broken image. The resolver is for links only. |
| 655 | let rendered = html_with("", |_| Some("/rewritten".to_owned())); |
| 656 | |
| 657 | assert!(rendered.contains("src=\"logo.png\""), "{rendered}"); |
| 658 | } |
| 659 | |
| 660 | #[test] |
| 661 | fn without_a_resolver_a_relative_link_is_left_alone() { |
| 662 | assert!(html("[c](./CONTRIBUTING.md)").contains("href=\"./CONTRIBUTING.md\"")); |
| 663 | } |
| 664 | |
| 665 | #[test] |
| 666 | fn a_colon_in_a_path_is_not_a_scheme() { |
| 667 | assert!(scheme_of("docs/a:b.md").is_none()); |
| 668 | assert!(scheme_of("#a:b").is_none()); |
| 669 | assert!(scheme_of("./a.md").is_none()); |
| 670 | assert_eq!(scheme_of("HTTPS://example.com").as_deref(), Some("https")); |
| 671 | } |
| 672 | |
| 673 | #[test] |
| 674 | fn empty_input_renders_nothing() { |
| 675 | assert!(html("").is_empty()); |
| 676 | assert!(html(" \n\n ").is_empty()); |
| 677 | } |
| 678 | } |