diff --git a/crates/defguard_core/src/enterprise/posture/evaluation.rs b/crates/defguard_core/src/enterprise/posture/evaluation.rs index f1d42958b..e45273028 100644 --- a/crates/defguard_core/src/enterprise/posture/evaluation.rs +++ b/crates/defguard_core/src/enterprise/posture/evaluation.rs @@ -1,7 +1,7 @@ use defguard_common::db::Id; use defguard_proto::enterprise::posture::{ - BoolCheck, DevicePostureCheckRequest, DevicePostureData, Int32Check, StringCheck, - UnavailableReason, bool_check::Result as BoolResult, int32_check::Result as Int32Result, + BoolCheck, DevicePostureData, Int32Check, StringCheck, UnavailableReason, + bool_check::Result as BoolResult, int32_check::Result as Int32Result, string_check::Result as StringResult, }; use sqlx::PgPool; @@ -230,37 +230,27 @@ fn client_version_requirement<'a>( /// Returns [`PostureResult::Fail`] with accumulated [`FailureReason`]s otherwise. pub(crate) async fn validate_posture( pool: &PgPool, - request: &DevicePostureCheckRequest, + location_id: Id, + pubkey: &str, + posture_data: Option<&DevicePostureData>, ) -> Result { - debug!( - "Performing posture check for device {}: {:?}", - request.pubkey, request.device_posture_data - ); + debug!("Performing posture check for device {pubkey}: {posture_data:?}"); // If location has no assigned postures - pass immediately (no license required). - let posture_ids = DevicePostureLocation::find_by_location(pool, request.location_id).await?; + let posture_ids = DevicePostureLocation::find_by_location(pool, location_id).await?; if posture_ids.is_empty() { - debug!( - "No posture policies assigned to location {} — passing device {}", - request.location_id, request.pubkey - ); + debug!("No posture policies assigned to location {location_id} — passing device {pubkey}"); return Ok(PostureResult::Pass); } // Policies exist - enforce the enterprise license. if !has_enterprise_access(Some(LicenseFeature::DevicePosture)) { - warn!( - "No active enterprise license - posture check aborted for device {}", - request.pubkey - ); + warn!("No active enterprise license - posture check aborted for device {pubkey}"); return Err(PostureCheckError::NoActiveEnterpriseLicense); } - let Some(data) = request.device_posture_data.as_ref() else { - info!( - "Missing posture data - posture check failed for device {}", - request.pubkey - ); + let Some(data) = posture_data else { + info!("Missing posture data - posture check failed for device {pubkey}"); return Ok(PostureResult::Fail(vec![FailureReason::MissingPostureData])); }; @@ -322,7 +312,7 @@ pub(crate) async fn validate_posture( } if all_failures.is_empty() { - info!("Posture check passed for device {}", request.pubkey); + info!("Posture check passed for device {pubkey}"); Ok(PostureResult::Pass) } else { Ok(PostureResult::Fail(all_failures)) diff --git a/crates/defguard_core/src/enterprise/posture/tests.rs b/crates/defguard_core/src/enterprise/posture/tests.rs index 19c6b3ebb..d7bb5023d 100644 --- a/crates/defguard_core/src/enterprise/posture/tests.rs +++ b/crates/defguard_core/src/enterprise/posture/tests.rs @@ -8,8 +8,8 @@ use defguard_common::db::{ setup_pool, }; use defguard_proto::enterprise::posture::{ - BoolCheck, DevicePostureCheckRequest, DevicePostureData, Int32Check, StringCheck, - UnavailableReason, bool_check, int32_check, string_check, + BoolCheck, DevicePostureData, Int32Check, StringCheck, UnavailableReason, bool_check, + int32_check, string_check, }; use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions}; @@ -29,6 +29,8 @@ use crate::{ // Test helpers // --------------------------------------------------------------------------- +const TEST_DEVICE_PUBKEY: &str = "testpubkey"; + fn set_enterprise_license() { let limits = LicenseLimits { users: 100, @@ -117,14 +119,6 @@ fn windows_posture_data() -> DevicePostureData { } } -fn make_request(location_id: Id, data: Option) -> DevicePostureCheckRequest { - DevicePostureCheckRequest { - location_id, - pubkey: "testpubkey".to_owned(), - device_posture_data: data, - } -} - /// Creates a Linux posture policy with no OS version requirement (Linux has no version list). async fn save_linux_policy( pool: &PgPool, @@ -409,7 +403,9 @@ async fn pass_no_posture_assigned(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(linux_posture_data("22.04", true))), + location_id, + TEST_DEVICE_PUBKEY, + Some(&linux_posture_data("22.04", true)), ) .await .unwrap(); @@ -428,7 +424,9 @@ async fn pass_all_checks_met(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(windows_posture_data())), + location_id, + TEST_DEVICE_PUBKEY, + Some(&windows_posture_data()), ) .await .unwrap(); @@ -452,7 +450,7 @@ async fn pass_boundary_os_version_exact(_: PgPoolOptions, options: PgConnectOpti ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -474,7 +472,7 @@ async fn pass_macos_version(_: PgPoolOptions, options: PgConnectOptions) { ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -489,7 +487,7 @@ async fn fail_missing_posture_data(_: PgPoolOptions, options: PgConnectOptions) save_linux_policy(&pool, location_id, None, None, true).await; - let result = validate_posture(&pool, &make_request(location_id, None)) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, None) .await .unwrap(); @@ -550,7 +548,7 @@ async fn fail_os_version_too_old_regression(_: PgPoolOptions, options: PgConnect ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -576,7 +574,7 @@ async fn pass_known_client_version_meets_minimum(_: PgPoolOptions, options: PgCo let mut data = linux_posture_data("6.1.0", true); data.defguard_client_version = "2.1.2".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -600,7 +598,7 @@ async fn pass_mobile_client_version_uses_mobile_minimum( let mut data = android_posture_data("2026-01-01"); data.defguard_client_version = "1.7.0".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -624,7 +622,7 @@ async fn fail_desktop_client_version_uses_desktop_minimum( let mut data = linux_posture_data("22.04", true); data.defguard_client_version = "1.7.0".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -645,7 +643,9 @@ async fn pass_antivirus_present(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(windows_posture_data())), + location_id, + TEST_DEVICE_PUBKEY, + Some(&windows_posture_data()), ) .await .unwrap(); @@ -663,7 +663,9 @@ async fn pass_ad_domain_joined(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(windows_posture_data())), + location_id, + TEST_DEVICE_PUBKEY, + Some(&windows_posture_data()), ) .await .unwrap(); @@ -682,7 +684,7 @@ async fn pass_security_update_within_max_age(_: PgPoolOptions, options: PgConnec let mut data = windows_posture_data(); data.windows_security_update_age_days = Some(int32_check_value(15)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -733,7 +735,7 @@ async fn pass_kernel_version_meets_minimum(_: PgPoolOptions, options: PgConnectO let mut data = linux_posture_data("22.04", true); data.linux_kernel_version = Some(string_check_value("6.8.0")); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -788,7 +790,7 @@ async fn pass_device_integrity_ok(_: PgPoolOptions, options: PgConnectOptions) { ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -839,7 +841,9 @@ async fn fail_os_not_in_policy(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(linux_posture_data("22.04", true))), + location_id, + TEST_DEVICE_PUBKEY, + Some(&linux_posture_data("22.04", true)), ) .await .unwrap(); @@ -861,7 +865,9 @@ async fn fail_disk_encryption_required(_: PgPoolOptions, options: PgConnectOptio let result = validate_posture( &pool, - &make_request(location_id, Some(linux_posture_data("22.04", false))), + location_id, + TEST_DEVICE_PUBKEY, + Some(&linux_posture_data("22.04", false)), ) .await .unwrap(); @@ -889,7 +895,7 @@ async fn fail_os_version_too_old(_: PgPoolOptions, options: PgConnectOptions) { ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -917,7 +923,7 @@ async fn pass_os_version_same_major_lower_minor(_: PgPoolOptions, options: PgCon ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -938,7 +944,7 @@ async fn fail_client_version_too_old(_: PgPoolOptions, options: PgConnectOptions let mut data = linux_posture_data("22.04", true); data.defguard_client_version = "2.1.2".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -959,7 +965,7 @@ async fn pass_accept_prerelease(_: PgPoolOptions, options: PgConnectOptions) { let mut data = linux_posture_data("22.04", true); data.defguard_client_version = "2.1.0-alpha".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -980,7 +986,7 @@ async fn fail_prerelease_not_allowed(_: PgPoolOptions, options: PgConnectOptions let mut data = linux_posture_data("22.04", true); data.defguard_client_version = "1.6.0-beta1".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1002,7 +1008,7 @@ async fn fail_check_unavailable_detection_failed(_: PgPoolOptions, options: PgCo let mut data = linux_posture_data("22.04", true); data.disk_encryption = Some(bool_check_unavailable(UnavailableReason::DetectionFailed)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1029,7 +1035,7 @@ async fn fail_check_unavailable_insufficient_permissions( UnavailableReason::InsufficientPermissions, )); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1051,7 +1057,7 @@ async fn pass_check_not_applicable(_: PgPoolOptions, options: PgConnectOptions) let mut data = linux_posture_data("22.04", true); data.disk_encryption = Some(bool_check_unavailable(UnavailableReason::NotApplicable)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1132,7 +1138,9 @@ async fn fail_multi_policy_and_logic(_: PgPoolOptions, options: PgConnectOptions let result = validate_posture( &pool, - &make_request(location_id, Some(linux_posture_data("22.04", false))), + location_id, + TEST_DEVICE_PUBKEY, + Some(&linux_posture_data("22.04", false)), ) .await .unwrap(); @@ -1154,7 +1162,9 @@ async fn fail_enterprise_inactive(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(linux_posture_data("22.04", true))), + location_id, + TEST_DEVICE_PUBKEY, + Some(&linux_posture_data("22.04", true)), ) .await; @@ -1175,7 +1185,7 @@ async fn fail_antivirus_required(_: PgPoolOptions, options: PgConnectOptions) { let mut data = windows_posture_data(); data.antivirus_present = Some(bool_check_value(false)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1197,7 +1207,7 @@ async fn fail_ad_domain_required(_: PgPoolOptions, options: PgConnectOptions) { let mut data = windows_posture_data(); data.windows_ad_domain_joined = Some(bool_check_value(false)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1219,7 +1229,7 @@ async fn fail_security_update_too_old(_: PgPoolOptions, options: PgConnectOption let mut data = windows_posture_data(); data.windows_security_update_age_days = Some(int32_check_value(90)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1274,7 +1284,7 @@ async fn fail_kernel_version_too_old(_: PgPoolOptions, options: PgConnectOptions let mut data = linux_posture_data("22.04", true); data.linux_kernel_version = Some(string_check_value("5.15.0")); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1332,7 +1342,7 @@ async fn fail_device_integrity_required(_: PgPoolOptions, options: PgConnectOpti ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1354,7 +1364,7 @@ async fn fail_check_unavailable_unspecified(_: PgPoolOptions, options: PgConnect let mut data = linux_posture_data("22.04", true); data.disk_encryption = Some(bool_check_unavailable(UnavailableReason::Unspecified)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1379,7 +1389,7 @@ async fn pass_android_security_patch_within_max_age(_: PgPoolOptions, options: P .to_string(); let data = android_posture_data(&patch_date); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1400,7 +1410,7 @@ async fn fail_android_security_patch_too_old(_: PgPoolOptions, options: PgConnec .to_string(); let data = android_posture_data(&patch_date); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1421,7 +1431,7 @@ async fn fail_android_security_patch_unparseable(_: PgPoolOptions, options: PgCo let data = android_posture_data("not-a-date"); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); diff --git a/crates/defguard_core/src/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index 2cab09137..9554d0a38 100644 --- a/crates/defguard_core/src/grpc/proxy/client_mfa.rs +++ b/crates/defguard_core/src/grpc/proxy/client_mfa.rs @@ -12,6 +12,7 @@ use defguard_common::{ models::{ BiometricAuth, BiometricChallenge, Device, User, WireguardNetwork, device::{DeviceNetworkInfo, WireguardNetworkDevice}, + polling_token::PollingToken, vpn_client_session::{VpnClientMfaMethod, VpnClientSession, VpnClientSessionState}, wireguard::LocationMfaMode, }, @@ -222,12 +223,14 @@ impl ClientMfaServer { Status::internal("unexpected error") })?; if has_postures { - let posture_request = DevicePostureCheckRequest { - location_id: location.id, - pubkey: request.pubkey.clone(), - device_posture_data: request.posture_data.clone(), - }; - let posture_result = match validate_posture(&self.pool, &posture_request).await { + let posture_result = match validate_posture( + &self.pool, + location.id, + &request.pubkey, + request.posture_data.as_ref(), + ) + .await + { Ok(result) => result, Err(PostureCheckError::NoActiveEnterpriseLicense) => { debug!("No active license - skipping posture check for location {location}"); @@ -877,6 +880,30 @@ impl ClientMfaServer { request.pubkey, request.location_id ); + // Authenticate the caller before touching anything else. + // Validated first so that an unauthenticated caller cannot use the error codes below to + // probe which locations exist or which public keys are enrolled. + let Some(token) = request.token.as_deref().filter(|token| !token.is_empty()) else { + error!( + "Posture check: missing polling token for pubkey {}", + request.pubkey + ); + return Err(Status::unauthenticated("missing token")); + }; + let polling_token = PollingToken::find(&self.pool, token) + .await + .map_err(|err| { + error!("Posture check: failed to look up polling token: {err}"); + Status::internal("unexpected error") + })? + .ok_or_else(|| { + error!( + "Posture check: unknown polling token for claimed pubkey {}", + request.pubkey + ); + Status::unauthenticated("invalid token") + })?; + // Look up location, device, and user. let Ok(Some(location)) = WireguardNetwork::find_by_id(&self.pool, request.location_id).await @@ -900,6 +927,16 @@ impl ClientMfaServer { return Err(Status::invalid_argument("device not found")); }; + // Make sure caller owns the device. + if polling_token.device_id != device.id { + error!( + "Posture check: polling token belongs to device {} but request claims pubkey {} \ + (device {})", + polling_token.device_id, request.pubkey, device.id + ); + return Err(Status::unauthenticated("token does not match device")); + } + if !location.has_postures(&self.pool).await.map_err(|err| { error!("Posture check: failed to fetch postures for location {location}: {err}"); Status::internal("unexpected error") @@ -936,8 +973,16 @@ impl ClientMfaServer { })?; Self::validate_location_access(&self.pool, &location, &user_info).await?; - // Evaluate posture. - let posture_result = match validate_posture(&self.pool, &request).await { + // Evaluate posture. `location.id` rather than `request.location_id`: the location was + // already looked up and validated above, so this passes the trusted value. + let posture_result = match validate_posture( + &self.pool, + location.id, + &device.wireguard_pubkey, + request.device_posture_data.as_ref(), + ) + .await + { Ok(result) => result, Err(PostureCheckError::NoActiveEnterpriseLicense) => { debug!("No active license - skipping posture check for location {location}"); @@ -1165,6 +1210,7 @@ mod tests { models::{ Device, DeviceType, User, WireguardNetwork, device::WireguardNetworkDevice, + polling_token::PollingToken, settings::initialize_current_settings, vpn_client_session::{VpnClientMfaMethod, VpnClientSession, VpnClientSessionState}, wireguard::{LocationMfaMode, ServiceLocationMode}, @@ -1213,6 +1259,7 @@ mod tests { let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; + let token = create_polling_token(&pool, device.id).await; let (mut server, _event_rx, mut gateway_rx) = make_server(pool.clone()); let outcome = server @@ -1220,6 +1267,7 @@ mod tests { location_id: location.id, pubkey: device.wireguard_pubkey.clone(), device_posture_data: Some(passing_linux_posture_data()), + token: Some(token.clone()), }) .await .expect("posture check should pass"); @@ -1289,6 +1337,7 @@ mod tests { .save(&pool) .await .expect("failed to create previous posture session"); + let token = create_polling_token(&pool, device.id).await; let (mut server, mut event_rx, mut gateway_rx) = make_server(pool.clone()); server @@ -1296,6 +1345,7 @@ mod tests { location_id: location.id, pubkey: device.wireguard_pubkey.clone(), device_posture_data: Some(passing_linux_posture_data()), + token: Some(token.clone()), }) .await .expect("replacement posture check should pass"); @@ -1350,6 +1400,165 @@ mod tests { assert_eq!(old_session.state, VpnClientSessionState::Disconnected); } + /// A caller with no token must be refused. Without this, knowing a device's public key is + /// enough to mint a preshared key for it. + #[sqlx::test] + async fn test_posture_check_requires_a_token(_: PgPoolOptions, options: PgConnectOptions) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).await; + let user = create_user(&pool).await; + let device = create_device(&pool, user.id).await; + attach_device_to_location(&pool, location.id, device.id).await; + let (mut server, _, mut gateway_rx) = make_server(pool.clone()); + + for token in [None, Some(String::new())] { + let err = server + .handle_posture_check(DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token, + }) + .await; + let err = match err { + Ok(_) => panic!("posture check without a token must be refused"), + Err(err) => err, + }; + assert_eq!(err.code(), Code::Unauthenticated); + } + + // No session may be created and the gateway must not be touched. + assert!( + VpnClientSession::get_all_active_device_sessions_in_location( + &pool, + location.id, + device.id + ) + .await + .expect("failed to query sessions") + .is_empty() + ); + assert!(gateway_rx.try_recv().is_err()); + } + + /// An unknown token must be refused, so tokens cannot be guessed or replayed after rotation. + #[sqlx::test] + async fn test_posture_check_rejects_unknown_token(_: PgPoolOptions, options: PgConnectOptions) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).await; + let user = create_user(&pool).await; + let device = create_device(&pool, user.id).await; + attach_device_to_location(&pool, location.id, device.id).await; + let (mut server, _, _) = make_server(pool); + + let err = server + .handle_posture_check(DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token: Some("not-a-real-token".to_owned()), + }) + .await; + let err = match err { + Ok(_) => panic!("posture check with an unknown token must be refused"), + Err(err) => err, + }; + + assert_eq!(err.code(), Code::Unauthenticated); + } + + /// Regression test for the session-hijack denial of service: holding a valid token for *one* + /// device must not allow authorizing — and thereby superseding the live session of — another. + #[sqlx::test] + async fn test_posture_check_rejects_token_belonging_to_another_device( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).await; + let user = create_user(&pool).await; + + let victim = create_device(&pool, user.id).await; + attach_device_to_location(&pool, location.id, victim.id).await; + + // The attacker is a legitimately enrolled device with a token of its own. + let attacker = Device::new( + "attacker-device".to_owned(), + "attacker-pubkey".to_owned(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .expect("failed to create attacker device"); + let attacker_token = create_polling_token(&pool, attacker.id).await; + + // The victim holds a live session. + let mut victim_session = VpnClientSession::new( + location.id, + user.id, + victim.id, + Some(Utc::now().naive_utc()), + None, + ); + victim_session.preshared_key = Some("victim-psk".to_owned()); + victim_session.state = VpnClientSessionState::Connected; + let victim_session = victim_session + .save(&pool) + .await + .expect("failed to create victim session"); + + let (mut server, _, mut gateway_rx) = make_server(pool.clone()); + + // Attacker presents its own valid token but claims the victim's public key. + let err = server + .handle_posture_check(DevicePostureCheckRequest { + location_id: location.id, + pubkey: victim.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token: Some(attacker_token), + }) + .await; + let err = match err { + Ok(_) => panic!("a token from another device must not authorize this one"), + Err(err) => err, + }; + assert_eq!(err.code(), Code::Unauthenticated); + + // The victim's session must survive untouched, and the gateway must see nothing. + let victim_session = VpnClientSession::find_by_id(&pool, victim_session.id) + .await + .expect("failed to reload victim session") + .expect("victim session should still exist"); + assert_eq!(victim_session.state, VpnClientSessionState::Connected); + assert_eq!( + victim_session.preshared_key.as_deref(), + Some("victim-psk"), + "the victim's preshared key must not have been rotated" + ); + assert!( + gateway_rx.try_recv().is_err(), + "no peer delete or re-create may be sent to the gateway" + ); + } + #[sqlx::test] async fn test_posture_check_rejects_mfa_enabled_location( _: PgPoolOptions, @@ -1357,6 +1566,10 @@ mod tests { ) { let pool = setup_pool(options).await; let location = create_mfa_location(&pool).await; + // A valid token is needed to get past authentication and reach the check under test. + let user = create_user(&pool).await; + let device = create_device(&pool, user.id).await; + let token = create_polling_token(&pool, device.id).await; let (mut server, _, _) = make_server(pool); let err = match server @@ -1364,6 +1577,7 @@ mod tests { location_id: location.id, pubkey: "irrelevant".to_owned(), device_posture_data: None, + token: Some(token), }) .await { @@ -1384,6 +1598,7 @@ mod tests { let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; + let token = create_polling_token(&pool, device.id).await; let (mut server, _, _) = make_server(pool); let err = match server @@ -1391,6 +1606,7 @@ mod tests { location_id: location.id, pubkey: device.wireguard_pubkey, device_posture_data: None, + token: Some(token), }) .await { @@ -1589,6 +1805,16 @@ mod tests { .expect("failed to create device") } + /// Issues a polling token for a device, as enrollment does. Posture checks require one to + /// authenticate the caller. + async fn create_polling_token(pool: &PgPool, device_id: Id) -> String { + PollingToken::new(device_id) + .save(pool) + .await + .expect("failed to create polling token") + .token + } + #[sqlx::test] async fn test_create_new_mfa_session_disconnects_previous_active_session( _: PgPoolOptions, diff --git a/proto b/proto index cbb798774..569334098 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit cbb798774a48e77940de33b8d6df7dae519541a4 +Subproject commit 569334098f1cd7e81809c3ccd7681acfc18a7491