bws_web_server/handlers/
static_handler.rs

1use crate::config::SiteConfig;
2use crate::middleware::compression::{CompressionMethod, CompressionMiddleware};
3use pingora::http::ResponseHeader;
4use pingora::prelude::*;
5use std::path::Path;
6use tokio::fs;
7
8/// Handler for serving static files from disk.
9pub struct StaticFileHandler {
10    // Future: Add caching, compression, etc.
11}
12
13impl StaticFileHandler {
14    /// Create a new StaticFileHandler
15    pub fn new() -> Self {
16        Self {}
17    }
18
19    /// Handle a static file request for the given session, site, and path.
20    /// Resolves the file path, checks security, and serves the file or a 404 page.
21    pub async fn handle(&self, session: &mut Session, site: &SiteConfig, path: &str) -> Result<()> {
22        let file_path = self.resolve_file_path(site, path).await;
23
24        match file_path {
25            Some(resolved_path) => self.serve_file(session, site, &resolved_path).await,
26            None => self.handle_not_found(session, site).await,
27        }
28    }
29
30    /// Resolve the requested path to a file on disk, checking for index files and path safety.
31    async fn resolve_file_path(&self, site: &SiteConfig, request_path: &str) -> Option<String> {
32        let clean_path = self.clean_path(request_path);
33
34        // Security check: ensure the path is safe before proceeding
35        if !self.is_path_safe(&site.static_dir, &clean_path) {
36            log::warn!("Blocked path traversal attempt: {}", request_path);
37            return None;
38        }
39
40        // Try exact path first
41        let file_path = format!("{}/{}", site.static_dir, clean_path);
42        if self.is_file_accessible(&file_path).await {
43            return Some(file_path);
44        }
45
46        // If path ends with '/', try index files
47        if clean_path.ends_with('/') || clean_path.is_empty() {
48            for index_file in site.get_index_files() {
49                let index_path = format!("{}/{}{}", site.static_dir, clean_path, index_file);
50                if self.is_file_accessible(&index_path).await {
51                    return Some(index_path);
52                }
53            }
54        } else {
55            // Try adding '/' and looking for index files
56            for index_file in site.get_index_files() {
57                let index_path = format!("{}/{}/{}", site.static_dir, clean_path, index_file);
58                if self.is_file_accessible(&index_path).await {
59                    return Some(index_path);
60                }
61            }
62        }
63
64        None
65    }
66
67    /// Clean and normalize a request path, removing dangerous components and normalizing separators.
68    fn clean_path(&self, path: &str) -> String {
69        // Remove query parameters and fragments
70        let path = path.split('?').next().unwrap_or(path);
71        let path = path.split('#').next().unwrap_or(path);
72
73        // Handle root path
74        if path == "/" {
75            return "".to_string();
76        }
77
78        // Remove leading slash for joining with static_dir
79        let clean = path.strip_prefix('/').unwrap_or(path);
80
81        // Normalize path separators and remove dangerous sequences
82        let clean = clean.replace('\\', "/"); // Normalize Windows paths
83        let clean = clean.replace("//", "/"); // Remove double slashes
84
85        // Split path and filter out dangerous components
86        let components: Vec<&str> = clean
87            .split('/')
88            .filter(|component| {
89                !component.is_empty()
90                    && *component != "."
91                    && *component != ".."
92                    && !component.contains('\0') // Null byte injection protection
93            })
94            .collect();
95
96        components.join("/")
97    }
98
99    /// Check if a file exists, is a regular file, and is within the allowed size limit.
100    async fn is_file_accessible(&self, file_path: &str) -> bool {
101        let path = Path::new(file_path);
102
103        // Check if file exists and is a regular file
104        if let Ok(metadata) = fs::metadata(path).await {
105            if metadata.is_file() {
106                // Additional security check: file size limit (100MB max)
107                const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
108                if metadata.len() > MAX_FILE_SIZE {
109                    log::warn!(
110                        "File too large, rejecting: {} ({} bytes)",
111                        file_path,
112                        metadata.len()
113                    );
114                    return false;
115                }
116                return true;
117            }
118        }
119
120        false
121    }
122
123    /// Serve the given file to the client, applying headers and compression as needed.
124    async fn serve_file(
125        &self,
126        session: &mut Session,
127        site: &SiteConfig,
128        file_path: &str,
129    ) -> Result<()> {
130        match fs::read(file_path).await {
131            Ok(content) => {
132                let mime_type = self.get_mime_type(file_path);
133                let mut header = ResponseHeader::build(200, Some(4))?;
134
135                // Basic headers
136                header.insert_header("Content-Type", mime_type)?;
137                header.insert_header("Content-Length", content.len().to_string())?;
138
139                // Cache headers
140                let is_static = self.is_static_file(file_path);
141                for (key, value) in site.get_cache_headers(is_static) {
142                    header.insert_header(key, value)?;
143                }
144
145                // CORS headers
146                for (key, value) in site.get_cors_headers() {
147                    header.insert_header(key, value)?;
148                }
149
150                // Custom site headers
151                for (key, value) in &site.headers {
152                    header.insert_header(key.clone(), value.clone())?;
153                }
154
155                // Check if content should be compressed
156                let content_len = content.len();
157                let compression_middleware = CompressionMiddleware::new(site.compression.clone());
158
159                let (final_content, encoding) = if compression_middleware
160                    .should_compress(mime_type, content_len)
161                {
162                    // Get the best compression method based on Accept-Encoding header
163                    let accept_encoding = session
164                        .req_header()
165                        .headers
166                        .get("accept-encoding")
167                        .and_then(|h| h.to_str().ok());
168
169                    let compression_method =
170                        compression_middleware.get_best_compression(accept_encoding);
171
172                    match compression_middleware.compress(&content, compression_method.clone()) {
173                        Ok(compressed_content) => {
174                            log::debug!(
175                                "Compressed {} bytes to {} bytes using {:?} ({}% reduction)",
176                                content_len,
177                                compressed_content.len(),
178                                compression_method,
179                                ((content_len - compressed_content.len()) * 100) / content_len
180                            );
181                            (compressed_content.to_vec(), Some(compression_method))
182                        }
183                        Err(e) => {
184                            log::warn!("Compression failed: {}, serving uncompressed", e);
185                            (content, None)
186                        }
187                    }
188                } else {
189                    (content, None)
190                };
191
192                // Update content length and add encoding header
193                header.remove_header("Content-Length");
194                header.insert_header("Content-Length", final_content.len().to_string())?;
195
196                if let Some(method) = encoding {
197                    if !matches!(method, CompressionMethod::None) {
198                        header.insert_header("Content-Encoding", method.as_str())?;
199                        header.insert_header("Vary", "Accept-Encoding")?;
200                    }
201                }
202
203                session
204                    .write_response_header(Box::new(header), false)
205                    .await?;
206
207                let final_content_len = final_content.len();
208                session
209                    .write_response_body(Some(final_content.into()), true)
210                    .await?;
211
212                log::debug!(
213                    "Served file: {} ({} -> {} bytes)",
214                    file_path,
215                    content_len,
216                    final_content_len
217                );
218            }
219            Err(e) => {
220                log::warn!("Failed to read file {}: {}", file_path, e);
221                self.handle_not_found(session, site).await?;
222            }
223        }
224
225        Ok(())
226    }
227
228    async fn handle_not_found(&self, session: &mut Session, site: &SiteConfig) -> Result<()> {
229        // Check if site has custom 404 page
230        if let Some(error_page) = site.get_error_page(404) {
231            let error_page_path = format!("{}/{}", site.static_dir, error_page);
232            if let Ok(content) = fs::read(&error_page_path).await {
233                let mut header = ResponseHeader::build(404, Some(3))?;
234                header.insert_header("Content-Type", "text/html")?;
235                header.insert_header("Content-Length", content.len().to_string())?;
236
237                // Custom site headers
238                for (key, value) in &site.headers {
239                    header.insert_header(key.clone(), value.clone())?;
240                }
241
242                session
243                    .write_response_header(Box::new(header), false)
244                    .await?;
245                session
246                    .write_response_body(Some(content.into()), true)
247                    .await?;
248                return Ok(());
249            }
250        }
251
252        // Default 404 response
253        let error_html = r#"<!DOCTYPE html>
254<html>
255<head>
256    <title>404 Not Found</title>
257    <style>
258        body { font-family: Arial, sans-serif; text-align: center; margin-top: 100px; }
259        h1 { color: #666; }
260        p { color: #999; }
261    </style>
262</head>
263<body>
264    <h1>404 Not Found</h1>
265    <p>The requested resource was not found on this server.</p>
266</body>
267</html>"#;
268
269        let mut header = ResponseHeader::build(404, Some(3))?;
270        header.insert_header("Content-Type", "text/html")?;
271        header.insert_header("Content-Length", error_html.len().to_string())?;
272
273        // Custom site headers
274        for (key, value) in &site.headers {
275            header.insert_header(key.clone(), value.clone())?;
276        }
277
278        session
279            .write_response_header(Box::new(header), false)
280            .await?;
281        session
282            .write_response_body(Some(error_html.as_bytes().to_vec().into()), true)
283            .await?;
284
285        Ok(())
286    }
287
288    fn get_mime_type(&self, file_path: &str) -> &'static str {
289        let path = Path::new(file_path);
290        match path.extension().and_then(|ext| ext.to_str()) {
291            Some("html") | Some("htm") => "text/html; charset=utf-8",
292            Some("css") => "text/css; charset=utf-8",
293            Some("js") | Some("mjs") => "application/javascript; charset=utf-8",
294            Some("json") => "application/json; charset=utf-8",
295            Some("png") => "image/png",
296            Some("jpg") | Some("jpeg") => "image/jpeg",
297            Some("gif") => "image/gif",
298            Some("svg") => "image/svg+xml",
299            Some("ico") => "image/x-icon",
300            Some("txt") => "text/plain; charset=utf-8",
301            Some("pdf") => "application/pdf",
302            Some("woff") | Some("woff2") => "font/woff",
303            Some("ttf") => "font/ttf",
304            Some("otf") => "font/otf",
305            Some("eot") => "application/vnd.ms-fontobject",
306            Some("xml") => "application/xml; charset=utf-8",
307            Some("wasm") => "application/wasm",
308            Some("webp") => "image/webp",
309            Some("avif") => "image/avif",
310            Some("mp4") => "video/mp4",
311            Some("webm") => "video/webm",
312            Some("mp3") => "audio/mpeg",
313            Some("wav") => "audio/wav",
314            Some("ogg") => "audio/ogg",
315            Some("zip") => "application/zip",
316            Some("gz") => "application/gzip",
317            Some("tar") => "application/x-tar",
318            Some("md") => "text/markdown; charset=utf-8",
319            Some("yaml") | Some("yml") => "application/x-yaml; charset=utf-8",
320            Some("toml") => "application/toml; charset=utf-8",
321            Some("csv") => "text/csv; charset=utf-8",
322            Some("manifest") => "text/cache-manifest; charset=utf-8",
323            Some("webmanifest") => "application/manifest+json; charset=utf-8",
324            Some("rss") => "application/rss+xml; charset=utf-8",
325            Some("atom") => "application/atom+xml; charset=utf-8",
326            _ => "application/octet-stream",
327        }
328    }
329
330    fn is_static_file(&self, file_path: &str) -> bool {
331        let path = Path::new(file_path);
332        if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
333            matches!(
334                ext,
335                "css"
336                    | "js"
337                    | "png"
338                    | "jpg"
339                    | "jpeg"
340                    | "gif"
341                    | "svg"
342                    | "ico"
343                    | "woff"
344                    | "woff2"
345                    | "ttf"
346                    | "otf"
347                    | "eot"
348                    | "pdf"
349                    | "zip"
350                    | "mp4"
351                    | "webm"
352                    | "mp3"
353                    | "wav"
354                    | "ogg"
355                    | "webp"
356                    | "avif"
357            )
358        } else {
359            false
360        }
361    }
362
363    // Security function to prevent path traversal
364    fn is_path_safe(&self, static_dir: &str, requested_path: &str) -> bool {
365        let static_path = Path::new(static_dir);
366        let requested_path = static_path.join(requested_path);
367
368        // Canonicalize paths to resolve .. and . components
369        if let (Ok(static_canonical), Ok(requested_canonical)) =
370            (static_path.canonicalize(), requested_path.canonicalize())
371        {
372            requested_canonical.starts_with(static_canonical)
373        } else {
374            // If canonicalization fails, be conservative and reject
375            // This handles cases where the path doesn't exist or has permission issues
376            let static_abs = match std::fs::canonicalize(static_path) {
377                Ok(path) => path,
378                Err(_) => return false,
379            };
380
381            // For non-existent files, check if the parent directory is safe
382            let mut check_path = requested_path.clone();
383            while let Some(parent) = check_path.parent() {
384                if let Ok(parent_canonical) = parent.canonicalize() {
385                    return parent_canonical.starts_with(&static_abs);
386                }
387                check_path = parent.to_path_buf();
388            }
389
390            false
391        }
392    }
393}
394
395impl Default for StaticFileHandler {
396    fn default() -> Self {
397        Self::new()
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_mime_type_detection() {
407        let handler = StaticFileHandler::new();
408
409        assert_eq!(
410            handler.get_mime_type("test.html"),
411            "text/html; charset=utf-8"
412        );
413        assert_eq!(handler.get_mime_type("test.css"), "text/css; charset=utf-8");
414        assert_eq!(
415            handler.get_mime_type("test.js"),
416            "application/javascript; charset=utf-8"
417        );
418        assert_eq!(handler.get_mime_type("test.png"), "image/png");
419        assert_eq!(handler.get_mime_type("test.wasm"), "application/wasm");
420        assert_eq!(
421            handler.get_mime_type("test.unknown"),
422            "application/octet-stream"
423        );
424    }
425
426    #[test]
427    fn test_clean_path() {
428        let handler = StaticFileHandler::new();
429
430        assert_eq!(handler.clean_path("/"), ""); // Root path becomes empty for joining with static_dir
431        assert_eq!(handler.clean_path("/test.html"), "test.html");
432        assert_eq!(handler.clean_path("/path/to/file.css"), "path/to/file.css");
433        assert_eq!(handler.clean_path("/test.html?query=1"), "test.html");
434        assert_eq!(handler.clean_path("/test.html#fragment"), "test.html");
435        assert_eq!(
436            handler.clean_path("/test.html?query=1#fragment"),
437            "test.html"
438        );
439
440        // Test security: path traversal protection
441        // "../.." components are filtered out, leaving only valid path components
442        assert_eq!(handler.clean_path("/../../../etc/passwd"), "etc/passwd");
443        assert_eq!(handler.clean_path("/test/../../../secret"), "test/secret"); // ".." filtered out, leaving "test" and "secret"
444        assert_eq!(handler.clean_path("./test.html"), "test.html"); // "." filtered out
445        assert_eq!(handler.clean_path("/./test/../file.css"), "test/file.css"); // "." and ".." filtered out
446
447        // Test null byte injection protection
448        assert_eq!(handler.clean_path("/test\0.html"), ""); // Component with null byte is filtered out
449
450        // Test double slash normalization
451        assert_eq!(handler.clean_path("//test//file.js"), "test/file.js");
452
453        // Test Windows path normalization
454        assert_eq!(
455            handler.clean_path("/path\\to\\file.css"),
456            "path/to/file.css"
457        );
458    }
459
460    #[test]
461    fn test_is_static_file() {
462        let handler = StaticFileHandler::new();
463
464        assert!(handler.is_static_file("test.css"));
465        assert!(handler.is_static_file("test.js"));
466        assert!(handler.is_static_file("test.png"));
467        assert!(handler.is_static_file("test.woff"));
468        assert!(!handler.is_static_file("test.html"));
469        assert!(!handler.is_static_file("test.json"));
470        assert!(!handler.is_static_file("test.unknown"));
471    }
472
473    #[test]
474    fn test_path_safety() {
475        let handler = StaticFileHandler::new();
476
477        // Create a temporary directory for testing
478        let temp_dir = std::env::temp_dir().join("bws_test_static");
479        std::fs::create_dir_all(&temp_dir).unwrap();
480
481        // Create a test file
482        let index_file = temp_dir.join("index.html");
483        std::fs::write(&index_file, "test content").unwrap();
484
485        // Create assets directory and file
486        let assets_dir = temp_dir.join("assets");
487        std::fs::create_dir_all(&assets_dir).unwrap();
488        let css_file = assets_dir.join("style.css");
489        std::fs::write(&css_file, "body { color: black; }").unwrap();
490
491        let static_dir = temp_dir.to_str().unwrap();
492
493        assert!(handler.is_path_safe(static_dir, "index.html"));
494        assert!(handler.is_path_safe(static_dir, "assets/style.css"));
495
496        // Clean up
497        std::fs::remove_dir_all(&temp_dir).unwrap();
498    }
499}