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

@ -29,6 +29,10 @@ impl Server {
write(dir.path(), "one/.itsybitsy.toml", "[defaults]\nmax_age = 120\n");
// A line starting with a dot, which Gopher has to double.
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(),
@ -500,6 +504,42 @@ fn an_empty_nex_selector_is_the_root() {
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 ---------------------------------------------------------
#[test]