mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
auth, headers: the examples are refused, HTTPS is insisted on
The server no longer starts with WORKSPACE_SECRET or WORKSPACE_PASSWORD left at the value .env.example ships with. The secret was only checked for length, and the public placeholder passed: anyone who had read the repository could sign a session cookie for themselves. A secret under 32 characters is warned about. With WORKSPACE_SECURE_COOKIE=1 the cookie is named __Host-diary_session, which the browser keeps to this host and to HTTPS and which no sibling subdomain can plant; turning it on logs everyone out once. Responses then carry Strict-Transport-Security for a year. The traefik example turns it on. The password is compared as an HMAC under the secret, so the time a comparison takes no longer depends on the configured password's length. Every response also carries Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy same-origin and a Permissions-Policy that turns camera, microphone, location, payment and USB off; API answers are no-store unless they say otherwise.
This commit is contained in:
1 parent
6bf88e7ffd
commit
f450883ac0
6 files changed
+128
-19
No files matched your search
+4
-1
@@ -22,7 +22,10 @@ WORKSPACE_SESSION_DAYS=30
|
|||||||
# to. Uploads stream to disk, so this bounds the disk, not the memory.
|
# to. Uploads stream to disk, so this bounds the disk, not the memory.
|
||||||
WORKSPACE_MAX_UPLOAD_MB=1024
|
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
|
WORKSPACE_SECURE_COOKIE=0
|
||||||
|
|
||||||
# --- SFTP mount --------------------------------------------------------------
|
# --- SFTP mount --------------------------------------------------------------
|
||||||
|
|||||||
@@ -582,11 +582,20 @@ sealed or not.
|
|||||||
|
|
||||||
Put a TLS-terminating reverse proxy in front of it, point it at `WORKSPACE_BIND`,
|
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
|
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`,
|
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
|
`X-Content-Type-Options`, `X-Frame-Options`, `Cross-Origin-Opener-Policy`,
|
||||||
not weaken them. `no-referrer` matters in particular, because a share token
|
`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.
|
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`;
|
Share links minted before the key existed are revoked by migration `0005`;
|
||||||
re-run `:share` on those entries to publish them again.
|
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
|
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`
|
traefik instead, copy `compose.override.yml.example` to `compose.override.yml`
|
||||||
and adjust the host — it joins the external `narl` network, drops the published
|
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`
|
port, routes to container port 4242, and sets `WORKSPACE_SECURE_COOKIE=1`, since
|
||||||
once it is served over HTTPS.
|
traefik serves it over HTTPS.
|
||||||
@@ -7,6 +7,9 @@ services:
|
|||||||
# Requires WORKSPACE_SFTP_BIND=0.0.0.0:2222 in .env to actually listen.
|
# Requires WORKSPACE_SFTP_BIND=0.0.0.0:2222 in .env to actually listen.
|
||||||
ports: !override
|
ports: !override
|
||||||
- "127.0.0.1:2222:2222"
|
- "127.0.0.1:2222:2222"
|
||||||
|
# Traefik serves it over HTTPS, so the cookie can insist on it.
|
||||||
|
environment:
|
||||||
|
WORKSPACE_SECURE_COOKIE: "1"
|
||||||
networks:
|
networks:
|
||||||
- narl
|
- narl
|
||||||
- internal_net
|
- internal_net
|
||||||
|
|||||||
+45
-6
@@ -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
|
// Kept from before the rename: the name is never seen, and changing it would
|
||||||
// sign every browser out to no purpose.
|
// sign every browser out to no purpose.
|
||||||
pub const COOKIE_NAME: &str = "diary_session";
|
pub const COOKIE_NAME: &str = "diary_session";
|
||||||
|
pub const SECURE_COOKIE_NAME: &str = "__Host-diary_session";
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
@@ -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 {
|
pub fn credentials_match(config: &Config, username: &str, password: &str) -> bool {
|
||||||
let user_ok = username.as_bytes().ct_eq(config.username.as_bytes());
|
let digest = |value: &str| {
|
||||||
let pass_ok = password.as_bytes().ct_eq(config.password.as_bytes());
|
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)
|
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 {
|
pub fn session_cookie(config: &Config, token: &str) -> String {
|
||||||
let max_age = config.session_days * 86_400;
|
let max_age = config.session_days * 86_400;
|
||||||
|
let name = cookie_name(config);
|
||||||
let secure = if config.secure_cookie { "; Secure" } else { "" };
|
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 {
|
pub fn clear_cookie(config: &Config) -> String {
|
||||||
|
let name = cookie_name(config);
|
||||||
let secure = if config.secure_cookie { "; Secure" } else { "" };
|
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> {
|
fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> {
|
||||||
@@ -147,7 +169,8 @@ impl FromRequestParts<AppState> for Session {
|
|||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
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);
|
let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
if verify_token(&state.config, token, epoch) {
|
if verify_token(&state.config, token, epoch) {
|
||||||
Ok(Session)
|
Ok(Session)
|
||||||
@@ -218,7 +241,9 @@ fn is_same_origin(headers: &HeaderMap) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use axum::http::{HeaderMap, HeaderValue};
|
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;
|
use crate::config::Config;
|
||||||
|
|
||||||
fn config(session_days: i64) -> Config {
|
fn config(session_days: i64) -> Config {
|
||||||
@@ -245,6 +270,20 @@ mod tests {
|
|||||||
assert!(!verify_token(&rotated, &token, 0));
|
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]
|
#[test]
|
||||||
fn a_tampered_token_does_not_verify() {
|
fn a_tampered_token_does_not_verify() {
|
||||||
let config = config(30);
|
let config = config(30);
|
||||||
|
|||||||
+16
-5
@@ -4,6 +4,10 @@ use anyhow::{Context, Result};
|
|||||||
use chrono::Weekday;
|
use chrono::Weekday;
|
||||||
use chrono_tz::Tz;
|
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
|
/// Runtime configuration. Everything comes from the environment (`.env` is
|
||||||
/// loaded on startup), because this is a deliberately single-user application.
|
/// loaded on startup), because this is a deliberately single-user application.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -251,13 +255,20 @@ impl Config {
|
|||||||
if secret.len() < 16 {
|
if secret.len() < 16 {
|
||||||
anyhow::bail!("WORKSPACE_SECRET must be at least 16 characters");
|
anyhow::bail!("WORKSPACE_SECRET must be at least 16 characters");
|
||||||
}
|
}
|
||||||
|
// The examples are public: with the secret anyone can sign a session
|
||||||
if password == "change-me" {
|
// cookie, with the password anyone can log in.
|
||||||
tracing::warn!(
|
if secret == EXAMPLE_SECRET.as_bytes() {
|
||||||
"WORKSPACE_PASSWORD is still the example value from .env.example — \
|
anyhow::bail!(
|
||||||
anyone who can reach this server can read the workspace"
|
"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")
|
let bind = var("BIND")
|
||||||
.unwrap_or_else(|| "127.0.0.1:4242".to_string())
|
.unwrap_or_else(|| "127.0.0.1:4242".to_string())
|
||||||
|
|||||||
+46
-2
@@ -234,12 +234,21 @@ async fn snapshot_before_migrating(
|
|||||||
|
|
||||||
async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) -> Result<()> {
|
async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) -> Result<()> {
|
||||||
let app = Router::new()
|
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)
|
.fallback(static_files::handler)
|
||||||
.layer(axum::middleware::from_fn(auth::same_origin))
|
.layer(axum::middleware::from_fn(auth::same_origin))
|
||||||
.layer(axum::middleware::from_fn(deadline))
|
.layer(axum::middleware::from_fn(deadline))
|
||||||
.layer(CompressionLayer::new().compress_when(compressible))
|
.layer(CompressionLayer::new().compress_when(compressible))
|
||||||
.layer(security_headers())
|
.layer(security_headers())
|
||||||
|
.layer(strict_transport(state.config.secure_cookie))
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
.make_span_with(|request: &axum::http::Request<axum::body::Body>| {
|
.make_span_with(|request: &axum::http::Request<axum::body::Body>| {
|
||||||
@@ -378,7 +387,15 @@ type HeaderLayer = SetResponseHeaderLayer<HeaderValue>;
|
|||||||
/// Headers every response carries. `if_not_present` throughout, so a handler
|
/// Headers every response carries. `if_not_present` throughout, so a handler
|
||||||
/// that has already said something stricter about itself — the media routes and
|
/// that has already said something stricter about itself — the media routes and
|
||||||
/// their sandbox CSP — keeps its own answer.
|
/// 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| {
|
let header = |name: header::HeaderName, value: &'static str| {
|
||||||
SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value))
|
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_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||||
header(header::X_FRAME_OPTIONS, "DENY"),
|
header(header::X_FRAME_OPTIONS, "DENY"),
|
||||||
header(header::CONTENT_SECURITY_POLICY, CSP),
|
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<impl Fn(&axum::response::Response) -> Option<HeaderValue> + Clone> {
|
||||||
|
SetResponseHeaderLayer::if_not_present(
|
||||||
|
header::STRICT_TRANSPORT_SECURITY,
|
||||||
|
move |_: &axum::response::Response| {
|
||||||
|
secure.then(|| HeaderValue::from_static("max-age=31536000"))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user