|
5 | 5 | //! as a CGI-style response (headers followed by body). |
6 | 6 |
|
7 | 7 | use std::collections::HashMap; |
8 | | -use std::path::PathBuf; |
| 8 | +use std::path::{Component, Path, PathBuf}; |
9 | 9 | use std::process::Stdio; |
10 | 10 |
|
11 | 11 | use http::{Response, StatusCode}; |
@@ -66,13 +66,19 @@ impl CgiHandler { |
66 | 66 | client_addr: std::net::SocketAddr, |
67 | 67 | ) -> Result<Response<crate::Body>, ProxyError> { |
68 | 68 | let path = request.uri().path().to_string(); |
69 | | - let script_path = self.root.join(path.trim_start_matches('/')); |
70 | | - |
71 | | - if !script_path.exists() { |
72 | | - return Ok(Response::builder() |
73 | | - .status(StatusCode::NOT_FOUND) |
74 | | - .body(full_body("Not Found"))?); |
75 | | - } |
| 69 | + let script_path = match resolve_script_path(&self.root, &path) { |
| 70 | + Ok(script_path) => script_path, |
| 71 | + Err(CgiPathError::NotFound) => { |
| 72 | + return Ok(Response::builder() |
| 73 | + .status(StatusCode::NOT_FOUND) |
| 74 | + .body(full_body("Not Found"))?); |
| 75 | + } |
| 76 | + Err(CgiPathError::Forbidden) => { |
| 77 | + return Ok(Response::builder() |
| 78 | + .status(StatusCode::FORBIDDEN) |
| 79 | + .body(full_body("Forbidden"))?); |
| 80 | + } |
| 81 | + }; |
76 | 82 |
|
77 | 83 | // Collect the request body before decomposing the request. |
78 | 84 | let (parts, body) = request.into_parts(); |
@@ -163,6 +169,79 @@ impl CgiHandler { |
163 | 169 | } |
164 | 170 | } |
165 | 171 |
|
| 172 | +// --------------------------------------------------------------------------- |
| 173 | +// CGI path resolution |
| 174 | +// --------------------------------------------------------------------------- |
| 175 | + |
| 176 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 177 | +enum CgiPathError { |
| 178 | + NotFound, |
| 179 | + Forbidden, |
| 180 | +} |
| 181 | + |
| 182 | +fn resolve_script_path(root: &Path, uri_path: &str) -> Result<PathBuf, CgiPathError> { |
| 183 | + let decoded_path = percent_decode_path(uri_path).ok_or(CgiPathError::Forbidden)?; |
| 184 | + if has_forbidden_path_components(Path::new(decoded_path.trim_start_matches('/'))) { |
| 185 | + return Err(CgiPathError::Forbidden); |
| 186 | + } |
| 187 | + |
| 188 | + let candidate = root.join(uri_path.trim_start_matches('/')); |
| 189 | + let root = root.canonicalize().map_err(|_| CgiPathError::NotFound)?; |
| 190 | + let script = candidate |
| 191 | + .canonicalize() |
| 192 | + .map_err(|_| CgiPathError::NotFound)?; |
| 193 | + |
| 194 | + if !script.starts_with(&root) { |
| 195 | + return Err(CgiPathError::Forbidden); |
| 196 | + } |
| 197 | + if !script.is_file() { |
| 198 | + return Err(CgiPathError::NotFound); |
| 199 | + } |
| 200 | + |
| 201 | + Ok(script) |
| 202 | +} |
| 203 | + |
| 204 | +fn has_forbidden_path_components(path: &Path) -> bool { |
| 205 | + path.components().any(|component| { |
| 206 | + matches!( |
| 207 | + component, |
| 208 | + Component::ParentDir | Component::RootDir | Component::Prefix(_) |
| 209 | + ) |
| 210 | + }) |
| 211 | +} |
| 212 | + |
| 213 | +fn percent_decode_path(path: &str) -> Option<String> { |
| 214 | + let bytes = path.as_bytes(); |
| 215 | + let mut output = Vec::with_capacity(bytes.len()); |
| 216 | + let mut i = 0; |
| 217 | + |
| 218 | + while i < bytes.len() { |
| 219 | + if bytes[i] == b'%' { |
| 220 | + if i + 2 >= bytes.len() { |
| 221 | + return None; |
| 222 | + } |
| 223 | + let hi = hex_value(bytes[i + 1])?; |
| 224 | + let lo = hex_value(bytes[i + 2])?; |
| 225 | + output.push((hi << 4) | lo); |
| 226 | + i += 3; |
| 227 | + } else { |
| 228 | + output.push(bytes[i]); |
| 229 | + i += 1; |
| 230 | + } |
| 231 | + } |
| 232 | + |
| 233 | + String::from_utf8(output).ok() |
| 234 | +} |
| 235 | + |
| 236 | +fn hex_value(byte: u8) -> Option<u8> { |
| 237 | + match byte { |
| 238 | + b'0'..=b'9' => Some(byte - b'0'), |
| 239 | + b'a'..=b'f' => Some(byte - b'a' + 10), |
| 240 | + b'A'..=b'F' => Some(byte - b'A' + 10), |
| 241 | + _ => None, |
| 242 | + } |
| 243 | +} |
| 244 | + |
166 | 245 | // --------------------------------------------------------------------------- |
167 | 246 | // CGI response parsing (shared with SCGI) |
168 | 247 | // --------------------------------------------------------------------------- |
@@ -268,4 +347,29 @@ mod tests { |
268 | 347 | let resp = parse_cgi_response(data).unwrap(); |
269 | 348 | assert_eq!(resp.status(), 200); |
270 | 349 | } |
| 350 | + |
| 351 | + #[test] |
| 352 | + fn resolve_script_path_allows_files_under_root() { |
| 353 | + let dir = tempfile::tempdir().unwrap(); |
| 354 | + let script = dir.path().join("run.cgi"); |
| 355 | + std::fs::write(&script, "echo ok").unwrap(); |
| 356 | + |
| 357 | + let resolved = resolve_script_path(dir.path(), "/run.cgi").unwrap(); |
| 358 | + |
| 359 | + assert_eq!(resolved, script.canonicalize().unwrap()); |
| 360 | + } |
| 361 | + |
| 362 | + #[test] |
| 363 | + fn resolve_script_path_rejects_parent_escape() { |
| 364 | + let dir = tempfile::tempdir().unwrap(); |
| 365 | + let outside = dir.path().parent().unwrap().join("outside-gatel-cgi-test"); |
| 366 | + std::fs::write(&outside, "echo outside").unwrap(); |
| 367 | + |
| 368 | + let result = resolve_script_path(dir.path(), "/../outside-gatel-cgi-test"); |
| 369 | + let encoded = resolve_script_path(dir.path(), "/%2e%2e/outside-gatel-cgi-test"); |
| 370 | + |
| 371 | + std::fs::remove_file(&outside).unwrap(); |
| 372 | + assert_eq!(result, Err(CgiPathError::Forbidden)); |
| 373 | + assert_eq!(encoded, Err(CgiPathError::Forbidden)); |
| 374 | + } |
271 | 375 | } |
0 commit comments