mini-static 0.29.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
mod common;

use std::fs;

use hyper::header::HeaderMap;
use hyper::Method;
use mini_static::{Server, StaticError};
use tempfile::TempDir;

const HSTS: &str = "Strict-Transport-Security";
const HSTS_VALUE: &str = "max-age=63072000; includeSubDomains";

fn root() -> TempDir {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), b"<html>Hi</html>").unwrap();
    fs::create_dir(root.path().join("dir")).unwrap();
    fs::write(root.path().join("dir/index.html"), b"<html>Dir</html>").unwrap();
    root
}

fn server_with_header(root: &TempDir) -> Server {
    Server::new(root.path())
        .unwrap()
        .with_response_header(HSTS, HSTS_VALUE)
        .unwrap()
}

fn header_of(response: &hyper::Response<mini_static::ResponseBody>, name: &str) -> Option<String> {
    response
        .headers()
        .get(name)
        .map(|v| v.to_str().unwrap().to_string())
}

/// A policy header that appears on some statuses and not others is worse than none: the
/// responses most worth protecting are the error paths an attacker is probing.
#[tokio::test]
async fn a_configured_header_is_present_on_every_status() {
    let root = root();
    let server = server_with_header(&root);

    let ok = common::get(&server, "/index.html").await;
    assert_eq!(ok.status().as_u16(), 200);
    assert_eq!(header_of(&ok, HSTS).as_deref(), Some(HSTS_VALUE));

    let missing = common::get(&server, "/nope.html").await;
    assert_eq!(missing.status().as_u16(), 404);
    assert_eq!(header_of(&missing, HSTS).as_deref(), Some(HSTS_VALUE));

    let traversal = common::get(&server, "/../../etc/passwd").await;
    assert_eq!(traversal.status().as_u16(), 404);
    assert_eq!(header_of(&traversal, HSTS).as_deref(), Some(HSTS_VALUE));

    let rejected = common::request(&server, &Method::DELETE, "/index.html").await;
    assert_eq!(rejected.status().as_u16(), 405);
    assert_eq!(header_of(&rejected, HSTS).as_deref(), Some(HSTS_VALUE));

    let redirect = common::get(&server, "/dir").await;
    assert_eq!(redirect.status().as_u16(), 301);
    assert_eq!(header_of(&redirect, HSTS).as_deref(), Some(HSTS_VALUE));
}

#[tokio::test]
async fn a_configured_header_is_present_on_a_304() {
    let root = root();
    let server = server_with_header(&root);

    let first = common::get(&server, "/index.html").await;
    let etag = first.headers().get("ETag").unwrap().clone();

    let mut headers = HeaderMap::new();
    headers.insert("if-none-match", etag);
    let revalidated = server
        .handle_request(&Method::GET, "/index.html", &headers)
        .await;

    assert_eq!(revalidated.status().as_u16(), 304);
    assert_eq!(header_of(&revalidated, HSTS).as_deref(), Some(HSTS_VALUE));
}

#[tokio::test]
async fn several_headers_can_be_configured() {
    let root = root();
    let server = Server::new(root.path())
        .unwrap()
        .with_response_header(HSTS, HSTS_VALUE)
        .unwrap()
        .with_response_header("Referrer-Policy", "no-referrer")
        .unwrap()
        .with_response_header("Content-Security-Policy", "default-src 'self'")
        .unwrap();

    let response = common::get(&server, "/index.html").await;

    assert_eq!(header_of(&response, HSTS).as_deref(), Some(HSTS_VALUE));
    assert_eq!(
        header_of(&response, "Referrer-Policy").as_deref(),
        Some("no-referrer")
    );
    assert_eq!(
        header_of(&response, "Content-Security-Policy").as_deref(),
        Some("default-src 'self'")
    );
}

#[tokio::test]
async fn nothing_extra_is_sent_without_configuration() {
    let root = root();
    let server = Server::new(root.path()).unwrap();

    let response = common::get(&server, "/index.html").await;

    assert_eq!(header_of(&response, HSTS), None);
}

/// Malformed input fails when the server is built, not on a request months later.
#[test]
fn an_invalid_name_or_value_is_rejected_at_configuration_time() {
    let root = root();

    let bad_name = Server::new(root.path())
        .unwrap()
        .with_response_header("Not A Header", "x");
    assert!(
        matches!(bad_name, Err(StaticError::Config(_))),
        "a header name with spaces must be rejected"
    );

    let bad_value = Server::new(root.path())
        .unwrap()
        .with_response_header("X-Fine", "bad\nvalue");
    assert!(
        matches!(bad_value, Err(StaticError::Config(_))),
        "a header value containing a newline must be rejected"
    );
}

/// A fixed value for a computed header would be silently overridden or silently
/// duplicated depending on the response — a wrong `Content-Length` or `ETag` is a
/// correctness bug, so it is refused rather than accepted and ignored.
#[test]
fn server_computed_headers_are_refused() {
    let root = root();

    for name in [
        "Content-Length",
        "content-type",
        "ETag",
        "Cache-Control",
        "Vary",
        "Accept-Ranges",
        "Allow",
        "Location",
        "X-Content-Type-Options",
        "Content-Encoding",
        "Content-Range",
        "Connection",
        "Transfer-Encoding",
    ] {
        let result = Server::new(root.path())
            .unwrap()
            .with_response_header(name, "x");
        assert!(
            matches!(result, Err(StaticError::Config(_))),
            "{name} should be refused as a fixed header"
        );
    }
}

/// The refusal is by header name, not by the exact casing the caller typed.
#[test]
fn the_computed_header_check_is_case_insensitive() {
    let root = root();

    let result = Server::new(root.path())
        .unwrap()
        .with_response_header("CONTENT-LENGTH", "999");

    assert!(matches!(result, Err(StaticError::Config(_))));
}