warpdrive_proxy/middleware/
sendfile.rs

1//! X-Sendfile middleware
2//!
3//! Implements X-Sendfile/X-Accel-Redirect support for efficient file serving.
4//! When the upstream application sends an X-Sendfile header, this middleware
5//! intercepts it and serves the file directly from disk, bypassing application
6//! processing for better performance.
7//!
8//! # How it works
9//!
10//! 1. Request phase: Add X-Sendfile-Type header to inform upstream we support it
11//! 2. Response phase: Check for X-Sendfile header in upstream response
12//! 3. If present: Serve file directly, remove X-Sendfile header from response
13//! 4. If absent: Pass through response as-is
14//!
15//! # Security
16//!
17//! This implementation trusts the upstream application to only send safe file paths.
18//! In production, consider adding path validation to prevent directory traversal.
19//!
20//! # Note on Pingora Limitations
21//!
22//! Pingora's filter system doesn't support replacing response bodies in filters.
23//! This implementation marks sendfile intent in context; actual file serving
24//! would need custom response body handling or a separate file server module.
25
26use async_trait::async_trait;
27use bytes::Bytes;
28use pingora::http::ResponseHeader;
29use pingora::prelude::*;
30use std::path::PathBuf;
31use tokio::fs;
32use tracing::{debug, warn};
33
34use super::{Middleware, MiddlewareContext};
35
36/// X-Sendfile middleware
37///
38/// Handles X-Sendfile headers from upstream applications to serve files
39/// directly from disk instead of proxying through the application.
40pub struct SendfileMiddleware {
41    /// Whether sendfile support is enabled
42    enabled: bool,
43}
44
45impl SendfileMiddleware {
46    /// Create new sendfile middleware
47    pub fn new() -> Self {
48        Self { enabled: true }
49    }
50
51    /// Validate file path for security
52    ///
53    /// This performs basic validation to prevent directory traversal attacks.
54    /// In production, you may want more strict validation based on allowed directories.
55    pub(crate) fn validate_path(path: &str) -> bool {
56        // Reject directory traversal attempts
57        if path.starts_with("../") || path.contains("/../") {
58            return false;
59        }
60
61        // Path must exist and be a file
62        let path_buf = PathBuf::from(path);
63        if !path_buf.exists() {
64            return false;
65        }
66
67        if !path_buf.is_file() {
68            return false;
69        }
70
71        true
72    }
73
74    /// Get file metadata for Content-Length header
75    ///
76    /// This is critical when Content-Encoding is present to ensure
77    /// the client knows the actual file size being served.
78    fn get_file_size(path: &str) -> Option<u64> {
79        std::fs::metadata(path).ok().map(|meta| meta.len())
80    }
81}
82
83impl Default for SendfileMiddleware {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89#[async_trait]
90impl Middleware for SendfileMiddleware {
91    /// Add X-Sendfile-Type header to request
92    ///
93    /// This informs the upstream application that we support X-Sendfile,
94    /// allowing it to respond with X-Sendfile headers for static files.
95    async fn request_filter(
96        &self,
97        session: &mut Session,
98        _ctx: &mut MiddlewareContext,
99    ) -> Result<()> {
100        if self.enabled {
101            session
102                .req_header_mut()
103                .insert_header("X-Sendfile-Type", "X-Sendfile")?;
104            debug!("Added X-Sendfile-Type header to request");
105        }
106
107        Ok(())
108    }
109
110    /// Check for X-Sendfile header in response and handle file serving
111    ///
112    /// If X-Sendfile header is present:
113    /// 1. Extract file path
114    /// 2. Validate path for security
115    /// 3. Set Content-Length from file size
116    /// 4. Mark sendfile as active in context
117    /// 5. Remove X-Sendfile header (internal implementation detail)
118    async fn response_filter(
119        &self,
120        _session: &mut Session,
121        upstream_response: &mut ResponseHeader,
122        ctx: &mut MiddlewareContext,
123    ) -> Result<()> {
124        if !self.enabled {
125            return Ok(());
126        }
127
128        // Check if response has X-Sendfile header and extract the path before mutating
129        let sendfile_path_opt = upstream_response
130            .headers
131            .get("x-sendfile")
132            .and_then(|v| v.to_str().ok())
133            .map(|s| s.to_string());
134
135        if let Some(file_path) = sendfile_path_opt {
136            debug!("X-Sendfile header found: {}", file_path);
137
138            // Validate file path
139            if !Self::validate_path(&file_path) {
140                warn!("Invalid X-Sendfile path: {}", file_path);
141                return Err(Error::explain(
142                    ErrorType::HTTPStatus(403),
143                    "Invalid X-Sendfile path",
144                ));
145            }
146
147            // Get file size for Content-Length
148            if let Some(file_size) = Self::get_file_size(&file_path) {
149                // Set Content-Length header
150                // This is critical when Content-Encoding is present
151                upstream_response.insert_header("Content-Length", file_size.to_string())?;
152
153                debug!("Set Content-Length to {} for X-Sendfile", file_size);
154            } else {
155                // If we can't get file size, remove Content-Length to be safe
156                upstream_response.remove_header("Content-Length");
157            }
158
159            // Load file contents into memory for downstream body replacement
160            match fs::read(&file_path).await {
161                Ok(contents) => {
162                    let body = Bytes::from(contents);
163                    ctx.sendfile.activate(file_path.clone(), body);
164                }
165                Err(err) => {
166                    warn!("Failed to read X-Sendfile path {}: {}", file_path, err);
167                    return Err(Error::explain(
168                        ErrorType::HTTPStatus(500),
169                        "Failed to read X-Sendfile path",
170                    ));
171                }
172            }
173
174            // Remove X-Sendfile header (internal implementation detail)
175            upstream_response.remove_header("X-Sendfile");
176
177            debug!("X-Sendfile configured for: {}", file_path);
178
179            // Actual body replacement occurs in response_body_filter via MiddlewareContext
180        }
181
182        Ok(())
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use std::env;
190
191    #[test]
192    fn test_validate_path() {
193        // Invalid paths (directory traversal)
194        assert!(!SendfileMiddleware::validate_path("../etc/passwd"));
195        assert!(!SendfileMiddleware::validate_path(
196            "foo/../../../etc/passwd"
197        ));
198
199        // Non-existent file should fail validation
200        assert!(!SendfileMiddleware::validate_path(
201            "/nonexistent/path/to/file.txt"
202        ));
203
204        // This source file should exist and be valid
205        let this_file = file!();
206        assert!(SendfileMiddleware::validate_path(this_file));
207
208        // Absolute paths are allowed when they point to files
209        let abs_path = env::current_dir().unwrap().join(this_file);
210        let abs_path = abs_path.to_string_lossy();
211        assert!(SendfileMiddleware::validate_path(abs_path.as_ref()));
212    }
213
214    #[test]
215    fn test_sendfile_middleware_creation() {
216        let middleware = SendfileMiddleware::new();
217        assert!(middleware.enabled);
218
219        let middleware_default = SendfileMiddleware::default();
220        assert!(middleware_default.enabled);
221    }
222
223    #[test]
224    fn test_get_file_size() {
225        // Test with non-existent file
226        assert!(SendfileMiddleware::get_file_size("/non/existent/file.txt").is_none());
227
228        // Test with this source file (should exist)
229        let this_file = file!();
230        assert!(SendfileMiddleware::get_file_size(this_file).is_some());
231    }
232}