Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 97 additions & 26 deletions src/handler/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,27 @@ use std::{
path::{Path, PathBuf},
};

/// Attempts to safely join a root directory and a requested relative path.
use crate::{
handler::handler::{Handler, HandlerFactory},
hteapot::{HttpHeaders, HttpResponse, HttpStatus},
utils::{Context, get_mime_tipe},
};

/// Safely joins a root directory with a requested relative path.
///
/// Ensures that the resulting path:
/// - Resolves symbolic links and `..` segments via `canonicalize`
/// - Remains within the bounds of the specified root directory
/// - Actually exists on disk
/// Ensures that:
/// - Symbolic links and `..` segments are resolved (`canonicalize`)
/// - The resulting path stays within `root`
/// - The path exists on disk
///
/// This protects against directory traversal vulnerabilities, such as accessing
/// files outside of the intended root (e.g., `/etc/passwd`).
/// This prevents directory traversal attacks (e.g., accessing `/etc/passwd`).
///
/// # Arguments
/// * `root` - The root directory from which serving is allowed.
/// * `requested_path` - The path requested by the client (usually from the URL).
/// * `root` - Allowed root directory.
/// * `requested_path` - Path requested by the client.
///
/// # Returns
/// `Some(PathBuf)` if the resolved path exists and is within the root. `None` otherwise.
/// `Some(PathBuf)` if the path is valid and exists, `None` otherwise.
///
/// # Example
/// ```
Expand All @@ -34,27 +39,93 @@ pub fn safe_join_paths(root: &str, requested_path: &str) -> Option<PathBuf> {
}

let canonical_path = requested_full_path.canonicalize().ok()?;

if canonical_path.starts_with(&root_path) {
Some(canonical_path)
} else {
None
}
}

