diff --git a/.env.example b/.env.example index 5af89a7..990e09c 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,10 @@ WORKSPACE_SESSION_DAYS=30 # to. Uploads stream to disk, so this bounds the disk, not the memory. WORKSPACE_MAX_UPLOAD_MB=1024 -# Set to 1 once the app is served over HTTPS, so the cookie gets `Secure`. +# Set to 1 once the app is served over HTTPS: the cookie gets `Secure` and the +# `__Host-` prefix (everyone is logged out once), and browsers are told to use +# HTTPS only (HSTS). The server refuses to start with the example secret or +# password above still in place. WORKSPACE_SECURE_COOKIE=0 # --- SFTP mount -------------------------------------------------------------- diff --git a/README.md b/README.md index ec022db..f591c1d 100644 --- a/README.md +++ b/README.md @@ -582,11 +582,20 @@ sealed or not. Put a TLS-terminating reverse proxy in front of it, point it at `WORKSPACE_BIND`, and set `WORKSPACE_SECURE_COOKIE=1`. Nothing else is needed: no database server, no -runtime dependencies, no build tools on the host. +runtime dependencies, no build tools on the host. The server will not start +while `WORKSPACE_SECRET` or `WORKSPACE_PASSWORD` is still the example from +`.env.example`: with the example secret anyone could sign themselves a session. + +With `WORKSPACE_SECURE_COOKIE=1` the session cookie is `Secure` and named +`__Host-diary_session`, so the browser keeps it to this host and HTTPS, and every +response carries `Strict-Transport-Security` for a year. Turning it on logs +everyone out once. The app sends its own `Content-Security-Policy`, `Referrer-Policy: no-referrer`, -`X-Content-Type-Options` and `X-Frame-Options`; a proxy that adds its own should -not weaken them. `no-referrer` matters in particular, because a share token +`X-Content-Type-Options`, `X-Frame-Options`, `Cross-Origin-Opener-Policy`, +`Cross-Origin-Resource-Policy` and a `Permissions-Policy` that turns off camera, +microphone and location; API answers are `Cache-Control: no-store` unless they +say otherwise. A proxy that adds its own should not weaken them. `no-referrer` matters in particular, because a share token lives in the URL and would otherwise leak to any site a shared entry links to. Share links minted before the key existed are revoked by migration `0005`; re-run `:share` on those entries to publish them again. @@ -630,5 +639,5 @@ volume under a new name and find an empty one. By default the port is published on `127.0.0.1:4242` only. To serve it through traefik instead, copy `compose.override.yml.example` to `compose.override.yml` and adjust the host — it joins the external `narl` network, drops the published -port, and routes to container port 4242. Set `WORKSPACE_SECURE_COOKIE=1` in `.env` -once it is served over HTTPS. +port, routes to container port 4242, and sets `WORKSPACE_SECURE_COOKIE=1`, since +traefik serves it over HTTPS. diff --git a/compose.override.yml.example b/compose.override.yml.example index 4c18b9c..55c17a8 100644 --- a/compose.override.yml.example +++ b/compose.override.yml.example @@ -7,6 +7,9 @@ services: # Requires WORKSPACE_SFTP_BIND=0.0.0.0:2222 in .env to actually listen. ports: !override - "127.0.0.1:2222:2222" + # Traefik serves it over HTTPS, so the cookie can insist on it. + environment: + WORKSPACE_SECURE_COOKIE: "1" networks: - narl - internal_net diff --git a/src/auth.rs b/src/auth.rs index 67ab4ab..08004fb 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -17,6 +17,7 @@ use crate::{config::Config, error::AppError, state::AppState}; // Kept from before the rename: the name is never seen, and changing it would // sign every browser out to no purpose. pub const COOKIE_NAME: &str = "diary_session"; +pub const SECURE_COOKIE_NAME: &str = "__Host-diary_session"; type HmacSha256 = Hmac; @@ -108,21 +109,42 @@ impl Epoch { } } +/// Both sides are compared as MACs of the same length, so the time it takes +/// says nothing about how long the real password is either. pub fn credentials_match(config: &Config, username: &str, password: &str) -> bool { - let user_ok = username.as_bytes().ct_eq(config.username.as_bytes()); - let pass_ok = password.as_bytes().ct_eq(config.password.as_bytes()); + let digest = |value: &str| { + let mut mac = + HmacSha256::new_from_slice(&config.secret).expect("hmac accepts any key length"); + mac.update(value.as_bytes()); + mac.finalize().into_bytes() + }; + let user_ok = digest(username).ct_eq(&digest(&config.username)); + let pass_ok = digest(password).ct_eq(&digest(&config.password)); bool::from(user_ok & pass_ok) } +/// Over HTTPS the cookie takes the `__Host-` prefix: the browser then keeps +/// it to this exact host, sent only over a secure connection, and no sibling +/// subdomain can plant one of the same name. +pub fn cookie_name(config: &Config) -> &'static str { + if config.secure_cookie { + SECURE_COOKIE_NAME + } else { + COOKIE_NAME + } +} + pub fn session_cookie(config: &Config, token: &str) -> String { let max_age = config.session_days * 86_400; + let name = cookie_name(config); let secure = if config.secure_cookie { "; Secure" } else { "" }; - format!("{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}") + format!("{name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}") } pub fn clear_cookie(config: &Config) -> String { + let name = cookie_name(config); let secure = if config.secure_cookie { "; Secure" } else { "" }; - format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}") + format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}") } fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> { @@ -147,7 +169,8 @@ impl FromRequestParts for Session { parts: &mut Parts, state: &AppState, ) -> Result { - let token = cookie_value(parts, COOKIE_NAME).ok_or(AppError::Unauthorized)?; + let token = + cookie_value(parts, cookie_name(&state.config)).ok_or(AppError::Unauthorized)?; let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst); if verify_token(&state.config, token, epoch) { Ok(Session) @@ -218,7 +241,9 @@ fn is_same_origin(headers: &HeaderMap) -> bool { mod tests { use axum::http::{HeaderMap, HeaderValue}; - use super::{credentials_match, is_same_origin, issue_token, verify_token}; + use super::{ + clear_cookie, credentials_match, is_same_origin, issue_token, session_cookie, verify_token, + }; use crate::config::Config; fn config(session_days: i64) -> Config { @@ -245,6 +270,20 @@ mod tests { assert!(!verify_token(&rotated, &token, 0)); } + #[test] + fn over_https_the_cookie_is_kept_to_the_host() { + let mut config = config(30); + assert!(session_cookie(&config, "t").starts_with("diary_session=t;")); + config.secure_cookie = true; + let cookie = session_cookie(&config, "t"); + assert!(cookie.starts_with("__Host-diary_session=t;"), "{cookie}"); + // The prefix holds only with Secure, Path=/ and no Domain. + assert!( + cookie.contains("; Secure") && cookie.contains("Path=/") && !cookie.contains("Domain") + ); + assert!(clear_cookie(&config).starts_with("__Host-diary_session=;")); + } + #[test] fn a_tampered_token_does_not_verify() { let config = config(30); diff --git a/src/config.rs b/src/config.rs index bcb7548..d494c4a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,10 @@ use anyhow::{Context, Result}; use chrono::Weekday; use chrono_tz::Tz; +/// The placeholders `.env.example` ships with, refused when left in place. +const EXAMPLE_SECRET: &str = "replace-with-32-random-characters"; +const EXAMPLE_PASSWORD: &str = "change-me"; + /// Runtime configuration. Everything comes from the environment (`.env` is /// loaded on startup), because this is a deliberately single-user application. #[derive(Clone)] @@ -251,13 +255,20 @@ impl Config { if secret.len() < 16 { anyhow::bail!("WORKSPACE_SECRET must be at least 16 characters"); } - - if password == "change-me" { - tracing::warn!( - "WORKSPACE_PASSWORD is still the example value from .env.example — \ - anyone who can reach this server can read the workspace" + // The examples are public: with the secret anyone can sign a session + // cookie, with the password anyone can log in. + if secret == EXAMPLE_SECRET.as_bytes() { + anyhow::bail!( + "WORKSPACE_SECRET is still the example from .env.example — \ + set it to something random: openssl rand -base64 32" ); } + if secret.len() < 32 { + tracing::warn!("WORKSPACE_SECRET is shorter than 32 characters; a longer one is safer"); + } + if password == EXAMPLE_PASSWORD { + anyhow::bail!("WORKSPACE_PASSWORD is still the example from .env.example — change it"); + } let bind = var("BIND") .unwrap_or_else(|| "127.0.0.1:4242".to_string()) diff --git a/src/main.rs b/src/main.rs index b0316da..b57e97b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -234,12 +234,21 @@ async fn snapshot_before_migrating( async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) -> Result<()> { let app = Router::new() - .nest("/api", routes::api_router(max_upload)) + // What the API answers is private and changes; only what says + // otherwise — the media routes — is kept by a cache. + .nest( + "/api", + routes::api_router(max_upload).layer(SetResponseHeaderLayer::if_not_present( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + )), + ) .fallback(static_files::handler) .layer(axum::middleware::from_fn(auth::same_origin)) .layer(axum::middleware::from_fn(deadline)) .layer(CompressionLayer::new().compress_when(compressible)) .layer(security_headers()) + .layer(strict_transport(state.config.secure_cookie)) .layer( TraceLayer::new_for_http() .make_span_with(|request: &axum::http::Request| { @@ -378,7 +387,15 @@ type HeaderLayer = SetResponseHeaderLayer; /// Headers every response carries. `if_not_present` throughout, so a handler /// that has already said something stricter about itself — the media routes and /// their sandbox CSP — keeps its own answer. -fn security_headers() -> (HeaderLayer, HeaderLayer, HeaderLayer, HeaderLayer) { +fn security_headers() -> ( + HeaderLayer, + HeaderLayer, + HeaderLayer, + HeaderLayer, + HeaderLayer, + HeaderLayer, + HeaderLayer, +) { let header = |name: header::HeaderName, value: &'static str| { SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value)) }; @@ -390,6 +407,33 @@ fn security_headers() -> (HeaderLayer, HeaderLayer, HeaderLayer, HeaderLayer) { header(header::X_CONTENT_TYPE_OPTIONS, "nosniff"), header(header::X_FRAME_OPTIONS, "DENY"), header(header::CONTENT_SECURITY_POLICY, CSP), + // No other site's window keeps a handle on this one, nor embeds what + // it serves. + header( + header::HeaderName::from_static("cross-origin-opener-policy"), + "same-origin", + ), + header( + header::HeaderName::from_static("cross-origin-resource-policy"), + "same-origin", + ), + header( + header::HeaderName::from_static("permissions-policy"), + "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + ), + ) +} + +/// Over HTTPS, the browser is told to come back over nothing else for a year, +/// so a mistyped `http://` never carries the cookie in the clear. +fn strict_transport( + secure: bool, +) -> SetResponseHeaderLayer Option + Clone> { + SetResponseHeaderLayer::if_not_present( + header::STRICT_TRANSPORT_SECURITY, + move |_: &axum::response::Response| { + secure.then(|| HeaderValue::from_static("max-age=31536000")) + }, ) }