Path traversal in the static file server: request path reaches Path::join unnormalised #35
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
serve_from_dirbuilds a filesystem path by lexically joining the raw request target to the configured directory, with no normalisation and no containment check, so literal../segments escape the directory and read arbitrary files the ER process can open.The chain is three hops, all in
proxy.rs:proxy.rs:245—let uri_path = req.uri().path().to_string();— the origin-form target as received. Nothing in the stack normalises dot-segments: there is noNormalizePathlayer, nocanonicalize, nocomponents()filter anywhere inproxy.rsormain.rs.serve_static(proxy.rs:956) passes it straight through toserve_from_dir.serve_from_dir(proxy.rs:1037):Path::joinwith a relative path containing..concatenates lexically; the OS then resolves the..onread.Reachable on any deployment with a
[[static_sites]]entry or[default_site].dir— including v1.1.0.Repro (curl normalises
..client-side unless told not to):Scope limits, so this isn't overstated: percent-encoded variants (
%2e%2e%2f) do not work —serve_from_dirnever percent-decodes, so the literal bytes are used as a filename. Only literal dot-segments in the target traverse. Reads are limited to what the ER process can open, and only files it can read in full.The fix is a containment check rather than string filtering: resolve the candidate and verify it is still under
dirbefore reading, rejecting anything that isn't. Notecanonicalizeon a non-existent path errors, and thedir.join(rel).join("index.html")candidate needs the same treatment.Found by code inspection while answering a question about using ER's static server on a root-CA host; not fixed inline per the standing rule.
Re-checked on
d3d7d1f(v2.0.0 candidate) — still live, unchanged, and there is no test covering it. Recording that because it has been assumed fixed in passing more than once.serve_from_dirinsrc/proxy.rs:1080joins the request path straight onto the site directory:No normalisation, no
Component::ParentDirrejection, no containment check after the join, andgrepfinds no traversal guard anywhere upstream of it — nothing in the handler chain inspects the path before this point.Reachability: hyper and axum do not collapse
..in the path, so a literal../sequence arrives intact. Most clients normalise before sending (curldoes unless given--path-as-is), so this needs a raw request rather than a browser — which lowers the odds of stumbling into it, not the severity. Percent-encoded%2e%2eis not a vector, becauseUri::path()leaves it encoded and the join looks for a literally-named directory.diris operator-configured, so the blast radius is whatever the ER process can read — which on the container image is root-owned, including the certificate database.#36 proposed
tower_http::services::ServeDir(featurefs, currently not enabled), which fixes this and the streaming gap together: it percent-decodes and then rejectsParentDir/RootDir, and it follows symlinks, which Kyoshi's Nix store paths need. That remains the right fix, and it is a dependency-feature change rather than hand-rolled path logic.Whatever the fix, it needs a test asserting a literal
../request is refused — there is none today, which is why this keeps being believed fixed.Fixed in
911633a, before the v2.0.0 tag.contained_pathrebuilds the relative path fromComponent::Normalparts only, so.., an absolute path and a Windows prefix are all refused — rejected rather than sanitised, so a request trying to leave the directory fails instead of being quietly rewritten into one that stays.serve_from_dirresolves through it.Not the
ServeDirswap: that remains the right long-term fix and #36 stays open for it, since it also brings decoding, Range and conditional requests. Those are behaviour changes, and this needed to be a security fix on its own before the tag.Three tests, each watched fail first:
serve_from_dir_refuses_a_path_that_escapes_the_siteserve_from_dir_refuses_an_escape_disguised_by_a_real_prefixserve_from_dir_still_serves_a_legitimate_nested_path(so the guard rejects escapes, not nesting)Worth recording about the second one: it initially passed against the unfixed code, because the
assetsprefix didn't exist and the kernel resolves..against real directories — so the read failed with ENOENT rather than escaping. It now creates the directory and asserts the escape target is genuinely readable before asserting the refusal. That's the same trap this issue kept falling into: an assertion that holds for a reason unrelated to the thing being tested.Two documented non-guarantees, at the function and in CLAUDE.md:
%2e%2eis not a vector today — but any decoding added later must run before the check or%2e%2e%2freopens the escape.344 tests green, clippy clean, fmt clean in
rust:1.98.0-bookworm.