fix: bound document nesting and escape generated urls

This commit is contained in:
randogoth 2026-10-04 22:30:44 +03:00
parent c29364d8e0
commit 49a63503d0
10 changed files with 276 additions and 19 deletions

View file

@ -26,6 +26,11 @@ pub enum Error {
/// name is a typo or a missing cargo feature.
UnknownFormat { listener: String, format: String, available: Vec<String> },
/// A document nests blocks or inlines deeper than the parser allows. Refused
/// rather than parsed, because every walk over the result recurses and a
/// stack overflow aborts the process rather than unwinding.
TooDeep { limit: usize },
/// An include or ASCII-art target cannot be used. `path` is where the
/// directive actually pointed, resolved, which is the thing an author needs
/// to see when a relative target is wrong.
@ -68,6 +73,9 @@ impl fmt::Display for Error {
Error::DuplicateHost { host, first, second } => {
write!(f, "host '{host}' is claimed by both site '{first}' and site '{second}'")
}
Error::TooDeep { limit } => {
write!(f, "document nests more than {limit} levels deep")
}
Error::Include { path, reason } => {
write!(f, "include target {} {reason}", path.display())
}

View file

@ -25,19 +25,38 @@ use crate::preprocess;
/// `source` is expected to be canonical, as [`crate::site::Site`] passes it.
pub fn document(source: &Path, root: &Path) -> Result<Doc, Error> {
let expanded = preprocess::expand(source, root)?;
let mut doc = markdown(&expanded);
let mut doc = markdown(&expanded)?;
directives::apply(&mut doc, source.parent().unwrap_or(root), root)?;
Ok(doc)
}
/// Hard limit on how deeply blocks and inlines may nest.
///
/// Everything downstream of the parser walks [`Doc`] recursively: the directive
/// pass, every renderer, and the derived `Drop` for [`Block`]. Unbounded nesting
/// therefore overflows the stack, and a stack overflow aborts the process
/// instead of unwinding, so the handler's `catch_unwind` cannot contain it and
/// one pathological document would take every virtual host down with it.
/// Capping once, here, is what keeps all of those walks safe. Real prose nests a
/// handful of levels; ten is already unusual.
const MAX_NESTING: usize = 100;
/// Parse one run of Markdown.
pub fn markdown(text: &str) -> Doc {
///
/// Fails only when the source nests deeper than [`MAX_NESTING`]; a document is
/// refused whole rather than silently truncated to the limit.
pub fn markdown(text: &str) -> Result<Doc, Error> {
let options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
let mut builder = Builder::default();
for event in Parser::new_ext(text, options) {
builder.handle(event);
// Checked per event, so the half-built document is never deeper than the
// limit and dropping it cannot overflow either.
if builder.too_deep {
return Err(Error::TooDeep { limit: MAX_NESTING });
}
}
builder.finish()
Ok(builder.finish())
}
/// Data a tag needs when it closes, for the tags whose `TagEnd` carries none.
@ -82,6 +101,9 @@ struct Builder {
/// list item holds its text with no `Paragraph` around it, so inline events
/// arrive with nothing open; without this they would be dropped.
implicit: bool,
/// Set when a container would have nested past [`MAX_NESTING`]; the parse is
/// abandoned rather than the container being opened.
too_deep: bool,
first_h1: Option<String>,
}
@ -96,6 +118,7 @@ impl Default for Builder {
code: None,
html: None,
implicit: false,
too_deep: false,
first_h1: None,
}
}
@ -131,7 +154,17 @@ impl Builder {
}
}
/// Live nesting depth. Every open container holds a frame on one of these
/// stacks, so their total is what has to stay bounded.
fn depth(&self) -> usize {
self.blocks.len() + self.inlines.len() + self.lists.len() + self.tables.len()
}
fn start(&mut self, tag: Tag<'_>) {
if self.depth() > MAX_NESTING {
self.too_deep = true;
return;
}
// An implicit paragraph ends where the next block begins, so it has to
// be closed before that block is added or the order would invert.
if matches!(
@ -374,7 +407,7 @@ mod tests {
use super::*;
fn blocks(text: &str) -> Vec<Block> {
markdown(text).blocks
markdown(text).unwrap().blocks
}
fn text(value: &str) -> Vec<Inline> {
@ -404,10 +437,10 @@ mod tests {
#[test]
fn records_the_first_level_one_heading() {
let doc = markdown("## Second\n\n# First\n\n# Another\n");
let doc = markdown("## Second\n\n# First\n\n# Another\n").unwrap();
assert_eq!(doc.first_h1.as_deref(), Some("First"));
// A document with no h1 has nothing to derive a title from.
assert_eq!(markdown("## Only\n").first_h1, None);
assert_eq!(markdown("## Only\n").unwrap().first_h1, None);
}
#[test]
@ -573,4 +606,57 @@ mod tests {
};
assert_eq!(inline[1], Inline::Html("<b>".into()));
}
#[test]
fn nesting_past_the_cap_is_refused_rather_than_overflowing_the_stack() {
// Everything downstream of the parser walks the tree recursively, and a
// stack overflow aborts the process instead of unwinding, so this is the
// one place the depth can be bounded. Both of these used to abort.
let over = MAX_NESTING + 50;
let deep = [
("bare quote markers", "> ".repeat(over)),
("quoted text", format!("{}deep\n", "> ".repeat(over))),
("nested lists", (0..over).map(|i| format!("{}- x\n", " ".repeat(i))).collect()),
];
for (what, source) in deep {
let err = markdown(&source).expect_err(what);
assert!(
matches!(err, Error::TooDeep { limit } if limit == MAX_NESTING),
"{what}: {err:?}"
);
assert_eq!(
err.to_string(),
format!("document nests more than {MAX_NESTING} levels deep")
);
}
}
#[test]
fn deeply_repeated_inline_markers_are_bounded_by_the_parser_itself() {
// Emphasis and link nesting cannot run away the way block containers can:
// pulldown-cmark pairs delimiters and refuses to nest links at all, so
// 150 of each yields 75 levels and 1. The cap still counts inline frames,
// but these sources are not what it is there for.
let n = 150;
assert!(markdown(&format!("{}x{}", "*".repeat(n), "*".repeat(n))).is_ok());
assert!(markdown(&format!("{}x{}", "[".repeat(n), "](/a)".repeat(n))).is_ok());
}
#[test]
fn nesting_within_the_cap_still_parses() {
// The limit has to be far above anything real prose does, or valid pages
// would start failing; ten levels is already unusual.
let doc = markdown(&format!("{}deep\n", "> ".repeat(20))).unwrap();
assert_eq!(doc.blocks.len(), 1);
let nested =
markdown(&(0..20).map(|i| format!("{}- x\n", " ".repeat(i))).collect::<String>());
assert!(nested.is_ok());
}
#[test]
fn a_deeply_nested_document_is_refused_whole_not_truncated() {
// Silently cutting the document at the limit would serve a page missing
// most of its content with no indication anything was dropped.
assert!(markdown(&format!("# Title\n\n{}deep\n", "> ".repeat(MAX_NESTING + 1))).is_err());
}
}

View file

@ -48,9 +48,35 @@ pub fn url_for(root: &Path, source: &Path) -> Option<String> {
let rel = source.strip_prefix(root).ok()?;
if rel.file_name()? == "index.md" {
let parent = rel.parent()?.to_str()?;
return Some(if parent.is_empty() { "/".to_string() } else { format!("/{parent}/") });
return Some(if parent.is_empty() {
"/".to_string()
} else {
format!("/{}/", encode_path(parent))
});
}
Some(format!("/{}", rel.with_extension("").to_str()?))
Some(format!("/{}", encode_path(rel.with_extension("").to_str()?)))
}
/// Percent-encode a path for use in a URL, leaving `/` as the separator.
///
/// A file name may contain bytes that mean something else in a URL or in a
/// response header, and this URL is emitted as a redirect target: `?` or `#`
/// would truncate the path, and a CR LF pair would end the header, so a file
/// named `x\r\nLocation: elsewhere.md` could inject a header of its own into
/// every protocol's redirect. Escaping everything outside RFC 3986's unreserved
/// set closes that off and incidentally makes spaces and non-ASCII names work.
/// `clean_path` decodes on the way back in, so the URL still resolves.
fn encode_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for byte in path.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
out.push(byte as char)
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
/// Decode `%XX` escapes, leaving an invalid escape as the literal text it is.
@ -194,4 +220,47 @@ mod tests {
fn url_for_refuses_a_path_outside_the_root() {
assert_eq!(url_for(Path::new("/srv/content"), Path::new("/etc/passwd")), None);
}
#[test]
fn url_for_escapes_bytes_that_would_end_a_response_header() {
// A file really can be named this. The URL is emitted as a redirect
// target, so an unescaped CR LF here injects a header into the response
// of every protocol: `Location: /x` followed by an attacker's own line.
let root = PathBuf::from("/srv/content");
let url = url_for(&root, &root.join("x\r\nX-Injected: yes.md")).unwrap();
assert_eq!(url, "/x%0D%0AX-Injected%3A%20yes");
assert!(!url.contains('\r') && !url.contains('\n'));
}
#[test]
fn url_for_escapes_characters_that_would_truncate_the_path() {
let root = PathBuf::from("/srv/content");
// `?` and `#` would start a query or a fragment, losing the rest.
assert_eq!(url_for(&root, &root.join("a?b.md")).unwrap(), "/a%3Fb");
assert_eq!(url_for(&root, &root.join("a#b.md")).unwrap(), "/a%23b");
// A literal percent must not read as an escape on the way back in.
assert_eq!(url_for(&root, &root.join("100%.md")).unwrap(), "/100%25");
// A tab would forge a field separator in a Gopher menu line.
assert_eq!(url_for(&root, &root.join("a\tb.md")).unwrap(), "/a%09b");
}
#[test]
fn url_for_escapes_spaces_and_non_ascii_names() {
let root = PathBuf::from("/srv/content");
assert_eq!(url_for(&root, &root.join("my notes.md")).unwrap(), "/my%20notes");
assert_eq!(url_for(&root, &root.join("caf\u{e9}.md")).unwrap(), "/caf%C3%A9");
assert_eq!(url_for(&root, &root.join("d i r/index.md")).unwrap(), "/d%20i%20r/");
}
#[test]
fn an_encoded_url_still_resolves_back_to_the_same_path() {
// The round trip is what makes escaping safe: the redirect target a
// client is sent must clean back to the file it came from.
let root = PathBuf::from("/srv/content");
for name in ["my notes.md", "caf\u{e9}.md", "100%.md", "a?b.md", "a#b.md"] {
let url = url_for(&root, &root.join(name)).unwrap();
let stem = name.strip_suffix(".md").unwrap();
assert_eq!(clean_path(&url).as_deref(), Some(stem), "{name} via {url}");
}
}
}

View file

@ -14,6 +14,7 @@
use std::collections::BTreeSet;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::error::{Error, IncludeReason};
@ -65,8 +66,7 @@ fn expand_file(
depth: usize,
out: &mut Output,
) -> Result<(), Error> {
let text =
fs::read_to_string(path).map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
let text = read_capped(path)?;
let base = path.parent().unwrap_or(root);
for line in text.lines() {
@ -90,6 +90,36 @@ fn expand_file(
Ok(())
}
/// Read a file, refusing one larger than the whole expansion may be.
///
/// `fs::read_to_string` allocates the entire file before anything can reject it,
/// so one oversized document in the tree would cost its full size on every
/// concurrent request -- with the default connection cap, 256 times over -- even
/// though [`MAX_BYTES`] bounds what can actually be used. Nothing past that cap
/// can reach the output, so reading past it is waste an attacker can amplify.
fn read_capped(path: &Path) -> Result<String, Error> {
let too_large = || Error::Include { path: path.to_path_buf(), reason: IncludeReason::TooLarge };
let length =
fs::metadata(path).map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?.len();
if length > MAX_BYTES as u64 {
return Err(too_large());
}
let file =
fs::File::open(path).map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
let mut text = String::with_capacity(length as usize);
// Capped again while reading, because the file may have grown since the stat;
// this is the bound that actually holds. One byte past, so a file of exactly
// the cap still reads.
file.take(MAX_BYTES as u64 + 1)
.read_to_string(&mut text)
.map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
if text.len() > MAX_BYTES {
return Err(too_large());
}
Ok(text)
}
/// Canonicalise a path and require it to be a regular file inside `root`.
pub(crate) fn canonical_within(path: &Path, root: &Path) -> Result<PathBuf, Error> {
let canonical = path
@ -256,4 +286,24 @@ mod tests {
tree.write("page.md", "![[part.md]]\n![[part.md]]\n");
assert_eq!(tree.expand("page.md").unwrap(), "shared\nshared\n");
}
#[test]
fn an_oversized_file_is_refused_without_being_read_into_memory() {
// A single document larger than the expansion cap used to be allocated in
// full before anything could reject it, once per concurrent request.
let dir = tempfile::tempdir().unwrap();
let big = dir.path().join("big.md");
fs::write(&big, "x".repeat(9 * 1024 * 1024)).unwrap();
let err = expand(&big, dir.path()).unwrap_err();
assert!(matches!(err, Error::Include { reason: IncludeReason::TooLarge, .. }), "{err:?}");
}
#[test]
fn a_file_at_exactly_the_cap_still_reads() {
let dir = tempfile::tempdir().unwrap();
let edge = dir.path().join("edge.md");
// The trailing newline each line gains puts the output at the cap exactly.
fs::write(&edge, "x".repeat(8 * 1024 * 1024 - 1)).unwrap();
assert!(expand(&edge, dir.path()).is_ok());
}
}