sofie-http 0.1.0

The HTTP server, areas, routes and the handler model.
Documentation
#![cfg(feature = "cors")]

use std::time::Duration;

use http::StatusCode;
use sofie_core::Result;
use sofie_db::Db;
use sofie_http::{App, Area, Cors, CorsOrigins, Method, Routes, Visitor, Web};
use sofie_testing::{FakeDriver, TestRequest, TestResponse, TestServer};
use tokio::net::TcpListener;

const APP: &str = "https://app.example";
const OTHER: &str = "https://evil.example";
const SESSION: &str = "__Host-sofie-session=x";

struct Api;

impl Area for Api {
    const NAME: &'static str = "api";
    const PREFIX: &'static str = "/api";
    const SIGN_IN: &'static str = "/";
    type Principal = Visitor;
}

async fn hello() -> Result<()> {
    Ok(())
}

fn web(routes: &mut Routes<Web>) {
    routes.get("/hello", hello);
    routes.post("/note", hello);
}

fn api(routes: &mut Routes<Api>) {
    routes.get("/notes", hello);
    routes.post("/notes", hello);
    routes.put("/notes", hello);
}

fn app(cors: Cors) -> App {
    App::new(Db::new(FakeDriver::default()))
        .area("notes", web)
        .area("notes", api)
        .cors::<Api>(cors)
}

fn with_credentials() -> Cors {
    Cors::new()
        .origin(APP)
        .methods(&[Method::GET, Method::POST])
        .headers(&["content-type", "x-trace"])
        .exposed_headers(&["x-request-id"])
        .credentials()
        .max_age(Duration::from_mins(10))
}

fn preflight(server: &TestServer, path: &str, origin: &str, method: &str) -> TestRequest {
    server
        .request(Method::OPTIONS, path)
        .header("origin", origin)
        .header("access-control-request-method", method)
}

fn cross_site_post(server: &TestServer, path: &str, origin: &str) -> TestRequest {
    server
        .post(path)
        .header("origin", origin)
        .header("sec-fetch-site", "cross-site")
}

fn header<'r>(response: &'r TestResponse, name: &str) -> Option<&'r str> {
    response
        .headers
        .get(name)
        .map(|value| value.to_str().unwrap())
}

fn varies_by_origin(response: &TestResponse) -> bool {
    response
        .headers
        .get_all("vary")
        .iter()
        .any(|value| value == "Origin")
}

#[tokio::test]
async fn an_allowed_preflight_is_answered_before_routing() {
    let server = TestServer::start(app(with_credentials())).await;

    let allowed = preflight(&server, "/api/notes", APP, "POST")
        .header("access-control-request-headers", "Content-Type, X-Trace")
        .await;

    assert_eq!(allowed.status, StatusCode::NO_CONTENT);
    assert_eq!(header(&allowed, "access-control-allow-origin"), Some(APP));
    assert_eq!(
        header(&allowed, "access-control-allow-credentials"),
        Some("true")
    );
    assert_eq!(
        header(&allowed, "access-control-allow-methods"),
        Some("GET, POST")
    );
    assert_eq!(
        header(&allowed, "access-control-allow-headers"),
        Some("content-type, x-trace")
    );
    assert_eq!(header(&allowed, "access-control-max-age"), Some("600"));
    assert!(varies_by_origin(&allowed));
    assert_eq!(header(&allowed, "x-frame-options"), Some("DENY"));
}

#[tokio::test]
async fn a_preflight_outside_the_policy_is_refused_without_cors_headers() {
    let server = TestServer::start(app(with_credentials())).await;

    let refused = [
        preflight(&server, "/api/notes", OTHER, "POST").await,
        preflight(&server, "/api/notes", "null", "POST").await,
        preflight(&server, "/api/notes", APP, "PUT").await,
        preflight(&server, "/api/notes", APP, "POST")
            .header("access-control-request-headers", "authorization")
            .await,
    ];
    let other_area = preflight(&server, "/note", APP, "POST").await;

    for response in &refused {
        assert_eq!(response.status, StatusCode::FORBIDDEN);
        assert_eq!(header(response, "access-control-allow-origin"), None);
    }
    assert_eq!(other_area.status, StatusCode::METHOD_NOT_ALLOWED);
    assert_eq!(header(&other_area, "access-control-allow-origin"), None);
}