/// Reads the content of a file from the filesystem.
///
/// # Arguments
/// * `path` - A reference to a `PathBuf` representing the target file.
///
/// # Returns
/// `Some(Vec<u8>)` if the file is read successfully, or `None` if an error occurs.
///
/// # Notes
/// Uses `PathBuf` instead of `&str` to clearly express intent and reduce path handling bugs.
///
/// # See Also
/// [`std::fs::read`](https://doc.rust-lang.org/std/fs/fn.read.html)
pub fn serve_file(path: &PathBuf) -> Option<Vec<u8>> {
fs::read(path).ok()
/// Handles serving static files from a root directory, including index files.
pub struct FileHandler {
root: String,
index: String,
}

impl FileHandler {}

impl Handler for FileHandler {
fn run(&self, ctx: &mut Context) -> Box<dyn crate::hteapot::HttpResponseCommon> {
let logger = ctx.log.with_component("HTTP");

// Resolve the requested path safely
let safe_path_result = if ctx.request.path == "/" {
// Special handling for the root path: serve the index file
Path::new(&self.root)
.canonicalize()
.ok()
.map(|root_path| root_path.join(&self.index))
.filter(|index_path| index_path.exists())
} else {
// Other paths: use safe join
safe_join_paths(&self.root, &ctx.request.path)
};

// Handle directories or invalid paths
let safe_path = match safe_path_result {
Some(path) => {
if path.is_dir() {
let index_path = path.join(&self.index);
if index_path.exists() {
index_path
} else {
logger.warn(format!(
"Index file not found in directory: {}",
ctx.request.path
));
return HttpResponse::new(HttpStatus::NotFound, "Index not found", None);
}
} else {
path
}
}
None => {
logger.warn(format!(
"Path not found or access denied: {}",
ctx.request.path
));
return HttpResponse::new(HttpStatus::NotFound, "Not found", None);
}
};

// Determine MIME type
let mimetype = get_mime_tipe(&safe_path.to_string_lossy().to_string());

// Read file content
match fs::read(&safe_path).ok() {
Some(content) => {
let mut headers = HttpHeaders::new();
headers.insert("Content-Type", &mimetype);
headers.insert("X-Content-Type-Options", "nosniff");
let response = HttpResponse::new(HttpStatus::OK, content, Some(headers));

// Cache the response if caching is enabled
if let Some(cache) = ctx.cache.as_deref_mut() {
cache.set(ctx.request.clone(), (*response).clone());
}

response
}
None => HttpResponse::new(HttpStatus::NotFound, "Not found", None),
}
}
}

impl HandlerFactory for FileHandler {
fn is(ctx: &Context) -> Option<Box<dyn Handler>> {
Some(Box::new(FileHandler {
root: ctx.config.root.to_string(),
index: ctx.config.index.to_string(),
}))
}
}
9 changes: 6 additions & 3 deletions src/handler/handler.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use crate::{config::Config, hteapot::HttpRequest};
use crate::{hteapot::HttpResponseCommon, utils::Context};

pub trait Handler {
fn is(config: Config, request: HttpRequest) -> bool;
fn run(request: HttpRequest) -> Box<HttpRequest>;
fn run(&self, context: &mut Context) -> Box<dyn HttpResponseCommon>;
}

pub trait HandlerFactory {
fn is(context: &Context) -> Option<Box<dyn Handler>>;
}
62 changes: 61 additions & 1 deletion src/handler/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,63 @@
use crate::{
handler::handler::{Handler, HandlerFactory},
utils::Context,
};

pub mod file;
pub mod handler;
mod handler;
pub mod proxy;

/// Type alias for a handler factory function.
///
/// A factory takes a reference to the current `Config` and `HttpRequest`
/// and returns an `Option<Box<dyn Handler>>`. It returns `Some(handler)`
/// if it can handle the request, or `None` if it cannot.
type Factory = fn(&Context) -> Option<Box<dyn Handler>>;

/// List of all available handler factories.
///
/// New handlers can be added to this array to make them available
/// for request processing.
static HANDLERS: &[Factory] = &[proxy::ProxyHandler::is, file::FileHandler::is];

/// Returns the first handler that can process the given request.
///
/// Iterates over all registered handler factories in `HANDLERS`.
/// Calls each factory with the provided `config` and `request`.
/// Returns `Some(Box<dyn Handler>)` if a suitable handler is found,
/// or `None` if no handler can handle the request.
///
/// # Examples
///
/// ```rust
/// let handler = get_handler(&config, &request);
/// if let Some(h) = handler {
/// let response = h.run(&request);
/// // process the response
/// }
/// ```

pub struct HandlerEngine {
handlers: Vec<Factory>,
}

impl HandlerEngine {
pub fn new() -> HandlerEngine {
let mut handlers = Vec::new();
handlers.extend_from_slice(HANDLERS);
HandlerEngine { handlers }
}

pub fn add_handler(&mut self, handler: Factory) {
self.handlers.insert(0, handler);
}

pub fn get_handler(&self, ctx: &Context) -> Option<Box<dyn Handler>> {
for h in self.handlers.iter() {
if let Some(handler) = h(ctx) {
return Some(handler);
}
}
None
}
}
116 changes: 81 additions & 35 deletions src/handler/proxy.rs
Original file line number Diff line number Diff line change
@@ -1,41 +1,87 @@
use crate::config::Config;
use crate::hteapot::HttpRequest;
use crate::handler::handler::{Handler, HandlerFactory};
use crate::hteapot::{HttpMethod, HttpResponse, HttpResponseCommon, HttpStatus, TunnelResponse};
use crate::utils::Context;

/// Determines whether a given HTTP request should be proxied based on the configuration.
/// Handles HTTP proxying based on server configuration.
///
/// If a matching proxy rule is found in `config.proxy_rules`, the function rewrites the
/// request path and updates the `Host` header accordingly.
/// Determines whether a request matches any proxy rules and forwards it
/// to the corresponding upstream server, rewriting the path and `Host` header
/// as needed.
///
/// # Arguments
/// * `config` - Server configuration containing proxy rules.
/// * `req` - The original HTTP request.
///
/// # Returns
/// `Some((proxy_url, modified_request))` if the request should be proxied, otherwise `None`.
pub fn is_proxy(config: &Config, req: HttpRequest) -> Option<(String, HttpRequest)> {
for proxy_path in config.proxy_rules.keys() {
let path_match = req.path.strip_prefix(proxy_path);
if path_match.is_some() {
let new_path = path_match.unwrap();
let url = config.proxy_rules.get(proxy_path).unwrap().clone();
let url = if url.is_empty() {
let proxy_url = req.headers.get("host")?;
proxy_url.to_owned()
} else {
url
};
let mut proxy_req = req.clone();
proxy_req.path = new_path.to_string();
proxy_req.headers.remove("Host");
let host_parts: Vec<_> = url.split("://").collect();
let host = if host_parts.len() == 1 {
host_parts.first().unwrap()
} else {
host_parts.last().clone().unwrap()
};
proxy_req.headers.insert("host", host);
return Some((url, proxy_req));
/// # Fields
/// * `new_path` - Path to use for the proxied request.
/// * `url` - Target upstream URL.
pub struct ProxyHandler {
new_path: String,
url: String,
}

impl Handler for ProxyHandler {
fn run(&self, ctx: &mut Context) -> Box<dyn HttpResponseCommon> {
let _proxy_logger = &ctx.log.with_component("proxy");

// Return a tunnel response immediately for OPTIONS requests
if ctx.request.method == HttpMethod::OPTIONS {
return TunnelResponse::new(&ctx.request.path);
}

// Prepare a modified request for proxying
let mut proxy_req = ctx.request.clone();
proxy_req.path = self.new_path.clone();
proxy_req.headers.remove("Host");

// Determine the upstream host from the URL
let host_parts: Vec<&str> = self.url.split("://").collect();
let host = if host_parts.len() == 1 {
host_parts.first().unwrap()
} else {
host_parts.last().unwrap()
};
proxy_req.headers.insert("host", host);

// Forward the request and handle errors
let response = proxy_req.brew(&self.url).unwrap_or(HttpResponse::new(
HttpStatus::NotAcceptable,
"",
None,
));

// Cache the response if caching is enabled
if let Some(cache) = ctx.cache.as_deref_mut() {
cache.set(ctx.request.clone(), (*response).clone());
}

response
}
}

impl HandlerFactory for ProxyHandler {
fn is(ctx: &Context) -> Option<Box<dyn Handler>> {
// OPTIONS requests are always handled
if ctx.request.method == HttpMethod::OPTIONS {
return Some(Box::new(ProxyHandler {
url: String::new(),
new_path: String::new(),
}));
}

// Check if the request matches any configured proxy rules
for proxy_path in ctx.config.proxy_rules.keys() {
if let Some(path_match) = ctx.request.path.strip_prefix(proxy_path) {
let new_path = path_match.to_string();
let url = ctx.config.proxy_rules.get(proxy_path).unwrap();
let url = if url.is_empty() {
// If the rule URL is empty, fallback to Host header
let proxy_url = ctx.request.headers.get("host")?;
proxy_url.to_owned()
} else {
url.to_string()
};

return Some(Box::new(ProxyHandler { url, new_path }));
}
}

None
}
None
}
4 changes: 2 additions & 2 deletions src/hteapot/brew.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ mod tests {
let mut request = HttpRequest::new(HttpMethod::GET, "/data");
request.headers.insert("Content-Type", "application/json");
assert_eq!(
request.headers.get("Content-Type"),
Some(&"application/json".to_string())
request.headers.get_owned("Content-Type"),
Some("application/json".to_string())
);
}

Expand Down
Loading