use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use tokio::sync::RwLock;
use crate::error::AppError;
use crate::website::cache::WebsiteCache;
use crate::website::paths::{PathError, canonical_within_root};
use crate::website::storage::WebsiteRoot;
#[derive(Debug, Clone)]
pub struct WebsiteState {
pub root: WebsiteRoot,
pub cache: WebsiteCache,
pub executable_blocklist: Vec<String>,
pub cache_control: String,
pub csp_override_file: String,
pub csp_cache: CspOverrideCache,
}
#[derive(Debug, Deserialize, Default)]
pub struct WebsiteOverride {
pub csp: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CspOverrideCache {
inner: Arc<RwLock<Option<CachedCsp>>>,
ttl: Duration,
}
#[derive(Debug, Clone)]
struct CachedCsp {
value: Option<String>,
fetched_at: Instant,
}
impl CspOverrideCache {
pub fn new(ttl_secs: u64) -> Self {
Self {
inner: Arc::new(RwLock::new(None)),
ttl: Duration::from_secs(ttl_secs),
}
}
async fn get(&self, serve_root: &Path, override_file: &str) -> Option<String> {
if let Some(entry) = self.inner.read().await.as_ref()
&& entry.fetched_at.elapsed() < self.ttl
{
return entry.value.clone();
}
let value = read_csp_override(serve_root, override_file).await;
*self.inner.write().await = Some(CachedCsp {
value: value.clone(),
fetched_at: Instant::now(),
});
value
}
}
pub async fn serve(State(state): State<WebsiteState>, req: Request<Body>) -> Response {
let if_none_match = req
.headers()
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
match serve_inner(&state, req.uri(), if_none_match.as_deref()).await {
Ok(resp) => resp,
Err(err) => err.into_response(),
}
}
async fn serve_inner(
state: &WebsiteState,
uri: &Uri,
if_none_match: Option<&str>,
) -> Result<Response, AppError> {
let raw_path = uri.path();
let req_path = if raw_path == "/" || raw_path.ends_with('/') {
format!("{raw_path}index.html")
} else {
raw_path.to_string()
};
let serve_root = state.root.serve_root();
let resolved = match canonical_within_root(&serve_root, &req_path, &state.executable_blocklist)
{
Ok(p) => p,
Err(PathError::NotFound) => {
return Err(AppError::NotFound(format!("no such resource: {raw_path}")));
}
Err(PathError::Hidden) => {
return Err(AppError::NotFound(format!("no such resource: {raw_path}")));
}
Err(PathError::BlockedExtension(ext)) => {
return Err(AppError::Forbidden(format!(
"extension {ext} is blocked by website.executable_blocklist"
)));
}
Err(PathError::Escape | PathError::ControlChars | PathError::NonNfc) => {
return Err(AppError::Validation(format!(
"request path rejected by website path-safety: {raw_path}"
)));
}
Err(PathError::ExecBit) => {
return Err(AppError::Forbidden(
"file has executable bit set; refusing to serve".into(),
));
}
};
if let Ok(meta) = tokio::fs::metadata(&resolved).await
&& meta.is_dir()
{
return Err(AppError::NotFound("path resolves to a directory".into()));
}
let cached = state
.cache
.get(&resolved)
.await
.map_err(|e| AppError::Internal(format!("failed to read website file: {e}")))?;
let etag = format!("\"{}\"", cached.digest_hex);
if let Some(inm) = if_none_match
&& etag_matches(inm, &etag)
{
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag)
.header(header::CACHE_CONTROL, state.cache_control.clone())
.body(Body::empty())
.map_err(|e| AppError::Internal(format!("response build: {e}")));
}
let mime = mime_guess::from_path(&resolved)
.first_or_octet_stream()
.to_string();
let csp_override = state
.csp_cache
.get(&serve_root, &state.csp_override_file)
.await;
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::ETAG, etag.clone())
.header(header::CACHE_CONTROL, state.cache_control.clone());
if let Some(csp) = csp_override {
builder = builder.header(header::CONTENT_SECURITY_POLICY, csp);
}
builder
.body(Body::from((*cached.body).clone()))
.map_err(|e| AppError::Internal(format!("response build: {e}")))
}
fn etag_matches(if_none_match: &str, etag: &str) -> bool {
if_none_match.split(',').any(|tok| {
let tok = tok.trim();
let tok = tok.strip_prefix("W/").unwrap_or(tok);
tok == "*" || tok == etag
})
}
async fn read_csp_override(serve_root: &Path, override_file: &str) -> Option<String> {
let path: PathBuf = serve_root.join(override_file);
let bytes = tokio::fs::read(&path).await.ok()?;
let parsed: WebsiteOverride = toml::from_slice(&bytes).ok()?;
let csp = parsed.csp?;
match validate_csp_override(&csp) {
Some(valid) => Some(valid),
None => {
tracing::warn!(
file = %path.display(),
"per-site CSP override weakens script-src/object-src/base-uri below the \
daemon default; ignoring it and serving the default CSP",
);
None
}
}
}
fn validate_csp_override(csp: &str) -> Option<String> {
let directives = parse_csp(csp);
let effective = |name: &str| -> Option<Vec<String>> {
directives
.get(name)
.or_else(|| directives.get("default-src"))
.cloned()
};
match effective("script-src") {
Some(sources) if !sources_are_loose(&sources, true) => {}
_ => return None,
}
if let Some(sources) = effective("object-src")
&& sources_are_loose(&sources, false)
{
return None;
}
if let Some(sources) = directives.get("base-uri")
&& sources_are_loose(sources, false)
{
return None;
}
Some(csp.trim().to_string())
}
fn parse_csp(csp: &str) -> std::collections::HashMap<String, Vec<String>> {
let mut out = std::collections::HashMap::new();
for segment in csp.split(';') {
let mut tokens = segment.split_whitespace();
if let Some(name) = tokens.next() {
let sources: Vec<String> = tokens.map(str::to_string).collect();
out.insert(name.to_ascii_lowercase(), sources);
}
}
out
}
fn sources_are_loose(sources: &[String], strict_schemes: bool) -> bool {
for src in sources {
if src.contains('*') {
return true;
}
let lower = src.to_ascii_lowercase();
if lower == "'unsafe-inline'" || lower == "'unsafe-eval'" || lower == "'unsafe-hashes'" {
return true;
}
if strict_schemes && matches!(lower.as_str(), "data:" | "blob:" | "http:" | "https:") {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use http_body_util::BodyExt;
fn block() -> Vec<String> {
vec![".cgi".into(), ".php".into(), ".exe".into()]
}
async fn make_state(root: &Path) -> WebsiteState {
WebsiteState {
root: WebsiteRoot::new(root, "live").unwrap(),
cache: WebsiteCache::new(60),
executable_blocklist: block(),
cache_control: "public, max-age=300".into(),
csp_override_file: ".vtc-website.toml".into(),
csp_cache: CspOverrideCache::new(60),
}
}
#[tokio::test]
async fn serves_existing_file_with_etag() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.html"), "<p>hi</p>").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/hello.html".parse().unwrap();
let resp = serve_inner(&state, &uri, None).await.expect("ok");
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get(header::ETAG).is_some());
assert_eq!(
resp.headers()
.get(header::CACHE_CONTROL)
.map(|h| h.to_str().unwrap()),
Some("public, max-age=300"),
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(bytes.as_ref(), b"<p>hi</p>");
}
#[tokio::test]
async fn matching_if_none_match_returns_304() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.html"), "<p>hi</p>").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/hello.html".parse().unwrap();
let resp = serve_inner(&state, &uri, None).await.expect("ok");
let etag = resp
.headers()
.get(header::ETAG)
.and_then(|h| h.to_str().ok())
.unwrap()
.to_string();
let resp = serve_inner(&state, &uri, Some(&etag)).await.expect("ok");
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
assert_eq!(
resp.headers()
.get(header::ETAG)
.and_then(|h| h.to_str().ok()),
Some(etag.as_str()),
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert!(bytes.is_empty(), "304 must carry no body");
let resp = serve_inner(&state, &uri, Some("\"deadbeef\""))
.await
.expect("ok");
assert_eq!(resp.status(), StatusCode::OK);
let resp = serve_inner(&state, &uri, Some("*")).await.expect("ok");
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
}
#[test]
fn etag_matches_handles_lists_weak_and_star() {
assert!(etag_matches("\"abc\"", "\"abc\""));
assert!(etag_matches("\"x\", \"abc\", \"y\"", "\"abc\""));
assert!(etag_matches("W/\"abc\"", "\"abc\""));
assert!(etag_matches("*", "\"abc\""));
assert!(!etag_matches("\"abc\"", "\"def\""));
assert!(!etag_matches("", "\"abc\""));
}
#[tokio::test]
async fn serves_index_for_root_request() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), "<title>home</title>").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/".parse().unwrap();
let resp = serve_inner(&state, &uri, None).await.expect("ok");
assert_eq!(resp.status(), StatusCode::OK);
assert!(
resp.headers()
.get(header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok())
.unwrap_or("")
.starts_with("text/html"),
"got {:?}",
resp.headers().get(header::CONTENT_TYPE)
);
}
#[tokio::test]
async fn rejects_hidden_with_404() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".secrets"), "shh").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/.secrets".parse().unwrap();
let err = serve_inner(&state, &uri, None)
.await
.expect_err("must reject");
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn rejects_blocked_extension_with_403() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("evil.cgi"), "#!/bin/sh\n").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/evil.cgi".parse().unwrap();
let err = serve_inner(&state, &uri, None)
.await
.expect_err("must reject");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[tokio::test]
async fn rejects_dotdot_escape_with_validation_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), "ok").unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/../../etc/passwd".parse().unwrap();
let err = serve_inner(&state, &uri, None)
.await
.expect_err("must reject");
assert!(
matches!(err, AppError::NotFound(_) | AppError::Validation(_)),
"got {err:?}"
);
}
#[tokio::test]
async fn directory_request_404s() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("assets")).unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/assets".parse().unwrap();
let err = serve_inner(&state, &uri, None)
.await
.expect_err("must reject");
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn safe_per_site_csp_override_wins() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), "<title>home</title>").unwrap();
std::fs::write(
dir.path().join(".vtc-website.toml"),
r#"csp = "default-src 'self'; script-src 'self'; img-src 'self' https://cdn.example.com""#,
)
.unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/".parse().unwrap();
let resp = serve_inner(&state, &uri, None).await.expect("ok");
let csp = resp
.headers()
.get(header::CONTENT_SECURITY_POLICY)
.and_then(|h| h.to_str().ok())
.unwrap_or("");
assert!(csp.contains("https://cdn.example.com"), "got CSP: {csp}");
}
#[tokio::test]
async fn loose_per_site_csp_override_is_refused() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), "<title>home</title>").unwrap();
std::fs::write(
dir.path().join(".vtc-website.toml"),
r#"csp = "default-src 'self'; script-src 'self' 'unsafe-inline'""#,
)
.unwrap();
let state = make_state(dir.path()).await;
let uri: Uri = "/".parse().unwrap();
let resp = serve_inner(&state, &uri, None).await.expect("ok");
assert!(
resp.headers()
.get(header::CONTENT_SECURITY_POLICY)
.is_none(),
"loose override must not be emitted; got {:?}",
resp.headers().get(header::CONTENT_SECURITY_POLICY),
);
}
#[test]
fn validate_csp_accepts_strict_and_custom_directives() {
assert!(validate_csp_override("default-src 'self'; script-src 'self'").is_some());
assert!(
validate_csp_override(
"default-src 'self'; script-src 'self'; connect-src 'self' https://api.example.com"
)
.is_some()
);
assert!(validate_csp_override("default-src 'self'").is_some());
assert!(validate_csp_override("default-src 'self'; script-src").is_some());
}
#[test]
fn validate_csp_refuses_weakened_critical_directives() {
assert!(
validate_csp_override("default-src 'self'; script-src 'self' 'unsafe-inline'")
.is_none()
);
assert!(validate_csp_override("default-src 'self'; script-src 'unsafe-eval'").is_none());
assert!(validate_csp_override("default-src 'self'; script-src *").is_none());
assert!(
validate_csp_override("default-src 'self'; script-src https://*.evil.com").is_none()
);
assert!(validate_csp_override("default-src * 'unsafe-inline'").is_none());
assert!(validate_csp_override("default-src https:").is_none());
assert!(validate_csp_override("img-src 'self'").is_none());
assert!(validate_csp_override("default-src 'self'; object-src *").is_none());
assert!(validate_csp_override("default-src 'self'; base-uri *").is_none());
}
#[tokio::test]
async fn csp_override_cache_is_not_read_every_request() {
let dir = tempfile::tempdir().unwrap();
let override_path = dir.path().join(".vtc-website.toml");
std::fs::write(
&override_path,
r#"csp = "default-src 'self'; script-src 'self'; img-src 'self' https://a.example.com""#,
)
.unwrap();
let cache = CspOverrideCache::new(3600);
let first = cache.get(dir.path(), ".vtc-website.toml").await.unwrap();
assert!(first.contains("a.example.com"));
std::fs::write(
&override_path,
r#"csp = "default-src 'self'; script-src 'self'; img-src 'self' https://b.example.com""#,
)
.unwrap();
let second = cache.get(dir.path(), ".vtc-website.toml").await.unwrap();
assert_eq!(first, second, "override must be cached within the TTL");
}
}