use core::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
NotAbsolute,
BadEscape,
ForbiddenByte,
NotUtf8,
AboveRoot,
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::NotAbsolute => "request target does not begin with \"/\"",
Self::BadEscape => "a \"%\" escape is not followed by two hex digits",
Self::ForbiddenByte => {
"a decoded segment carries a byte a path segment may not: \
a control byte, \"/\", \"\\\", or \":\""
}
Self::NotUtf8 => "a decoded segment is not valid UTF-8",
Self::AboveRoot => "the target reaches above the root",
})
}
}
impl core::error::Error for Refusal {}
pub fn resolve(target: &str) -> Result<Vec<String>, Refusal> {
if !target.starts_with('/') {
return Err(Refusal::NotAbsolute);
}
let cut = target.find(['?', '#']).unwrap_or(target.len());
let path = &target[1..cut];
let mut stack: Vec<String> = Vec::new();
for raw_segment in path.split('/') {
let decoded = decode_segment(raw_segment)?;
if decoded.iter().copied().any(is_forbidden_byte) {
return Err(Refusal::ForbiddenByte);
}
let segment = String::from_utf8(decoded).map_err(|_| Refusal::NotUtf8)?;
match segment.as_str() {
"" | "." => {}
".." => {
if stack.pop().is_none() {
return Err(Refusal::AboveRoot);
}
}
_ => stack.push(segment),
}
}
Ok(stack)
}
pub fn is_hidden(segments: &[String]) -> bool {
segments.iter().any(|segment| segment.starts_with('.'))
}
fn decode_segment(segment: &str) -> Result<Vec<u8>, Refusal> {
let bytes = segment.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex_pair = bytes
.get(i + 1)
.copied()
.and_then(hex_value)
.zip(bytes.get(i + 2).copied().and_then(hex_value));
let (hi, lo) = hex_pair.ok_or(Refusal::BadEscape)?;
out.push((hi << 4) | lo);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
Ok(out)
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn is_forbidden_byte(byte: u8) -> bool {
byte < 0x20 || byte == 0x7f || matches!(byte, b'/' | b'\\' | b':')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_traversal_shapes_are_each_refused_for_their_own_reason() {
assert_eq!(
resolve("/assets/app.css").unwrap(),
vec!["assets", "app.css"]
);
assert_eq!(
resolve("/assets/../index.html").unwrap(),
vec!["index.html"]
);
assert_eq!(resolve("/").unwrap(), Vec::<String>::new());
assert_eq!(resolve("/a//b/./c").unwrap(), vec!["a", "b", "c"]);
assert_eq!(resolve("/../../etc/passwd"), Err(Refusal::AboveRoot));
assert_eq!(
resolve("/%2e%2e/%2e%2e/etc/passwd"),
Err(Refusal::AboveRoot)
);
assert_eq!(
resolve("/..%2f..%2fetc/passwd"),
Err(Refusal::ForbiddenByte)
);
assert_eq!(resolve("/x%00.png"), Err(Refusal::ForbiddenByte));
assert_eq!(
resolve("/a%0d%0aSet-Cookie:%20x"),
Err(Refusal::ForbiddenByte)
);
assert_eq!(resolve("../etc/passwd"), Err(Refusal::NotAbsolute));
assert_eq!(
resolve("http://elsewhere/etc/passwd"),
Err(Refusal::NotAbsolute)
);
assert_eq!(resolve("/a%zz"), Err(Refusal::BadEscape));
assert_eq!(resolve("/a%2"), Err(Refusal::BadEscape));
assert_eq!(resolve("/a%ff%fe"), Err(Refusal::NotUtf8));
}
#[test]
fn an_absolute_looking_target_resolves_inside_the_root() {
assert_eq!(resolve("/etc/passwd").unwrap(), vec!["etc", "passwd"]);
assert_eq!(resolve("//etc/passwd").unwrap(), vec!["etc", "passwd"]);
}
#[test]
fn the_query_and_fragment_are_cut_before_anything_else() {
assert_eq!(resolve("/index.html?v=2").unwrap(), vec!["index.html"]);
assert_eq!(resolve("/index.html#top").unwrap(), vec!["index.html"]);
assert_eq!(resolve("/?../../etc/passwd").unwrap(), Vec::<String>::new());
}
#[test]
fn decoding_happens_after_splitting_and_never_creates_a_separator() {
assert_eq!(resolve("/a%2fb"), Err(Refusal::ForbiddenByte));
assert_eq!(resolve("/%252e%252e/x").unwrap(), vec!["%2e%2e", "x"]);
}
#[test]
fn a_backslash_segment_is_refused_on_every_target() {
assert_eq!(resolve("/a%5c..%5cetc"), Err(Refusal::ForbiddenByte));
assert_eq!(resolve("/\\..\\..\\etc"), Err(Refusal::ForbiddenByte));
}
#[test]
fn a_windows_drive_prefix_or_a_data_stream_is_refused() {
assert_eq!(
resolve("/C:/Windows/System32/config/SAM"),
Err(Refusal::ForbiddenByte)
);
assert_eq!(resolve("/C:foo"), Err(Refusal::ForbiddenByte));
assert_eq!(resolve("/x.txt:$DATA"), Err(Refusal::ForbiddenByte));
assert_eq!(resolve("/a%3ab"), Err(Refusal::ForbiddenByte));
}
#[test]
fn the_decoder_and_the_target_form_have_no_soft_edges() {
assert_eq!(resolve("/%2E%2E/x"), Err(Refusal::AboveRoot));
assert_eq!(resolve("/%2e%2E/x"), Err(Refusal::AboveRoot));
assert_eq!(resolve("/%c0%ae%c0%ae/x"), Err(Refusal::NotUtf8));
assert_eq!(resolve(""), Err(Refusal::NotAbsolute));
assert_eq!(resolve("*"), Err(Refusal::NotAbsolute));
}
#[test]
fn a_dot_leading_segment_anywhere_reads_as_hidden() {
assert!(is_hidden(&resolve("/.env").unwrap()));
assert!(is_hidden(&resolve("/.git/config").unwrap()));
assert!(is_hidden(&resolve("/a/.b/c").unwrap()));
assert!(!is_hidden(&resolve("/index.html").unwrap()));
assert!(!is_hidden(&resolve("/a.b/c").unwrap()));
}
}