serve_error_page skips the path containment guard #65

Closed
opened 2026-10-26 15:39:25 +00:00 by james.oates · 1 comment
Owner

From the v2.0.0 security audit, found while attacking the guard added in 911633a.

serve_from_dir now resolves through contained_path, which rebuilds the path from Component::Normal parts only. serve_error_page does not — it joins its file argument onto the site directory directly, so an error_pages value of ../../etc/passwd escapes.

Not client-controlled: file comes from the operator's own error_pages map, and the directory is their own config too. So this is not a vulnerability — an operator who can set error_pages can already point dir anywhere. Filing it because it is now the one static-serving path that does not go through the guard, which is exactly the kind of asymmetry that becomes a vulnerability when someone later makes error pages configurable over the API.

Fix: route it through contained_path as well. Cheap, and it makes "every filesystem read from a request is contained" true without exception.

Also recorded from the same attack pass

The guard itself held against: /../secret, /a/../../secret, /....//....//etc (.... is a Normal component), /..%2f..%2fetc (no percent-decoding anywhere on the path, so it becomes a literal filename), backslash /..\\..\\etc (0x5C is admitted by the URI parser but is a Normal byte on Linux), overlong and high-byte UTF-8 (reach components() but compare unequal to .., and Linux compares raw bytes), NUL (rejected by the URI parser), leading . components, and the index.html append.

Component::Prefix is unreachable on Unix. Symlink-following remains the one real reach and is deliberate — Kyoshi serves a Nix store path through one — so containment is on the requested path, not the resolved target. Worth a line in the release notes: a site directory containing an attacker-writable symlink is a full escape.

From the v2.0.0 security audit, found while attacking the guard added in `911633a`. `serve_from_dir` now resolves through `contained_path`, which rebuilds the path from `Component::Normal` parts only. `serve_error_page` does not — it joins its `file` argument onto the site directory directly, so an `error_pages` value of `../../etc/passwd` escapes. Not client-controlled: `file` comes from the operator's own `error_pages` map, and the directory is their own config too. So this is not a vulnerability — an operator who can set `error_pages` can already point `dir` anywhere. Filing it because it is now the one static-serving path that does not go through the guard, which is exactly the kind of asymmetry that becomes a vulnerability when someone later makes error pages configurable over the API. Fix: route it through `contained_path` as well. Cheap, and it makes "every filesystem read from a request is contained" true without exception. ## Also recorded from the same attack pass The guard itself held against: `/../secret`, `/a/../../secret`, `/....//....//etc` (`....` is a `Normal` component), `/..%2f..%2fetc` (no percent-decoding anywhere on the path, so it becomes a literal filename), backslash `/..\\..\\etc` (0x5C is admitted by the URI parser but is a `Normal` byte on Linux), overlong and high-byte UTF-8 (reach `components()` but compare unequal to `..`, and Linux compares raw bytes), NUL (rejected by the URI parser), leading `.` components, and the `index.html` append. `Component::Prefix` is unreachable on Unix. Symlink-following remains the one real reach and is deliberate — Kyoshi serves a Nix store path through one — so containment is on the requested path, not the resolved target. **Worth a line in the release notes:** a site directory containing an attacker-writable symlink is a full escape.
james.oates added this to the v2.1 milestone 2026-10-26 17:53:31 +00:00
Author
Owner

Fixed in fee27e1. serve_error_page now resolves through contained_path, so "every filesystem read from a request is contained" holds without exception.

One deviation from a straight reroute: an empty file returns None rather than inheriting contained_path's empty-path default. That default is index.html, which is right for a request path and wrong here — error_pages = { 404 = "" } would have served the app's landing page dressed as the error body, which is the same "a file from an unrelated source dressed as the app" failure the error-page design already guards against. A test pins it.

The escape test uses your site_with_a_secret_above_it helper, which asserts the target is readable before asserting the refusal — without that it would pass unfixed on ENOENT, which is how two tests fooled me earlier this week.

Release-notes line added for the symlink reach, under Known limits: containment is on the requested path, not the resolved target, so a site directory containing an attacker-writable symlink is a full escape. It names the forms you tested that held, so the next person knows the guard was attacked rather than assumed, and says plainly not to put a site directory anywhere untrusted content can create links.

376 tests pass, clippy --all-targets -D warnings clean, fmt clean.

Fixed in `fee27e1`. `serve_error_page` now resolves through `contained_path`, so "every filesystem read from a request is contained" holds without exception. One deviation from a straight reroute: an empty `file` returns `None` rather than inheriting `contained_path`'s empty-path default. That default is `index.html`, which is right for a *request* path and wrong here — `error_pages = { 404 = "" }` would have served the app's landing page dressed as the error body, which is the same "a file from an unrelated source dressed as the app" failure the error-page design already guards against. A test pins it. The escape test uses your `site_with_a_secret_above_it` helper, which asserts the target is readable before asserting the refusal — without that it would pass unfixed on ENOENT, which is how two tests fooled me earlier this week. **Release-notes line added** for the symlink reach, under Known limits: containment is on the requested path, not the resolved target, so a site directory containing an attacker-writable symlink is a full escape. It names the forms you tested that held, so the next person knows the guard was attacked rather than assumed, and says plainly not to put a site directory anywhere untrusted content can create links. 376 tests pass, clippy `--all-targets -D warnings` clean, fmt clean.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
IsoHex/edge-router#65
No description provided.