#[tokio::test]
async fn an_answer_carries_cors_headers_only_for_an_allowed_origin() {
    let server = TestServer::start(app(with_credentials())).await;

    let allowed = server.get("/api/notes").header("origin", APP).await;
    let other = server.get("/api/notes").header("origin", OTHER).await;
    let other_area = server.get("/hello").header("origin", APP).await;

    assert_eq!(header(&allowed, "access-control-allow-origin"), Some(APP));
    assert_eq!(
        header(&allowed, "access-control-allow-credentials"),
        Some("true")
    );
    assert_eq!(
        header(&allowed, "access-control-expose-headers"),
        Some("x-request-id")
    );
    assert_eq!(header(&other, "access-control-allow-origin"), None);
    assert!(varies_by_origin(&allowed) && varies_by_origin(&other));
    assert_eq!(header(&other_area, "access-control-allow-origin"), None);
    assert!(!varies_by_origin(&other_area));
}

#[tokio::test]
async fn a_cross_origin_write_passes_csrf_only_from_an_origin_cors_allows() {
    let server = TestServer::start(app(with_credentials())).await;

    let allowed = cross_site_post(&server, "/api/notes", APP)
        .header("cookie", SESSION)
        .await;
    let other_origin = cross_site_post(&server, "/api/notes", OTHER)
        .header("cookie", SESSION)
        .await;
    let without_origin = server
        .post("/api/notes")
        .header("sec-fetch-site", "cross-site")
        .await;
    let other_area = cross_site_post(&server, "/note", APP).await;

    assert_eq!(allowed.status, StatusCode::NO_CONTENT);
    assert_eq!(header(&allowed, "access-control-allow-origin"), Some(APP));
    assert_eq!(other_origin.status, StatusCode::FORBIDDEN);
    assert_eq!(without_origin.status, StatusCode::FORBIDDEN);
    assert_eq!(other_area.status, StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn without_credentials_a_cross_origin_write_carrying_the_session_is_refused() {
    let server = TestServer::start(app(Cors::new().origin(APP))).await;

    let without_session = cross_site_post(&server, "/api/notes", APP).await;
    let with_session = cross_site_post(&server, "/api/notes", APP)
        .header("cookie", SESSION)
        .await;

    assert_eq!(without_session.status, StatusCode::NO_CONTENT);
    assert_eq!(
        header(&without_session, "access-control-allow-credentials"),
        None
    );
    assert_eq!(with_session.status, StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn any_origin_is_answered_with_a_star_and_never_with_the_session() {
    let server = TestServer::start(app(Cors::new().origin("*"))).await;

    let read = server.get("/api/notes").header("origin", OTHER).await;
    let write = cross_site_post(&server, "/api/notes", OTHER).await;
    let with_session = cross_site_post(&server, "/api/notes", OTHER)
        .header("cookie", SESSION)
        .await;

    assert_eq!(header(&read, "access-control-allow-origin"), Some("*"));
    assert!(!varies_by_origin(&read));
    assert_eq!(write.status, StatusCode::NO_CONTENT);
    assert_eq!(with_session.status, StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn origins_from_settings_add_to_the_areas_policy() {
    let origins = CorsOrigins::parse("api=https://admin.example").unwrap();
    let server =
        TestServer::start(app(with_credentials()).serving(|serving| serving.cors_origins(origins)))
            .await;

    let from_code = server.get("/api/notes").header("origin", APP).await;
    let from_settings = server
        .get("/api/notes")
        .header("origin", "https://admin.example")
        .await;

    assert_eq!(header(&from_code, "access-control-allow-origin"), Some(APP));
    assert_eq!(
        header(&from_settings, "access-control-allow-origin"),
        Some("https://admin.example")
    );
}

#[tokio::test]
async fn settings_naming_an_area_without_a_policy_refuse_to_start() {
    let origins = CorsOrigins::parse("web=https://app.example").unwrap();
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();

    let served = app(with_credentials())
        .serving(|serving| serving.cors_origins(origins))
        .serve(listener, std::future::ready(()))
        .await;

    let error = served.unwrap_err().to_string();
    assert!(error.contains("SOFIE_CORS_ORIGINS"), "{error}");
}