fix: bound document nesting and escape generated urls
This commit is contained in:
parent
c29364d8e0
commit
49a63503d0
10 changed files with 276 additions and 19 deletions
|
|
@ -219,6 +219,10 @@ Links should be root-relative and extensionless (`[about](/about)`, not `about.m
|
||||||
|
|
||||||
Nothing outside the content root is reachable. A request target is percent-decoded before it is normalised, so an encoded `..` becomes a real one and gets clamped at the root rather than quietly matching nothing; any path component beginning with a dot is refused outright; and whatever survives is canonicalised and required to still be inside the root, which is what defeats a symlink pointing out of it. There are no generated directory listings — only `index.md`.
|
Nothing outside the content root is reachable. A request target is percent-decoded before it is normalised, so an encoded `..` becomes a real one and gets clamped at the root rather than quietly matching nothing; any path component beginning with a dot is refused outright; and whatever survives is canonicalised and required to still be inside the root, which is what defeats a symlink pointing out of it. There are no generated directory listings — only `index.md`.
|
||||||
|
|
||||||
|
URLs the server generates are percent-encoded. A file name can contain a space, a `?`, a `#` or even a CR LF, and these URLs are emitted as redirect targets, so an unencoded one would truncate the path or end the response header and let a crafted file name inject a header of its own into every protocol.
|
||||||
|
|
||||||
|
A document is also refused if it nests blocks more than 100 levels deep, or if it is larger than the 8 MiB an expansion may produce. Both are about the stack and the heap rather than about the content: every pass over a parsed document recurses, and a stack overflow aborts the process instead of unwinding, so one pathological file would take every virtual host down with it. Includes are separately capped at 16 levels and 200 000 lines, with the byte cap catching the diamond fan-out that a per-stack cycle check cannot see.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ impl Server {
|
||||||
write(dir.path(), "one/.itsybitsy.toml", "[defaults]\nmax_age = 120\n");
|
write(dir.path(), "one/.itsybitsy.toml", "[defaults]\nmax_age = 120\n");
|
||||||
// A line starting with a dot, which Gopher has to double.
|
// A line starting with a dot, which Gopher has to double.
|
||||||
write(dir.path(), "one/dotted.md", "# Dotted\n\n .hidden\n");
|
write(dir.path(), "one/dotted.md", "# Dotted\n\n .hidden\n");
|
||||||
|
// Nests past the parser's cap: this used to abort the whole process.
|
||||||
|
write(dir.path(), "one/abyss.md", &"> ".repeat(4000));
|
||||||
|
// A real file name carrying a CR LF, which a redirect target must escape.
|
||||||
|
write(dir.path(), "one/split\r\nX-Injected: yes.md", "# Split\n");
|
||||||
write(dir.path(), "two/index.md", "# Two\n\nSecond site.\n");
|
write(dir.path(), "two/index.md", "# Two\n\nSecond site.\n");
|
||||||
write(
|
write(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
|
|
@ -500,6 +504,42 @@ fn an_empty_nex_selector_is_the_root() {
|
||||||
assert!(server.send("nex", b"\r\n").starts_with("# Two"));
|
assert!(server.send("nex", b"\r\n").starts_with("# Two"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Robustness ----------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_document_nested_past_the_cap_is_an_error_and_the_server_survives() {
|
||||||
|
// Every walk over a parsed document recurses, and a stack overflow aborts
|
||||||
|
// the process rather than unwinding into the handler's catch_unwind, so this
|
||||||
|
// would take down every virtual host at once.
|
||||||
|
let server = Server::start();
|
||||||
|
assert_eq!(split(&server.get("one.test", "/abyss")).0, "HTTP/1.1 500 Internal Server Error");
|
||||||
|
assert!(server.get("one.test", "/").contains("First site"), "server still serving");
|
||||||
|
assert_eq!(server.send("gemini", b"gemini://one.test/abyss\r\n"), "40 Temporary failure\r\n");
|
||||||
|
assert_eq!(server.send("spartan", b"one.test /abyss 0\r\n"), "5 Internal error\r\n");
|
||||||
|
assert!(server.get("one.test", "/").contains("First site"), "still serving after all five");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_redirect_target_cannot_inject_a_response_header() {
|
||||||
|
// The file name contains a real CR LF. Unescaped, the 301's Location line
|
||||||
|
// ends early and the rest becomes a header the server never meant to send.
|
||||||
|
let server = Server::start();
|
||||||
|
let response = server.get("one.test", "/split%0d%0aX-Injected:%20yes.md");
|
||||||
|
assert!(!response.contains("\r\nX-Injected: yes"), "header injected: {response}");
|
||||||
|
assert!(response.contains("Location: /split%0D%0AX-Injected%3A%20yes"), "{response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_injected_redirect_target_is_escaped_on_every_protocol() {
|
||||||
|
// Spartan and Gemini put the target in a status line terminated the same way,
|
||||||
|
// so the same file name would split their responses too.
|
||||||
|
let server = Server::start();
|
||||||
|
let spartan = server.send("spartan", b"one.test /split%0d%0aX-Injected:%20yes.md 0\r\n");
|
||||||
|
assert_eq!(spartan, "3 /split%0D%0AX-Injected%3A%20yes\r\n");
|
||||||
|
let gemini = server.send("gemini", b"gemini://one.test/split%0d%0aX-Injected:%20yes.md\r\n");
|
||||||
|
assert_eq!(gemini, "31 /split%0D%0AX-Injected%3A%20yes\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
// -- Containment ---------------------------------------------------------
|
// -- Containment ---------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ pub enum Error {
|
||||||
/// name is a typo or a missing cargo feature.
|
/// name is a typo or a missing cargo feature.
|
||||||
UnknownFormat { listener: String, format: String, available: Vec<String> },
|
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
|
/// An include or ASCII-art target cannot be used. `path` is where the
|
||||||
/// directive actually pointed, resolved, which is the thing an author needs
|
/// directive actually pointed, resolved, which is the thing an author needs
|
||||||
/// to see when a relative target is wrong.
|
/// to see when a relative target is wrong.
|
||||||
|
|
@ -68,6 +73,9 @@ impl fmt::Display for Error {
|
||||||
Error::DuplicateHost { host, first, second } => {
|
Error::DuplicateHost { host, first, second } => {
|
||||||
write!(f, "host '{host}' is claimed by both site '{first}' and site '{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 } => {
|
Error::Include { path, reason } => {
|
||||||
write!(f, "include target {} {reason}", path.display())
|
write!(f, "include target {} {reason}", path.display())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,38 @@ use crate::preprocess;
|
||||||
/// `source` is expected to be canonical, as [`crate::site::Site`] passes it.
|
/// `source` is expected to be canonical, as [`crate::site::Site`] passes it.
|
||||||
pub fn document(source: &Path, root: &Path) -> Result<Doc, Error> {
|
pub fn document(source: &Path, root: &Path) -> Result<Doc, Error> {
|
||||||
let expanded = preprocess::expand(source, root)?;
|
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)?;
|
directives::apply(&mut doc, source.parent().unwrap_or(root), root)?;
|
||||||
Ok(doc)
|
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.
|
/// 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 options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
|
||||||
let mut builder = Builder::default();
|
let mut builder = Builder::default();
|
||||||
for event in Parser::new_ext(text, options) {
|
for event in Parser::new_ext(text, options) {
|
||||||
builder.handle(event);
|
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.
|
/// 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
|
/// list item holds its text with no `Paragraph` around it, so inline events
|
||||||
/// arrive with nothing open; without this they would be dropped.
|
/// arrive with nothing open; without this they would be dropped.
|
||||||
implicit: bool,
|
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>,
|
first_h1: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +118,7 @@ impl Default for Builder {
|
||||||
code: None,
|
code: None,
|
||||||
html: None,
|
html: None,
|
||||||
implicit: false,
|
implicit: false,
|
||||||
|
too_deep: false,
|
||||||
first_h1: None,
|
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<'_>) {
|
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
|
// 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.
|
// be closed before that block is added or the order would invert.
|
||||||
if matches!(
|
if matches!(
|
||||||
|
|
@ -374,7 +407,7 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn blocks(text: &str) -> Vec<Block> {
|
fn blocks(text: &str) -> Vec<Block> {
|
||||||
markdown(text).blocks
|
markdown(text).unwrap().blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
fn text(value: &str) -> Vec<Inline> {
|
fn text(value: &str) -> Vec<Inline> {
|
||||||
|
|
@ -404,10 +437,10 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn records_the_first_level_one_heading() {
|
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"));
|
assert_eq!(doc.first_h1.as_deref(), Some("First"));
|
||||||
// A document with no h1 has nothing to derive a title from.
|
// 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]
|
#[test]
|
||||||
|
|
@ -573,4 +606,57 @@ mod tests {
|
||||||
};
|
};
|
||||||
assert_eq!(inline[1], Inline::Html("<b>".into()));
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,35 @@ pub fn url_for(root: &Path, source: &Path) -> Option<String> {
|
||||||
let rel = source.strip_prefix(root).ok()?;
|
let rel = source.strip_prefix(root).ok()?;
|
||||||
if rel.file_name()? == "index.md" {
|
if rel.file_name()? == "index.md" {
|
||||||
let parent = rel.parent()?.to_str()?;
|
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.
|
/// 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() {
|
fn url_for_refuses_a_path_outside_the_root() {
|
||||||
assert_eq!(url_for(Path::new("/srv/content"), Path::new("/etc/passwd")), None);
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::error::{Error, IncludeReason};
|
use crate::error::{Error, IncludeReason};
|
||||||
|
|
@ -65,8 +66,7 @@ fn expand_file(
|
||||||
depth: usize,
|
depth: usize,
|
||||||
out: &mut Output,
|
out: &mut Output,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let text =
|
let text = read_capped(path)?;
|
||||||
fs::read_to_string(path).map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
|
|
||||||
let base = path.parent().unwrap_or(root);
|
let base = path.parent().unwrap_or(root);
|
||||||
|
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
|
|
@ -90,6 +90,36 @@ fn expand_file(
|
||||||
Ok(())
|
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`.
|
/// 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> {
|
pub(crate) fn canonical_within(path: &Path, root: &Path) -> Result<PathBuf, Error> {
|
||||||
let canonical = path
|
let canonical = path
|
||||||
|
|
@ -256,4 +286,24 @@ mod tests {
|
||||||
tree.write("page.md", "![[part.md]]\n![[part.md]]\n");
|
tree.write("page.md", "![[part.md]]\n![[part.md]]\n");
|
||||||
assert_eq!(tree.expand("page.md").unwrap(), "shared\nshared\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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -284,7 +284,7 @@ mod tests_support {
|
||||||
|
|
||||||
/// Render a Markdown fragment the way the server would.
|
/// Render a Markdown fragment the way the server would.
|
||||||
pub fn render(markdown: &str) -> String {
|
pub fn render(markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown);
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
||||||
let out = Gemtext.render(&doc, &ctx).unwrap();
|
let out = Gemtext.render(&doc, &ctx).unwrap();
|
||||||
|
|
@ -377,7 +377,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn a_card_divider_emits_nothing() {
|
fn a_card_divider_emits_nothing() {
|
||||||
// Pagination is a WML concern; a Gemini client scrolls one document.
|
// Pagination is a WML concern; a Gemini client scrolls one document.
|
||||||
let doc = parse::markdown("a\n\nb\n");
|
let doc = parse::markdown("a\n\nb\n").unwrap();
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
||||||
let plain = String::from_utf8(Gemtext.render(&doc, &ctx).unwrap().body).unwrap();
|
let plain = String::from_utf8(Gemtext.render(&doc, &ctx).unwrap().body).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ mod tests {
|
||||||
/// Render at a narrow width so wrapping is visible, with no margins so the
|
/// Render at a narrow width so wrapping is visible, with no margins so the
|
||||||
/// assertions read as the content itself.
|
/// assertions read as the content itself.
|
||||||
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
|
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown);
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
|
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
|
||||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +266,7 @@ mod separation_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn render(markdown: &str) -> String {
|
fn render(markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown);
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
let settings = PageSettings { margin_left: 0, margin_right: 0, ..Default::default() };
|
let settings = PageSettings { margin_left: 0, margin_right: 0, ..Default::default() };
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: Some(40) };
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: Some(40) };
|
||||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
|
|
@ -299,7 +299,7 @@ mod optional_feature_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
|
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown);
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
|
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
|
||||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn render(renderer: &dyn Renderer, markdown: &str) -> String {
|
fn render(renderer: &dyn Renderer, markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown);
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx = RenderCtx { url: "/x", title: "The Title", settings: &settings, width: None };
|
let ctx = RenderCtx { url: "/x", title: "The Title", settings: &settings, width: None };
|
||||||
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
|
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
|
||||||
|
|
@ -225,7 +225,7 @@ mod deck_tests {
|
||||||
/// Parse, then turn `<!-- card X -->` comments into dividers, which is what
|
/// Parse, then turn `<!-- card X -->` comments into dividers, which is what
|
||||||
/// `directives::apply` does for a real request.
|
/// `directives::apply` does for a real request.
|
||||||
pub fn with_card_breaks(markdown: &str) -> Doc {
|
pub fn with_card_breaks(markdown: &str) -> Doc {
|
||||||
let mut doc = parse::markdown(markdown);
|
let mut doc = parse::markdown(markdown).unwrap();
|
||||||
doc.blocks = doc
|
doc.blocks = doc
|
||||||
.blocks
|
.blocks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn render(markdown: &str) -> String {
|
fn render(markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown);
|
let doc = parse::markdown(markdown).unwrap();
|
||||||
content(&doc.blocks, &PageSettings::default())
|
content(&doc.blocks, &PageSettings::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,7 +222,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn images_follow_the_configured_policy() {
|
fn images_follow_the_configured_policy() {
|
||||||
let doc = parse::markdown("\n");
|
let doc = parse::markdown("\n").unwrap();
|
||||||
for (policy, expected) in [
|
for (policy, expected) in [
|
||||||
(Images::Keep, "<p><img src=\"/i.png\" alt=\"a photo\"/></p>\n"),
|
(Images::Keep, "<p><img src=\"/i.png\" alt=\"a photo\"/></p>\n"),
|
||||||
(Images::Alt, "<p>a photo</p>\n"),
|
(Images::Alt, "<p>a photo</p>\n"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue