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}