From a13b34b5716e52f2723b2eaa89860498f5778b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 24 Aug 2026 23:47:02 +0200 Subject: [PATCH 01/10] replace old CIDR regex --- new-ui/src/shared/utils/patterns.ts | 2 -- new-ui/src/shared/utils/zod.ts | 9 +++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/new-ui/src/shared/utils/patterns.ts b/new-ui/src/shared/utils/patterns.ts index 60baf039f..54dfaa72b 100644 --- a/new-ui/src/shared/utils/patterns.ts +++ b/new-ui/src/shared/utils/patterns.ts @@ -73,8 +73,6 @@ export const patternValidIp = export const patternValidIpWithMask = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:\/(?:[0-9]|[1-2][0-9]|3[0-2]))?$/; -export const cidrRegex = - /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}|[0-9a-fA-F:.]+\/\d{1,3})$/; // Regular expression to match a WireGuard endpoint. A bare IPv4 literal must // include a port (a port-less IP is almost always a mistake), while domain names // and localhost may omit it. IPv6 endpoints are validated separately via diff --git a/new-ui/src/shared/utils/zod.ts b/new-ui/src/shared/utils/zod.ts index 2e69a823e..21517f63e 100644 --- a/new-ui/src/shared/utils/zod.ts +++ b/new-ui/src/shared/utils/zod.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import { - cidrRegex, patternValidEndpoint, + patternValidIpV6WithMask, patternValidIpV6WithPort, + patternValidIpWithMask, patternValidWireguardKey, } from './patterns'; @@ -36,11 +37,11 @@ export const optionalWireguardKeySchema = z .string() .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'); -// Comma-separated list of CIDR ranges; an empty value is allowed. +// Comma-separated list of IP addresses or CIDR ranges; an empty value is allowed. export const allowedIpsSchema = z.string().refine((v) => { if (!v) return true; return v .split(',') .map((s) => s.trim()) - .every((cidr) => cidrRegex.test(cidr)); -}, 'Invalid CIDR notation'); + .every((ip) => patternValidIpWithMask.test(ip) || patternValidIpV6WithMask.test(ip)); +}, 'Invalid IP address or CIDR notation'); From 2b524bd38825965e7d02ba0c0ecd77151dce50fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 24 Aug 2026 23:47:52 +0200 Subject: [PATCH 02/10] add helpers for normalizing IP addresses --- src-tauri/client-proto/src/conversions.rs | 114 ++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/src-tauri/client-proto/src/conversions.rs b/src-tauri/client-proto/src/conversions.rs index ddc242491..cda3b9a43 100644 --- a/src-tauri/client-proto/src/conversions.rs +++ b/src-tauri/client-proto/src/conversions.rs @@ -1,4 +1,6 @@ use std::{ + collections::HashSet, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, str::FromStr, time::{Duration, UNIX_EPOCH}, }; @@ -9,6 +11,45 @@ use defguard_wireguard_rs::{ use crate::defguard::client::v1::{InterfaceConfig, InterfaceData, Peer as ProtoPeer}; +/// Clears host bits from a peer allowed IP. +/// +/// This runs before `WGApi` classifies default routes. In particular, a non-canonical `/0` must +/// become an unspecified address so it takes the default-route loop-prevention path. +#[must_use] +pub fn mask_allowed_ip(mut allowed_ip: IpAddrMask) -> IpAddrMask { + allowed_ip.address = match allowed_ip.address { + IpAddr::V4(address) => { + let mask = if allowed_ip.cidr == 0 { + 0 + } else { + u32::MAX << (32 - u32::from(allowed_ip.cidr)) + }; + IpAddr::V4(Ipv4Addr::from(u32::from(address) & mask)) + } + IpAddr::V6(address) => { + let mask = if allowed_ip.cidr == 0 { + 0 + } else { + u128::MAX << (128 - u32::from(allowed_ip.cidr)) + }; + IpAddr::V6(Ipv6Addr::from(u128::from(address) & mask)) + } + }; + allowed_ip +} + +/// Normalizes and deduplicates peer allowed IPs before they reach `WGApi`. +pub fn normalize_allowed_ips(config: &mut InterfaceConfiguration) { + for peer in &mut config.peers { + let mut seen = HashSet::new(); + peer.allowed_ips = std::mem::take(&mut peer.allowed_ips) + .into_iter() + .map(mask_allowed_ip) + .filter(|allowed_ip| seen.insert(allowed_ip.clone())) + .collect(); + } +} + impl From for InterfaceConfig { fn from(config: InterfaceConfiguration) -> Self { Self { @@ -153,6 +194,79 @@ mod tests { peer } + #[test] + fn test_mask_allowed_ip_clears_ipv4_host_bits() { + let allowed_ip = "172.16.0.1/24".parse::().unwrap(); + + assert_eq!( + mask_allowed_ip(allowed_ip), + "172.16.0.0/24".parse::().unwrap() + ); + } + + #[test] + fn test_mask_allowed_ip_keeps_ipv4_host_route() { + let allowed_ip = "172.16.0.1/32".parse::().unwrap(); + + assert_eq!(mask_allowed_ip(allowed_ip.clone()), allowed_ip); + } + + #[test] + fn test_mask_allowed_ip_keeps_canonical_address() { + let allowed_ip = "172.16.0.0/24".parse::().unwrap(); + + assert_eq!(mask_allowed_ip(allowed_ip.clone()), allowed_ip); + } + + #[test] + fn test_mask_allowed_ip_handles_ipv4_default_route() { + let allowed_ip = "10.0.0.1/0".parse::().unwrap(); + + assert_eq!( + mask_allowed_ip(allowed_ip), + "0.0.0.0/0".parse::().unwrap() + ); + } + + #[test] + fn test_mask_allowed_ip_clears_ipv6_host_bits() { + let allowed_ip = "2001:db8::1/96".parse::().unwrap(); + + assert_eq!( + mask_allowed_ip(allowed_ip), + "2001:db8::/96".parse::().unwrap() + ); + } + + #[test] + fn test_normalize_allowed_ips_deduplicates_after_masking() { + let mut peer = sample_peer(); + peer.allowed_ips = ["172.16.0.1/24", "172.16.0.2/24", "10.0.0.0/24"] + .into_iter() + .map(|allowed_ip| allowed_ip.parse().unwrap()) + .collect(); + let mut config = InterfaceConfiguration { + name: "wg0".into(), + prvkey: String::new(), + addresses: vec!["10.0.0.1/24".parse().unwrap()], + port: 0, + peers: vec![peer], + mtu: None, + fwmark: None, + }; + + normalize_allowed_ips(&mut config); + + assert_eq!( + config.peers[0].allowed_ips, + ["172.16.0.0/24", "10.0.0.0/24"] + .into_iter() + .map(|allowed_ip| allowed_ip.parse().unwrap()) + .collect::>() + ); + assert_eq!(config.addresses, vec!["10.0.0.1/24".parse().unwrap()]); + } + #[test] fn test_host_to_interface_data() { let secret = EphemeralSecret::random(); From 57bee8e7b4afecf6d938048b3de25547c52be603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 24 Aug 2026 23:50:02 +0200 Subject: [PATCH 03/10] normalize before configuring interface --- src-tauri/daemon/src/daemon.rs | 29 +++++++++++-------- .../enterprise/service-locations/src/linux.rs | 8 +++-- .../service-locations/src/windows.rs | 11 ++++--- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src-tauri/daemon/src/daemon.rs b/src-tauri/daemon/src/daemon.rs index 464cddd70..4838f52bc 100644 --- a/src-tauri/daemon/src/daemon.rs +++ b/src-tauri/daemon/src/daemon.rs @@ -12,14 +12,17 @@ use std::{fs, path::Path}; use defguard_client_common::dns_borrow; #[cfg(windows)] use defguard_client_posture::inspector::{device_posture_data, DiskEncryptionTarget}; -use defguard_client_proto::defguard::{ - client::v1::{ - desktop_daemon_service_server::{DesktopDaemonService, DesktopDaemonServiceServer}, - CreateInterfaceRequest, DeleteServiceLocationsRequest, InterfaceData, - ListInterfacesResponse, ManagedInterfaceData, ReadInterfaceDataRequest, - RemoveInterfaceRequest, SaveServiceLocationsRequest, +use defguard_client_proto::{ + conversions::normalize_allowed_ips, + defguard::{ + client::v1::{ + desktop_daemon_service_server::{DesktopDaemonService, DesktopDaemonServiceServer}, + CreateInterfaceRequest, DeleteServiceLocationsRequest, InterfaceData, + ListInterfacesResponse, ManagedInterfaceData, ReadInterfaceDataRequest, + RemoveInterfaceRequest, SaveServiceLocationsRequest, + }, + enterprise::posture::v2::DevicePostureData, }, - enterprise::posture::v2::DevicePostureData, }; #[cfg(target_os = "linux")] use defguard_client_service_locations::reconciler::{run_reconciler, ReconcileSignal}; @@ -125,8 +128,10 @@ fn configure_new_interface( ifname: &str, request: &CreateInterfaceRequest, wgapi: &mut WG, - interface_config: &InterfaceConfiguration, + interface_config: &mut InterfaceConfiguration, ) -> Result<(), Status> { + normalize_allowed_ips(interface_config); + // The WireGuard DNS config value can be a list of IP addresses and domain names, which will // be used as DNS servers and search domains respectively. debug!("Preparing DNS configuration for interface {ifname}"); @@ -287,7 +292,7 @@ impl DesktopDaemonService for DaemonService { ) -> Result, Status> { debug!("Received a request to create a new interface"); let request = request.into_inner(); - let config: InterfaceConfiguration = request + let mut config: InterfaceConfiguration = request .config .clone() .ok_or(Status::new( @@ -295,7 +300,7 @@ impl DesktopDaemonService for DaemonService { "Missing interface config in request", ))? .into(); - let ifname = &config.name; + let ifname = config.name.clone(); let _span = info_span!("create_interface", interface_name = &ifname).entered(); // Setup WireGuard API. let Ok(mut wgapis_map) = self.wgapis.write() else { @@ -304,7 +309,7 @@ impl DesktopDaemonService for DaemonService { }; let wgapi = wgapis_map .entry(ifname.clone()) - .or_insert(setup_wgapi(ifname)?); + .or_insert(setup_wgapi(&ifname)?); // create new interface debug!("Creating new interface {ifname}"); @@ -317,7 +322,7 @@ impl DesktopDaemonService for DaemonService { // attempt to configure new interface // remove interface if configuration fails to avoid duplicate interfaces - match configure_new_interface(ifname, &request, wgapi, &config) { + match configure_new_interface(&ifname, &request, wgapi, &mut config) { Ok(()) => info!("Finished configuring new interface {ifname}"), Err(err) => { error!("Failed to configure interface {ifname}. Error: {err}"); diff --git a/src-tauri/enterprise/service-locations/src/linux.rs b/src-tauri/enterprise/service-locations/src/linux.rs index 4e8860dce..9da1109d9 100644 --- a/src-tauri/enterprise/service-locations/src/linux.rs +++ b/src-tauri/enterprise/service-locations/src/linux.rs @@ -9,8 +9,9 @@ use std::{ }; use defguard_client_common::{dns_borrow, find_free_tcp_port, get_interface_name}; -use defguard_client_proto::defguard::client::v1::{ - SaveServiceLocationsRequest, ServiceLocation, ServiceLocationMode, +use defguard_client_proto::{ + conversions::normalize_allowed_ips, + defguard::client::v1::{SaveServiceLocationsRequest, ServiceLocation, ServiceLocationMode}, }; use defguard_wireguard_rs::{ key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration, WGApi, WireguardInterfaceApi, @@ -374,7 +375,7 @@ impl ServiceLocationManager { .collect::, _>>()?; let ifname = get_interface_name(&location.name); - let config = InterfaceConfiguration { + let mut config = InterfaceConfiguration { name: ifname.clone(), prvkey: private_key.to_string(), addresses, @@ -383,6 +384,7 @@ impl ServiceLocationManager { mtu: None, fwmark: None, }; + normalize_allowed_ips(&mut config); let mut wgapi = WGApi::new(&ifname).map_err(|err| { ServiceLocationError::InterfaceError(format!( diff --git a/src-tauri/enterprise/service-locations/src/windows.rs b/src-tauri/enterprise/service-locations/src/windows.rs index 57b2f2bd7..b6e11d633 100644 --- a/src-tauri/enterprise/service-locations/src/windows.rs +++ b/src-tauri/enterprise/service-locations/src/windows.rs @@ -9,8 +9,9 @@ use std::{ }; use defguard_client_common::{dns_borrow, find_free_tcp_port, get_interface_name}; -use defguard_client_proto::defguard::client::v1::{ - SaveServiceLocationsRequest, ServiceLocation, ServiceLocationMode, +use defguard_client_proto::{ + conversions::normalize_allowed_ips, + defguard::client::v1::{SaveServiceLocationsRequest, ServiceLocation, ServiceLocationMode}, }; use defguard_wireguard_rs::{ key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration, WGApi, WireguardInterfaceApi, @@ -578,12 +579,13 @@ impl ServiceLocationManager { private_key: &str, preshared_key: Option<&str>, ) -> Result<(), ServiceLocationError> { - let config = interface_configuration( + let mut config = interface_configuration( location, private_key, preshared_key, find_free_tcp_port().unwrap_or(DEFAULT_WIREGUARD_PORT), )?; + normalize_allowed_ips(&mut config); let ifname = location.name.clone(); let ifname = get_interface_name(&ifname); @@ -750,7 +752,8 @@ impl ServiceLocationManager { ))); }; let port = wgapi.read_interface_data()?.listen_port; - let config = interface_configuration(location, private_key, preshared_key, port)?; + let mut config = interface_configuration(location, private_key, preshared_key, port)?; + normalize_allowed_ips(&mut config); wgapi.configure_interface(&config)?; self.record_posture_session(instance_id, &location.pubkey); info!( From 46cf7559dc922dd1c96803d631a26f0d1586a803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 25 Aug 2026 11:13:15 +0200 Subject: [PATCH 04/10] rename helper --- src-tauri/client-proto/src/conversions.rs | 73 ++++++++++++++--------- 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/src-tauri/client-proto/src/conversions.rs b/src-tauri/client-proto/src/conversions.rs index cda3b9a43..84506970e 100644 --- a/src-tauri/client-proto/src/conversions.rs +++ b/src-tauri/client-proto/src/conversions.rs @@ -1,5 +1,6 @@ use std::{ collections::HashSet, + mem::take, net::{IpAddr, Ipv4Addr, Ipv6Addr}, str::FromStr, time::{Duration, UNIX_EPOCH}, @@ -11,29 +12,29 @@ use defguard_wireguard_rs::{ use crate::defguard::client::v1::{InterfaceConfig, InterfaceData, Peer as ProtoPeer}; -/// Clears host bits from a peer allowed IP. +/// Truncates host bits from a peer allowed IP. /// /// This runs before `WGApi` classifies default routes. In particular, a non-canonical `/0` must /// become an unspecified address so it takes the default-route loop-prevention path. #[must_use] -pub fn mask_allowed_ip(mut allowed_ip: IpAddrMask) -> IpAddrMask { - allowed_ip.address = match allowed_ip.address { - IpAddr::V4(address) => { - let mask = if allowed_ip.cidr == 0 { - 0 - } else { - u32::MAX << (32 - u32::from(allowed_ip.cidr)) - }; - IpAddr::V4(Ipv4Addr::from(u32::from(address) & mask)) +fn truncate_to_network(mut allowed_ip: IpAddrMask) -> IpAddrMask { + let max_cidr = if allowed_ip.address.is_ipv4() { + 32 + } else { + 128 + }; + if allowed_ip.cidr > max_cidr { + return allowed_ip; + } + + allowed_ip.address = match (allowed_ip.address, allowed_ip.mask()) { + (IpAddr::V4(address), IpAddr::V4(mask)) => { + IpAddr::V4(Ipv4Addr::from(u32::from(address) & u32::from(mask))) } - IpAddr::V6(address) => { - let mask = if allowed_ip.cidr == 0 { - 0 - } else { - u128::MAX << (128 - u32::from(allowed_ip.cidr)) - }; - IpAddr::V6(Ipv6Addr::from(u128::from(address) & mask)) + (IpAddr::V6(address), IpAddr::V6(mask)) => { + IpAddr::V6(Ipv6Addr::from(u128::from(address) & u128::from(mask))) } + _ => return allowed_ip, }; allowed_ip } @@ -42,9 +43,9 @@ pub fn mask_allowed_ip(mut allowed_ip: IpAddrMask) -> IpAddrMask { pub fn normalize_allowed_ips(config: &mut InterfaceConfiguration) { for peer in &mut config.peers { let mut seen = HashSet::new(); - peer.allowed_ips = std::mem::take(&mut peer.allowed_ips) + peer.allowed_ips = take(&mut peer.allowed_ips) .into_iter() - .map(mask_allowed_ip) + .map(truncate_to_network) .filter(|allowed_ip| seen.insert(allowed_ip.clone())) .collect(); } @@ -195,49 +196,63 @@ mod tests { } #[test] - fn test_mask_allowed_ip_clears_ipv4_host_bits() { + fn test_truncate_to_network_clears_ipv4_host_bits() { let allowed_ip = "172.16.0.1/24".parse::().unwrap(); assert_eq!( - mask_allowed_ip(allowed_ip), + truncate_to_network(allowed_ip), "172.16.0.0/24".parse::().unwrap() ); } #[test] - fn test_mask_allowed_ip_keeps_ipv4_host_route() { + fn test_truncate_to_network_keeps_ipv4_host_route() { let allowed_ip = "172.16.0.1/32".parse::().unwrap(); - assert_eq!(mask_allowed_ip(allowed_ip.clone()), allowed_ip); + assert_eq!(truncate_to_network(allowed_ip.clone()), allowed_ip); } #[test] - fn test_mask_allowed_ip_keeps_canonical_address() { + fn test_truncate_to_network_keeps_canonical_address() { let allowed_ip = "172.16.0.0/24".parse::().unwrap(); - assert_eq!(mask_allowed_ip(allowed_ip.clone()), allowed_ip); + assert_eq!(truncate_to_network(allowed_ip.clone()), allowed_ip); } #[test] - fn test_mask_allowed_ip_handles_ipv4_default_route() { + fn test_truncate_to_network_handles_ipv4_default_route() { let allowed_ip = "10.0.0.1/0".parse::().unwrap(); assert_eq!( - mask_allowed_ip(allowed_ip), + truncate_to_network(allowed_ip), "0.0.0.0/0".parse::().unwrap() ); } #[test] - fn test_mask_allowed_ip_clears_ipv6_host_bits() { + fn test_truncate_to_network_clears_ipv6_host_bits() { let allowed_ip = "2001:db8::1/96".parse::().unwrap(); assert_eq!( - mask_allowed_ip(allowed_ip), + truncate_to_network(allowed_ip), "2001:db8::/96".parse::().unwrap() ); } + #[test] + fn test_truncate_to_network_preserves_invalid_ipv4_cidr() { + let allowed_ip = IpAddrMask::new("172.16.0.1".parse().unwrap(), 33); + + assert_eq!(truncate_to_network(allowed_ip.clone()), allowed_ip); + } + + #[test] + fn test_truncate_to_network_preserves_invalid_ipv6_cidr() { + let allowed_ip = IpAddrMask::new("2001:db8::1".parse().unwrap(), 129); + + assert_eq!(truncate_to_network(allowed_ip.clone()), allowed_ip); + } + #[test] fn test_normalize_allowed_ips_deduplicates_after_masking() { let mut peer = sample_peer(); From f34d265ec8e667eab1a23f849f2cbe7010342d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 25 Aug 2026 11:13:32 +0200 Subject: [PATCH 05/10] format imports --- src-tauri/src/gui.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/gui.rs b/src-tauri/src/gui.rs index 9551ca1b6..da63fdb09 100644 --- a/src-tauri/src/gui.rs +++ b/src-tauri/src/gui.rs @@ -8,6 +8,17 @@ use std::{ thread::spawn, }; +#[cfg(target_os = "macos")] +use defguard_client_core::connection::sync_locations_and_tunnels; +use defguard_client_core::{ + connection::active_connections::close_all_connections, + version::{check_app_version, VersionCheckResult}, +}; +use log::{Level, LevelFilter}; +use tauri::{async_runtime, AppHandle, Builder, Manager, RunEvent, WindowEvent}; +use tauri_plugin_deep_link::DeepLinkExt; +use tauri_plugin_log::{Target, TargetKind}; + #[cfg(unix)] use crate::set_perms; #[cfg(windows)] @@ -38,16 +49,6 @@ use crate::{ }; #[cfg(all(target_os = "macos", feature = "macos_installer"))] use crate::{connection::apple::PLUGIN_BUNDLE_ID, system_extension::activate_system_extension}; -#[cfg(target_os = "macos")] -use defguard_client_core::connection::sync_locations_and_tunnels; -use defguard_client_core::{ - connection::active_connections::close_all_connections, - version::{check_app_version, VersionCheckResult}, -}; -use log::{Level, LevelFilter}; -use tauri::{async_runtime, AppHandle, Builder, Manager, RunEvent, WindowEvent}; -use tauri_plugin_deep_link::DeepLinkExt; -use tauri_plugin_log::{Target, TargetKind}; const ENABLE_WELCOME_SCREEN: bool = false; From 57ed158bcb3b6c91b4f8a3679157e2e92e5e2fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 25 Aug 2026 11:51:09 +0200 Subject: [PATCH 06/10] deduplicate IP validation --- .../UpdateTunnelModal/UpdateTunnelModal.tsx | 15 ++-------- .../GeneralInformationStep.tsx | 15 ++-------- new-ui/src/shared/utils/patterns.ts | 6 ---- new-ui/src/shared/utils/zod.ts | 28 +++++++++++++------ 4 files changed, 23 insertions(+), 41 deletions(-) diff --git a/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx b/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx index abe5164b7..048f0265e 100644 --- a/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx +++ b/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx @@ -21,13 +21,10 @@ import { Snackbar } from '../../../../../shared/providers/snackbar/snackbar'; import { api } from '../../../../../shared/rust-api/api'; import { ThemeSpacing } from '../../../../../shared/types'; import { isPresent } from '../../../../../shared/utils/isPresent'; -import { - patternValidIpV6WithMask, - patternValidIpWithMask, -} from '../../../../../shared/utils/patterns'; import { allowedIpsSchema, endpointSchema, + interfaceAddressesSchema, optionalWireguardKeySchema, wireguardKeySchema, } from '../../../../../shared/utils/zod'; @@ -67,15 +64,7 @@ export const UpdateTunnelModal = () => { const formSchema = z.object({ name: z.string().trim().min(1, 'Field is required'), - address: z.string().refine((value) => { - if (!value) return false; - return value - .split(',') - .map((ip) => ip.trim()) - .every( - (ip) => patternValidIpWithMask.test(ip) || patternValidIpV6WithMask.test(ip), - ); - }, 'Field is invalid'), + address: interfaceAddressesSchema, prvkey: wireguardKeySchema, pubkey: wireguardKeySchema, server_pubkey: wireguardKeySchema, diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/GeneralInformationStep/GeneralInformationStep.tsx b/new-ui/src/pages/full/TunnelWizardPage/steps/GeneralInformationStep/GeneralInformationStep.tsx index c82cd7ec2..8c6954657 100644 --- a/new-ui/src/pages/full/TunnelWizardPage/steps/GeneralInformationStep/GeneralInformationStep.tsx +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/GeneralInformationStep/GeneralInformationStep.tsx @@ -17,23 +17,12 @@ import { formChangeLogic } from '../../../../../shared/formLogic'; import { Snackbar } from '../../../../../shared/providers/snackbar/snackbar'; import { api } from '../../../../../shared/rust-api/api'; import { ThemeSpacing } from '../../../../../shared/types'; -import { - patternValidIpV6WithMask, - patternValidIpWithMask, -} from '../../../../../shared/utils/patterns'; +import { interfaceAddressesSchema } from '../../../../../shared/utils/zod'; import { useTunnelWizardStore } from '../../hooks/useTunnelWizardStore'; const formSchema = z.object({ name: z.string().trim().min(1, 'Field is required'), - address: z.string().refine((value) => { - if (value) { - const ips = value.split(',').map((ip) => ip.trim()); - return ips.every( - (ip) => patternValidIpWithMask.test(ip) || patternValidIpV6WithMask.test(ip), - ); - } - return false; - }, 'Field is invalid'), + address: interfaceAddressesSchema, }); type FormFields = z.infer; diff --git a/new-ui/src/shared/utils/patterns.ts b/new-ui/src/shared/utils/patterns.ts index 54dfaa72b..a167119b1 100644 --- a/new-ui/src/shared/utils/patterns.ts +++ b/new-ui/src/shared/utils/patterns.ts @@ -70,9 +70,6 @@ export const patternValidDomain = export const patternValidIp = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -export const patternValidIpWithMask = - /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:\/(?:[0-9]|[1-2][0-9]|3[0-2]))?$/; - // Regular expression to match a WireGuard endpoint. A bare IPv4 literal must // include a port (a port-less IP is almost always a mistake), while domain names // and localhost may omit it. IPv6 endpoints are validated separately via @@ -84,9 +81,6 @@ export const patternValidEndpoint = export const patternValidIpV6 = /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/; -export const patternValidIpV6WithMask = - /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))(?:\/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$/; - // Reuse pattern from above to support format [ipv6]:port export const patternValidIpV6WithPort = /^\[((([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))(\/128)?)\]:(\d{1,5})$/; diff --git a/new-ui/src/shared/utils/zod.ts b/new-ui/src/shared/utils/zod.ts index 21517f63e..5200740b5 100644 --- a/new-ui/src/shared/utils/zod.ts +++ b/new-ui/src/shared/utils/zod.ts @@ -1,9 +1,7 @@ import { z } from 'zod'; import { patternValidEndpoint, - patternValidIpV6WithMask, patternValidIpV6WithPort, - patternValidIpWithMask, patternValidWireguardKey, } from './patterns'; @@ -37,11 +35,23 @@ export const optionalWireguardKeySchema = z .string() .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'); -// Comma-separated list of IP addresses or CIDR ranges; an empty value is allowed. -export const allowedIpsSchema = z.string().refine((v) => { - if (!v) return true; - return v +const ipOrCidrSchema = z.union([z.ipv4(), z.ipv6(), z.cidrv4(), z.cidrv6()]); + +const isValidIpList = (value: string) => + value .split(',') - .map((s) => s.trim()) - .every((ip) => patternValidIpWithMask.test(ip) || patternValidIpV6WithMask.test(ip)); -}, 'Invalid IP address or CIDR notation'); + .map((ip) => ip.trim()) + .every((ip) => ipOrCidrSchema.safeParse(ip).success); + +// A required comma-separated list of interface addresses or CIDR ranges. +export const interfaceAddressesSchema = z + .string() + .refine((value) => Boolean(value) && isValidIpList(value), 'Field is invalid'); + +// Comma-separated list of allowed IP addresses or CIDR ranges; an empty value is allowed. +export const allowedIpsSchema = z + .string() + .refine( + (value) => !value || isValidIpList(value), + 'Invalid IP address or CIDR notation', + ); From c866704764c03b8a4d9a22726b86458def6fb204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 25 Aug 2026 13:20:04 +0200 Subject: [PATCH 07/10] normalize allowed IPs in dg CLI --- src-tauri/Cargo.lock | 1 + src-tauri/cli/Cargo.toml | 1 + src-tauri/cli/src/bin/dg.rs | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 664d45602..bff3aa924 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1771,6 +1771,7 @@ version = "2.1.0" dependencies = [ "clap", "defguard-client-common", + "defguard-client-proto", "defguard_wireguard_rs", "dirs-next", "prost", diff --git a/src-tauri/cli/Cargo.toml b/src-tauri/cli/Cargo.toml index c9b511d04..95abcb3a6 100644 --- a/src-tauri/cli/Cargo.toml +++ b/src-tauri/cli/Cargo.toml @@ -13,6 +13,7 @@ tonic-prost-build.workspace = true [dependencies] clap.workspace = true common = { package = "defguard-client-common", path = "../common" } +defguard-client-proto = { path = "../client-proto" } defguard_wireguard_rs = { workspace = true, features = ["check_dependencies"] } dirs-next.workspace = true prost.workspace = true diff --git a/src-tauri/cli/src/bin/dg.rs b/src-tauri/cli/src/bin/dg.rs index 28e59a603..7e60116a2 100644 --- a/src-tauri/cli/src/bin/dg.rs +++ b/src-tauri/cli/src/bin/dg.rs @@ -11,6 +11,7 @@ use std::{ use clap::{builder::FalseyValueParser, command, value_parser, Arg, Command}; use common::{dns_borrow, find_free_tcp_port, get_interface_name}; +use defguard_client_proto::conversions::normalize_allowed_ips; #[cfg(not(target_os = "macos"))] use defguard_wireguard_rs::Kernel; #[cfg(target_os = "macos")] @@ -243,7 +244,7 @@ async fn connect(config: CliConfig, ifname: String, trigger: Arc) -> Res .collect::>(); debug!("Parsed assigned IPs: {addresses:?}"); - let config = InterfaceConfiguration { + let mut config = InterfaceConfiguration { name: config.instance_info.name.clone(), prvkey: config.private_key.to_string(), addresses, @@ -252,6 +253,7 @@ async fn connect(config: CliConfig, ifname: String, trigger: Arc) -> Res mtu: None, fwmark: None, }; + normalize_allowed_ips(&mut config); let configure_interface_result = wgapi.configure_interface(&config); configure_interface_result.expect("Failed to configure WireGuard interface"); From bc3ab2813df6ad96fab80644517140ca83ba3a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 28 Aug 2026 14:02:09 +0200 Subject: [PATCH 08/10] add short explanation for extra check --- src-tauri/Cargo.lock | 1 + src-tauri/client-proto/Cargo.toml | 1 + src-tauri/client-proto/src/conversions.rs | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bff3aa924..67f5d5eaf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1697,6 +1697,7 @@ dependencies = [ "tonic", "tonic-prost", "tonic-prost-build", + "tracing", "wmi", "x25519-dalek 3.0.0", ] diff --git a/src-tauri/client-proto/Cargo.toml b/src-tauri/client-proto/Cargo.toml index 30932eb3d..3046dc492 100644 --- a/src-tauri/client-proto/Cargo.toml +++ b/src-tauri/client-proto/Cargo.toml @@ -17,6 +17,7 @@ serde.workspace = true serde_with = "3.11" tonic.workspace = true tonic-prost.workspace = true +tracing.workspace = true defguard_wireguard_rs.workspace = true diff --git a/src-tauri/client-proto/src/conversions.rs b/src-tauri/client-proto/src/conversions.rs index 84506970e..c0c58f3e2 100644 --- a/src-tauri/client-proto/src/conversions.rs +++ b/src-tauri/client-proto/src/conversions.rs @@ -9,6 +9,7 @@ use std::{ use defguard_wireguard_rs::{ host::Host, key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration, }; +use tracing::debug; use crate::defguard::client::v1::{InterfaceConfig, InterfaceData, Peer as ProtoPeer}; @@ -23,7 +24,11 @@ fn truncate_to_network(mut allowed_ip: IpAddrMask) -> IpAddrMask { } else { 128 }; + + // Unreachable via `FromStr`, which rejects an out-of-range cidr, but `IpAddrMask::new` and the + // public `cidr` field don't. Bail out rather than let `mask()` underflow its shift. if allowed_ip.cidr > max_cidr { + debug!("Leaving allowed IP {allowed_ip} unnormalized, its cidr exceeds {max_cidr}"); return allowed_ip; } From d28a98f295c2cb82443b8163adf72bbbb7af46be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 28 Aug 2026 14:33:18 +0200 Subject: [PATCH 09/10] use existing constants --- src-tauri/client-proto/src/conversions.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/client-proto/src/conversions.rs b/src-tauri/client-proto/src/conversions.rs index c0c58f3e2..212ec9f34 100644 --- a/src-tauri/client-proto/src/conversions.rs +++ b/src-tauri/client-proto/src/conversions.rs @@ -20,14 +20,14 @@ use crate::defguard::client::v1::{InterfaceConfig, InterfaceData, Peer as ProtoP #[must_use] fn truncate_to_network(mut allowed_ip: IpAddrMask) -> IpAddrMask { let max_cidr = if allowed_ip.address.is_ipv4() { - 32 + Ipv4Addr::BITS } else { - 128 + Ipv6Addr::BITS }; // Unreachable via `FromStr`, which rejects an out-of-range cidr, but `IpAddrMask::new` and the // public `cidr` field don't. Bail out rather than let `mask()` underflow its shift. - if allowed_ip.cidr > max_cidr { + if allowed_ip.cidr as u32 > max_cidr { debug!("Leaving allowed IP {allowed_ip} unnormalized, its cidr exceeds {max_cidr}"); return allowed_ip; } From 1645659f29d76186bbddb9a8c8830214d4fb5cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 28 Aug 2026 14:45:50 +0200 Subject: [PATCH 10/10] remove duplicate import --- src-tauri/src/gui.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src-tauri/src/gui.rs b/src-tauri/src/gui.rs index dd79cf593..ba9d13973 100644 --- a/src-tauri/src/gui.rs +++ b/src-tauri/src/gui.rs @@ -8,8 +8,6 @@ use std::{ thread::spawn, }; -#[cfg(target_os = "macos")] -use defguard_client_core::connection::sync_locations_and_tunnels; #[cfg(target_os = "macos")] use defguard_client_core::connection::sync_locations_and_tunnels; use defguard_client_core::{