fix(security): prevent path traversal bypass in WASM HTTP allowlist (#137)

* fix(security): prevent path traversal bypass in WASM HTTP allowlist

The allowlist validator checked url_path.starts_with(prefix) on the
raw, unnormalized path. A WASM tool could request a URL like:

  https://api.openai.com/v1/../admin

The starts_with("/v1/") check would pass, but the server would
resolve the ".." and serve /admin — effectively bypassing the
path prefix restriction.

This commit adds normalize_path() which resolves . and .. segments
before validation, closing the bypass. It also includes 6 new tests
covering traversal attacks and normalization correctness.

* deslop: remove redundant comments, consolidate tests

* chore(allowlist): trim nonessential traversal helper comment

* harden URL parsing for wasm allowlist and proxy paths

---------

Co-authored-by: Illia Polosukhin <[email protected]>
This commit is contained in:
bigguybobby
2026-02-18 19:53:53 +00:00
committed by GitHub
co-authored by Illia Polosukhin
parent 913073d83d
commit 2d3eb4de9a
4 changed files with 196 additions and 134 deletions
+17 -33
View File
@@ -144,41 +144,16 @@ impl Default for DomainAllowlist {
/// Parse host from a URL string.
pub fn extract_host(url: &str) -> Option<String> {
// Determine scheme and extract the rest
let rest = if let Some(stripped) = url.strip_prefix("https://") {
stripped
} else if let Some(stripped) = url.strip_prefix("http://") {
stripped
} else {
let parsed = url::Url::parse(url).ok()?;
if !matches!(parsed.scheme(), "http" | "https") {
return None;
};
// Find the end of the host (start of path, query, or end of string)
let host_end = rest.find('/').unwrap_or(rest.len());
let host_and_port = &rest[..host_end];
// Remove port if present
let host = if let Some(bracket_idx) = host_and_port.find('[') {
// IPv6 address
let close_bracket = host_and_port.find(']')?;
&host_and_port[bracket_idx + 1..close_bracket]
} else if let Some(colon_idx) = host_and_port.rfind(':') {
// Check if this is a port (all digits after colon)
let after_colon = &host_and_port[colon_idx + 1..];
if after_colon.chars().all(|c| c.is_ascii_digit()) {
&host_and_port[..colon_idx]
} else {
host_and_port
}
} else {
host_and_port
};
if host.is_empty() {
None
} else {
Some(host.to_lowercase())
}
parsed.host_str().map(|h| {
h.strip_prefix('[')
.and_then(|v| v.strip_suffix(']'))
.unwrap_or(h)
.to_lowercase()
})
}
#[cfg(test)]
@@ -246,6 +221,15 @@ mod tests {
extract_host("https://EXAMPLE.COM"),
Some("example.com".to_string())
);
assert_eq!(
extract_host("https://user:[email protected]:443/path"),
Some("api.example.com".to_string())
);
assert_eq!(
extract_host("http://[::1]:8080/path"),
Some("::1".to_string())
);
assert_eq!(extract_host("not-a-url"), None);
assert_eq!(extract_host("ftp://example.com/file"), None);
}
}
+24 -9
View File
@@ -24,8 +24,18 @@ pub struct NetworkRequest {
impl NetworkRequest {
/// Create from a URL string.
pub fn from_url(method: &str, url: &str) -> Option<Self> {
let host = crate::sandbox::proxy::allowlist::extract_host(url)?;
let path = extract_path(url);
let parsed = url::Url::parse(url).ok()?;
if !matches!(parsed.scheme(), "http" | "https") {
return None;
}
let host = parsed.host_str()?;
let host = host
.strip_prefix('[')
.and_then(|v| v.strip_suffix(']'))
.unwrap_or(host)
.to_lowercase();
let path = parsed.path().to_string();
Some(Self {
method: method.to_uppercase(),
@@ -37,15 +47,15 @@ impl NetworkRequest {
}
/// Extract path from a URL.
#[cfg(test)]
fn extract_path(url: &str) -> String {
// Find the start of the path (after ://)
if let Some(idx) = url.find("://") {
let rest = &url[idx + 3..];
if let Some(path_start) = rest.find('/') {
return rest[path_start..].to_string();
}
let Ok(parsed) = url::Url::parse(url) else {
return "/".to_string();
};
if !matches!(parsed.scheme(), "http" | "https") {
return "/".to_string();
}
"/".to_string()
parsed.path().to_string()
}
/// Decision for a network request.
@@ -203,6 +213,11 @@ mod tests {
);
assert_eq!(extract_path("https://example.com"), "/".to_string());
assert_eq!(extract_path("https://example.com/"), "/".to_string());
assert_eq!(
extract_path("https://example.com/path?q=1#frag"),
"/path".to_string()
);
assert_eq!(extract_path("ftp://example.com/path"), "/".to_string());
}
#[tokio::test]