diff --git a/Cargo.lock b/Cargo.lock index 81108793a..10ab78686 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1293,6 +1293,20 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -1455,6 +1469,7 @@ dependencies = [ "totp-lite", "tower", "tower-http", + "tower_governor", "tracing", "tracing-subscriber", "trait-variant", @@ -2429,6 +2444,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.32" @@ -2563,6 +2584,29 @@ dependencies = [ "walkdir", ] +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.4", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "group" version = "0.13.0" @@ -3749,6 +3793,12 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -4663,6 +4713,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -4870,6 +4926,21 @@ dependencies = [ "image", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -5073,6 +5144,15 @@ dependencies = [ "rgb", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "rayon" version = "1.12.0" @@ -5375,9 +5455,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "aws-lc-rs", "log", @@ -5963,6 +6043,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -6834,6 +6923,23 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower_governor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http", + "pin-project", + "thiserror 2.0.18", + "tonic", + "tower", + "tracing", +] + [[package]] name = "tracing" version = "0.1.44" @@ -7502,6 +7608,22 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -7511,6 +7633,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index e59620bd3..509850e55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,7 +130,8 @@ tonic-prost = "0.14" tonic-prost-build = "0.14" totp-lite = { version = "2.0" } tower = "0.5" -tower-http = { version = "0.6", features = ["fs", "trace", "set-header"] } +tower_governor = "0.8" +tower-http = { version = "0.6", features = ["fs", "trace", "timeout"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } trait-variant = "0.1" diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index 77ac0dfc8..63f85cce5 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -192,6 +192,16 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_ADOPT_EDGE")] pub adopt_edge: Option, + + /// Maximum number of requests per second per client IP before rate limiting kicks in. + /// Set to 0 to disable rate limiting. + #[arg(long, env = "DEFGUARD_RATELIMIT_PERSECOND", default_value_t = 10)] + pub rate_limit_per_second: u64, + + /// Maximum burst size for the rate limiter (token bucket capacity per client IP). + /// Set to 0 to disable rate limiting. + #[arg(long, env = "DEFGUARD_RATELIMIT_BURST", default_value_t = 100)] + pub rate_limit_burst: u32, } #[derive(Clone, Debug, Subcommand)] @@ -292,6 +302,8 @@ impl DefGuardConfig { grpc_bind_address: None, adopt_gateway: None, adopt_edge: None, + rate_limit_per_second: 10, + rate_limit_burst: 100, }; config diff --git a/crates/defguard_core/Cargo.toml b/crates/defguard_core/Cargo.toml index fb24a8776..71005e69b 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -71,6 +71,7 @@ tonic = { workspace = true } tonic-health = { workspace = true } totp-lite = { workspace = true } tower-http = { workspace = true } +tower_governor = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } trait-variant = { workspace = true } diff --git a/crates/defguard_core/src/appstate.rs b/crates/defguard_core/src/appstate.rs index 7243cf6a4..499775c14 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock, atomic::AtomicBool}; use axum::extract::FromRef; use axum_extra::extract::cookie::Key; @@ -36,6 +36,8 @@ pub struct AppState { pub event_tx: UnboundedSender, pub incompatible_components: Arc>, pub proxy_control_tx: tokio::sync::mpsc::Sender, + /// Reflects whether the HTTP server is currently running with TLS + pub tls_active: Arc, } impl AppState { @@ -123,6 +125,7 @@ impl AppState { event_tx: UnboundedSender, incompatible_components: Arc>, proxy_control_tx: tokio::sync::mpsc::Sender, + tls_active: Arc, ) -> Self { spawn(Self::handle_triggers(pool.clone(), rx)); @@ -136,6 +139,7 @@ impl AppState { event_tx, incompatible_components, proxy_control_tx, + tls_active, } } } diff --git a/crates/defguard_core/src/headers.rs b/crates/defguard_core/src/headers.rs index a577cd3b2..24b975771 100644 --- a/crates/defguard_core/src/headers.rs +++ b/crates/defguard_core/src/headers.rs @@ -1,6 +1,17 @@ -use std::{borrow::Borrow, sync::LazyLock}; +use std::{ + borrow::Borrow, + sync::{ + Arc, LazyLock, + atomic::{AtomicBool, Ordering}, + }, +}; -use axum::http::{HeaderName, HeaderValue}; +use axum::{ + body::Body, + http::{HeaderName, HeaderValue, Request, header}, + middleware::Next, + response::Response, +}; use defguard_common::db::{ Id, models::{DeviceLoginEvent, User}, @@ -9,10 +20,78 @@ use defguard_mail::templates::{SessionContext, TemplateError, new_device_login_m use sqlx::PgPool; use uaparser::{Client, Parser, UserAgentParser}; -pub(crate) const CONTENT_SECURITY_POLICY_HEADER_NAME: HeaderName = - HeaderName::from_static("content-security-policy"); -pub(crate) const CONTENT_SECURITY_POLICY_HEADER_VALUE: HeaderValue = - HeaderValue::from_static("frame-ancestors 'none';"); +// Header name constants not yet present in the `http` crate v1.x standard set. +const PERMISSIONS_POLICY: HeaderName = HeaderName::from_static("permissions-policy"); +const CROSS_ORIGIN_OPENER_POLICY: HeaderName = + HeaderName::from_static("cross-origin-opener-policy"); +const CROSS_ORIGIN_RESOURCE_POLICY: HeaderName = + HeaderName::from_static("cross-origin-resource-policy"); + +/// Injects baseline security response headers on every response. +pub(crate) async fn security_headers_middleware( + tls_active: Arc, + request: Request, + next: Next, +) -> Response { + let is_api = request.uri().path().starts_with("/api/"); + let mut response = next.run(request).await; + let headers = response.headers_mut(); + + // `X-Content-Type-Options: nosniff` - prevents MIME-type sniffing/confusion attacks + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + + // `Referrer-Policy: strict-origin-when-cross-origin` - avoids leaking internal URLs via Referer to external sites + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + + // `Permissions-Policy: geolocation=(), camera=(), microphone=()` - disables unused browser APIs + headers.insert( + PERMISSIONS_POLICY, + HeaderValue::from_static("geolocation=(), camera=(), microphone=()"), + ); + + // `Cross-Origin-Opener-Policy: same-origin` - severs window.opener references, preventing reverse tabnapping + headers.insert( + CROSS_ORIGIN_OPENER_POLICY, + HeaderValue::from_static("same-origin"), + ); + + // `Cross-Origin-Resource-Policy: same-origin` - blocks cross-origin embedding of application resources + headers.insert( + CROSS_ORIGIN_RESOURCE_POLICY, + HeaderValue::from_static("same-origin"), + ); + + // `X-Frame-Options: DENY` - clickjacking defense for browsers without CSP frame-ancestors support + headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); + + // `Content-Security-Policy: frame-ancestors 'none'` - prevents framing/clickjacking + // Use entry/or_insert so individual handlers can override CSP (e.g. per-request nonces) + headers + .entry(header::CONTENT_SECURITY_POLICY) + .or_insert(HeaderValue::from_static("frame-ancestors 'none';")); + + // `Strict-Transport-Security` - only sent over TLS; ignored and potentially harmful over plain HTTP (RFC 6797 ยง7.2) + let tls = tls_active.load(Ordering::Relaxed); + if tls { + headers.insert( + header::STRICT_TRANSPORT_SECURITY, + HeaderValue::from_static("max-age=31536000"), + ); + } + + // `Cache-Control: no-store` - prevents browsers and caches from storing sensitive API responses + if is_api { + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + } + + response +} pub static USER_AGENT_PARSER: LazyLock = LazyLock::new(|| { let regexes = include_bytes!("../user_agent_header_regexes.yaml"); diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 204dc7a96..de625351d 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -1,14 +1,19 @@ #![allow(clippy::too_many_arguments)] use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, - sync::{Arc, LazyLock, Mutex, RwLock}, + sync::{ + Arc, LazyLock, Mutex, RwLock, + atomic::{AtomicBool, Ordering}, + }, time::Duration, }; use anyhow::anyhow; use axum::{ Extension, Json, Router, + extract::DefaultBodyLimit, http::{Request, StatusCode}, + middleware, routing::{delete, get, post, put}, }; use axum_extra::extract::cookie::Key; @@ -59,12 +64,19 @@ use regex::Regex; use secrecy::ExposeSecret; use semver::Version; use sqlx::PgPool; -use tokio::sync::{ - broadcast::Sender, - mpsc::{UnboundedReceiver, UnboundedSender}, +use tokio::{ + spawn, + sync::{ + broadcast::Sender, + mpsc::{UnboundedReceiver, UnboundedSender}, + }, + time::sleep, +}; +use tower_governor::{ + GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, }; use tower_http::{ - set_header::SetResponseHeaderLayer, + timeout::TimeoutLayer, trace::{DefaultOnResponse, TraceLayer}, }; use tracing::Level; @@ -202,6 +214,20 @@ extern crate tracing; #[macro_use] extern crate serde; +/// Default request body size limit applied globally to every route. +const REQUEST_BODY_LIMIT: usize = 256 * 1024; // 256 KB + +/// Raised body size limit for the WireGuard config import endpoint, which may +/// carry configs with hundreds of peers. +const NETWORK_IMPORT_BODY_LIMIT: usize = 4 * 1024 * 1024; // 4 MB + +/// Maximum time a single request may take before the server returns 408. +/// Applies to all routes except long-lived SSE streams. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// How often the rate limiter evicts stale per-IP entries from its in-memory store. +const RATE_LIMITER_CLEANUP_PERIOD: Duration = Duration::from_secs(60); + static PHONE_NUMBER_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"^(\+?\d{1,3}\s?)?(\(\d{1,3}\)|\d{1,3})[-\s]?\d{1,4}[-\s]?\d{1,4}?$") .expect("Failed to parse phone number regex") @@ -232,9 +258,9 @@ pub fn build_webapp( key: Key, failed_logins: Arc>, event_tx: UnboundedSender, - version: Version, incompatible_components: Arc>, proxy_control_tx: tokio::sync::mpsc::Sender, + tls_active: Arc, ) -> Router { let webapp: Router = Router::new() .route("/", get(index)) @@ -400,10 +426,7 @@ pub fn build_webapp( get(gateway_details) .put(update_gateway) .delete(delete_gateway), - ) - // Proxy setup with SSE - .route("/proxy/setup/stream", get(setup_proxy_tls_stream)) - .route("/proxy/acme/stream", get(stream_proxy_acme)), + ), ); // Enterprise features @@ -557,7 +580,10 @@ pub fn build_webapp( .route("/network", post(create_network).get(list_networks)) .route("/network/count", get(count_networks)) .route("/network/display", get(get_locations_display)) - .route("/network/import", post(import_network)) + .route( + "/network/import", + post(import_network).layer(DefaultBodyLimit::max(NETWORK_IMPORT_BODY_LIMIT)), + ) .route("/network/stats", get(locations_overview_stats)) .route("/network/gateways", get(all_gateways_status)) .route( @@ -566,11 +592,6 @@ pub fn build_webapp( .delete(delete_network) .get(network_details), ) - // Gateway adding (uses SSE) - .route( - "/network/{network_id}/gateways/setup", - get(setup_gateway_tls_stream), - ) .route("/network/{network_id}/gateways", get(gateway_status)) .route("/network/{network_id}/devices", post(add_user_devices)) .route( @@ -612,31 +633,54 @@ pub fn build_webapp( .layer(Extension(worker_state)), ); - let webapp = webapp.layer(DefguardVersionLayer::new(version)).layer( - SetResponseHeaderLayer::if_not_present( - headers::CONTENT_SECURITY_POLICY_HEADER_NAME, - headers::CONTENT_SECURITY_POLICY_HEADER_VALUE, - ), + // SSE routes are long-lived connections; they must not be wrapped by the + // request timeout. They are merged in after TimeoutLayer is applied to the + // main router so that they bypass the timeout while still receiving all + // other middleware (security headers, tracing, body limit, etc.). + let sse_routes: Router = Router::new().nest( + "/api/v1", + Router::new() + .route("/proxy/setup/stream", get(setup_proxy_tls_stream)) + .route("/proxy/acme/stream", get(stream_proxy_acme)) + .route( + "/network/{network_id}/gateways/setup", + get(setup_gateway_tls_stream), + ), ); + let app_state = AppState::new( + pool.clone(), + webhook_tx, + webhook_rx, + wireguard_tx, + web_reload_tx, + key, + failed_logins, + event_tx, + incompatible_components, + proxy_control_tx.clone(), + tls_active, + ); + + let webapp = webapp + // Apply timeout to the main router BEFORE merging SSE routes so + // that long-lived streams bypass it. + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + REQUEST_TIMEOUT, + )) + .merge(sse_routes); + let swagger = SwaggerUi::new("/api-docs").url("/api-docs/openapi.json", openapi::ApiDoc::openapi()); webapp - .with_state(AppState::new( - pool.clone(), - webhook_tx, - webhook_rx, - wireguard_tx, - web_reload_tx, - key, - failed_logins, - event_tx, - incompatible_components, - proxy_control_tx.clone(), - )) + .with_state(app_state) .layer(Extension(pool)) .layer(Extension(proxy_control_tx)) + // swagger is merged before TraceLayer and DefaultBodyLimit so that those + // middleware layers cover swagger routes too. + .merge(swagger) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request<_>| { @@ -648,7 +692,28 @@ pub fn build_webapp( }) .on_response(DefaultOnResponse::new().level(Level::INFO)), ) - .merge(swagger) + // Global request body size limit. Per-route layers (e.g. /network/import) can + // override this by applying a larger DefaultBodyLimit closer to the handler. + .layer(DefaultBodyLimit::max(REQUEST_BODY_LIMIT)) +} + +/// Wraps a router with the outermost security layers: the version header and the +/// baseline security headers middleware. +/// +/// Called by both `run_web_server` and the integration-test helper so that +/// test clients exercise the same middleware stack as the real server. +pub fn apply_security_layers(router: Router, tls_active: Arc) -> Router { + let tls_for_headers = Arc::clone(&tls_active); + // Version and security headers are the outermost layers so that ALL short-circuit + // responses (408 timeout, 413 body-too-large, 429 rate-limited) and swagger routes + // also carry the baseline security headers and the server version header. + router + .layer(DefguardVersionLayer::new( + Version::parse(VERSION).expect("VERSION is a valid semver string"), + )) + .layer(middleware::from_fn(move |req, next| { + headers::security_headers_middleware(Arc::clone(&tls_for_headers), req, next) + })) } /// Runs core web server exposing REST API. @@ -668,7 +733,9 @@ pub async fn run_web_server( let settings = Settings::get_current_settings(); let key = Key::from(settings.secret_key_required()?.as_bytes()); - let webapp = build_webapp( + let tls_active = Arc::new(AtomicBool::new(false)); + + let mut webapp = build_webapp( webhook_tx, webhook_rx, wireguard_tx, @@ -678,12 +745,47 @@ pub async fn run_web_server( key, failed_logins, event_tx, - Version::parse(VERSION)?, incompatible_components, proxy_control_tx, + Arc::clone(&tls_active), ); info!("Started web services"); let server_config = server_config(); + + // Setup rate limiter. Both fields default to non-zero so limiting is on by default; + // operators can set either env var to 0 to disable. + debug!( + "Configuring rate limiter, per_second: {}, burst: {}", + server_config.rate_limit_per_second, server_config.rate_limit_burst + ); + let governor_conf = GovernorConfigBuilder::default() + .key_extractor(SmartIpKeyExtractor) + .per_second(server_config.rate_limit_per_second) + .burst_size(server_config.rate_limit_burst) + .finish(); + if let Some(conf) = governor_conf { + let governor_limiter = conf.limiter().clone(); + spawn(async move { + loop { + sleep(RATE_LIMITER_CLEANUP_PERIOD).await; + debug!( + "Cleaning-up rate limiter storage, current size: {}", + governor_limiter.len() + ); + governor_limiter.retain_recent(); + } + }); + info!( + "Rate limiter configured: {} req/s per IP, burst {}", + server_config.rate_limit_per_second, server_config.rate_limit_burst + ); + webapp = webapp.layer(GovernorLayer::new(conf)); + } else { + info!("Rate limiting disabled (per_second or burst is 0)"); + } + + webapp = apply_security_layers(webapp, Arc::clone(&tls_active)); + let addr = SocketAddr::new( server_config .http_bind_address @@ -696,13 +798,14 @@ pub async fn run_web_server( loop { let handle = axum_server::Handle::new(); let handle_clone = handle.clone(); - let app = webapp - .clone() - .into_make_service_with_connect_info::(); let current_tls_cert_pair = Certificates::get_or_default(&pool).await.map_or(None, |c| { c.core_http_cert_pair() .map(|(cert, key)| (cert.to_owned(), key.to_owned())) }); + tls_active.store(current_tls_cert_pair.is_some(), Ordering::Relaxed); + let app = webapp + .clone() + .into_make_service_with_connect_info::(); let mut server_task = tokio::spawn(async move { if let Some((cert_pem, key_pem)) = current_tls_cert_pair { diff --git a/crates/defguard_core/tests/integration/api/auth.rs b/crates/defguard_core/tests/integration/api/auth.rs index af2e8f8b9..8f2ec71ec 100644 --- a/crates/defguard_core/tests/integration/api/auth.rs +++ b/crates/defguard_core/tests/integration/api/auth.rs @@ -41,8 +41,12 @@ async fn dg25_19_clickjacking_vulnerability(_: PgPoolOptions, options: PgConnect let response = client.get("/").send().await; let headers = response.headers(); - let csp_header = headers.get("content-security-policy").unwrap(); - let csp_value = csp_header.to_str().unwrap(); + let csp_header = headers + .get("content-security-policy") + .expect("Content-Security-Policy header must be present on every response"); + let csp_value = csp_header + .to_str() + .expect("CSP header value must be valid ASCII"); assert!( csp_value.contains("frame-ancestors 'none'"), "CSP header should block all iframes with 'none' directive" diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index 56d20f666..f628aafe6 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -3,7 +3,7 @@ pub(crate) mod client; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, str::FromStr, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, atomic::AtomicBool}, }; use axum_extra::extract::cookie::Key; @@ -13,7 +13,6 @@ use defguard_certs::{ }; pub use defguard_common::db::setup_pool; use defguard_common::{ - VERSION, config::DefGuardConfig, db::{ Id, @@ -22,6 +21,7 @@ use defguard_common::{ secret::SecretStringWrapper, }; use defguard_core::{ + apply_security_layers, auth::failed_login::FailedLoginMap, build_webapp, db::AppEvent, @@ -31,7 +31,6 @@ use defguard_core::{ handlers::{Auth, user::UserDetails}, }; use reqwest::{StatusCode, header::HeaderName}; -use semver::Version; use serde_json::json; use sqlx::PgPool; use tokio::{ @@ -140,6 +139,7 @@ pub(crate) async fn make_base_client( ); let (web_reload_tx, _web_reload_rx) = broadcast::channel::<()>(8); + let tls_active = Arc::new(AtomicBool::new(false)); let webapp = build_webapp( tx, rx, @@ -150,10 +150,11 @@ pub(crate) async fn make_base_client( key, failed_logins, api_event_tx, - Version::parse(VERSION).unwrap(), Arc::default(), proxy_control_tx, + Arc::clone(&tls_active), ); + let webapp = apply_security_layers(webapp, tls_active); ( TestClient::new(webapp, listener, api_event_rx), diff --git a/crates/defguard_core/tests/integration/api/proxy_certs.rs b/crates/defguard_core/tests/integration/api/proxy_certs.rs index 893892368..5f0fb378b 100644 --- a/crates/defguard_core/tests/integration/api/proxy_certs.rs +++ b/crates/defguard_core/tests/integration/api/proxy_certs.rs @@ -8,14 +8,13 @@ /// was sent after a successful cert operation without needing a real proxy process. use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, atomic::AtomicBool}, time::Duration, }; use axum_extra::extract::cookie::Key; use defguard_certs::CertificateAuthority; use defguard_common::{ - VERSION, db::{ models::{ Certificates, ProxyCertSource, Settings, @@ -35,7 +34,6 @@ use defguard_core::{ handlers::Auth, }; use reqwest::StatusCode; -use semver::Version; use serde_json::json; use sqlx::{ PgPool, @@ -149,9 +147,9 @@ async fn make_test_client_with_proxy_rx( key, failed_logins, api_event_tx, - Version::parse(VERSION).unwrap(), Arc::default(), proxy_control_tx, + Arc::new(AtomicBool::new(false)), ); let client = TestClient::new(webapp, listener, api_event_rx); diff --git a/crates/defguard_setup/src/migration.rs b/crates/defguard_setup/src/migration.rs index 738321c92..ee1980bf4 100644 --- a/crates/defguard_setup/src/migration.rs +++ b/crates/defguard_setup/src/migration.rs @@ -1,6 +1,6 @@ use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, - sync::{Arc, Mutex, RwLock}, + sync::{Arc, Mutex, RwLock, atomic::AtomicBool}, }; use anyhow::anyhow; @@ -93,6 +93,7 @@ pub fn build_migration_webapp( event_tx, incompatible_components, proxy_control_tx.clone(), + Arc::new(AtomicBool::new(false)), ); let router = Router::new() diff --git a/flake.lock b/flake.lock index 2ecf91fc0..081d78eb9 100644 --- a/flake.lock +++ b/flake.lock @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1776827647, - "narHash": "sha256-sYixYhp5V8jCajO8TRorE4fzs7IkL4MZdfLTKgkPQBk=", + "lastModified": 1776914043, + "narHash": "sha256-qug5r56yW1qOsjSI99l3Jm15JNT9CvS2otkXNRNtrPI=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "40e6ccc06e1245a4837cbbd6bdda64e21cc67379", + "rev": "2d35c4358d7de3a0e606a6e8b27925d981c01cc3", "type": "github" }, "original": { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 20cd22b2c..63a7f1b47 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -312,24 +312,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.12': resolution: {integrity: sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.12': resolution: {integrity: sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.12': resolution: {integrity: sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.12': resolution: {integrity: sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig==} @@ -609,89 +613,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -809,36 +829,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -926,36 +952,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -2114,24 +2146,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}