mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-02 17:49:20 +00:00
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:
co-authored by
Illia Polosukhin
parent
913073d83d
commit
2d3eb4de9a
@@ -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,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]
|
||||
|
||||
Reference in New Issue
Block a user