Skip to main content

bitwarden_pam/access_rules/
validate.rs

1use std::net::IpAddr;
2
3use thiserror::Error;
4#[cfg(feature = "wasm")]
5use wasm_bindgen::prelude::wasm_bindgen;
6
7use super::{conditions::AccessCondition, models::AccessRuleAddEditRequest};
8
9/// Maximum length of an access rule's `name` field, matching the server's constraint.
10const MAX_NAME_LENGTH: usize = 256;
11/// Maximum number of conditions allowed on a single access rule.
12const MAX_CONDITIONS: usize = 10;
13
14/// Errors returned when a locally-constructed [`AccessRuleAddEditRequest`] fails validation
15/// before being sent to the server.
16#[derive(Debug, Error, PartialEq, Eq)]
17pub enum AccessRuleValidationError {
18    /// `name` was empty (after trimming whitespace) or exceeded 256 characters.
19    #[error("Name must be between 1 and {MAX_NAME_LENGTH} characters")]
20    InvalidName,
21    /// `allows_extensions` was `true` but `max_extension_duration_seconds` was missing or not
22    /// positive.
23    #[error("A positive max extension duration is required when extensions are allowed")]
24    MissingMaxExtensionDuration,
25    /// `default_lease_duration_seconds` or `max_lease_duration_seconds` was present but not
26    /// positive.
27    #[error("Lease durations must be positive")]
28    InvalidLeaseDuration,
29    /// More than 10 conditions were provided.
30    #[error("A rule may have at most {MAX_CONDITIONS} conditions")]
31    TooManyConditions,
32    /// An `ip_allowlist` condition contained a CIDR range that failed to parse.
33    #[error("Invalid CIDR range: {0}")]
34    InvalidCidr(String),
35    /// An `ip_allowlist` condition was provided without any CIDR ranges.
36    #[error("An IP allowlist condition must contain at least one CIDR range")]
37    EmptyCidrList,
38}
39
40/// Validates a request before it is sent to the server. Unknown condition kinds are skipped -
41/// the server is the source of truth for validating condition kinds this SDK version doesn't
42/// model.
43pub fn validate_request(
44    request: &AccessRuleAddEditRequest,
45) -> Result<(), AccessRuleValidationError> {
46    let trimmed_name = request.name.trim();
47    if trimmed_name.is_empty() || trimmed_name.encode_utf16().count() > MAX_NAME_LENGTH {
48        return Err(AccessRuleValidationError::InvalidName);
49    }
50
51    if request.allows_extensions
52        && request
53            .max_extension_duration_seconds
54            .is_none_or(|seconds| seconds <= 0)
55    {
56        return Err(AccessRuleValidationError::MissingMaxExtensionDuration);
57    }
58
59    if request
60        .default_lease_duration_seconds
61        .is_some_and(|d| d <= 0)
62        || request.max_lease_duration_seconds.is_some_and(|m| m <= 0)
63    {
64        return Err(AccessRuleValidationError::InvalidLeaseDuration);
65    }
66
67    if request.conditions.len() > MAX_CONDITIONS {
68        return Err(AccessRuleValidationError::TooManyConditions);
69    }
70
71    for condition in &request.conditions {
72        if let AccessCondition::IpAllowlist { cidrs } = condition {
73            if cidrs.is_empty() {
74                return Err(AccessRuleValidationError::EmptyCidrList);
75            }
76            for cidr in cidrs {
77                if !is_valid_cidr(cidr) {
78                    return Err(AccessRuleValidationError::InvalidCidr(cidr.clone()));
79                }
80            }
81        }
82    }
83
84    Ok(())
85}
86
87/// Returns `true` when `value` is a CIDR range in canonical form: `address/prefix` where the
88/// address parses strictly (RFC dotted-quad IPv4 / RFC 4291 IPv6, no leading-zero octets, hex
89/// octets, partial addresses, or zone IDs), the prefix is a plain decimal integer in range, and
90/// no host bits are set (e.g. `10.0.0.0/8` is valid, `10.0.0.1/8` is not).
91///
92/// This is deliberately stricter than the server, which currently stores conditions verbatim, and
93/// stricter than .NET 10's `IPNetwork.TryParse`, which silently truncates host bits and interprets
94/// leading-zero octets as octal. Rejecting ambiguous input here avoids a client/server
95/// disagreement about which network a rule matches.
96///
97/// IPv4-mapped IPv6 addresses (e.g. `::ffff:10.0.0.0/104`) are also rejected as ambiguous:
98/// client and server may disagree about whether such a range overlaps the equivalent native IPv4
99/// CIDR. Use the native IPv4 form (e.g. `10.0.0.0/8`) instead.
100#[cfg_attr(feature = "wasm", wasm_bindgen)]
101pub fn is_valid_cidr(value: &str) -> bool {
102    let Some((addr, prefix)) = value.split_once('/') else {
103        return false;
104    };
105    // `u8::from_str` accepts a leading `+`, which is not a valid CIDR prefix.
106    if !prefix.bytes().all(|b| b.is_ascii_digit()) {
107        return false;
108    }
109    let Ok(prefix) = prefix.parse::<u8>() else {
110        return false;
111    };
112    match addr.parse::<IpAddr>() {
113        Ok(IpAddr::V4(ip)) => prefix <= 32 && no_host_bits(u32::from(ip).into(), prefix, 32),
114        Ok(IpAddr::V6(ip)) => {
115            // Reject IPv4-mapped IPv6 addresses (::ffff:a.b.c.d). Use to_ipv4_mapped() rather
116            // than to_ipv4() because to_ipv4() also matches the deprecated IPv4-compatible range
117            // (::a.b.c.d), which would wrongly reject ::/0 and ::1.
118            ip.to_ipv4_mapped().is_none() && prefix <= 128 && no_host_bits(ip.into(), prefix, 128)
119        }
120        Err(_) => false,
121    }
122}
123
124/// Returns true when the low `width - prefix` host bits of `addr` are all zero.
125///
126/// # Preconditions
127///
128/// Callers **must** ensure `prefix <= width`. The `host_bits == 0` branch is not merely an
129/// optimisation: it is load-bearing for panic-safety. When `prefix == width`, `host_bits` is `0`
130/// and we return early, avoiding the expression `u128::MAX >> 128`, which would panic due to
131/// Rust's overflow checks on shift amounts.
132fn no_host_bits(addr: u128, prefix: u8, width: u8) -> bool {
133    debug_assert!(prefix <= width);
134    let host_bits = width - prefix;
135    host_bits == 0 || addr & (u128::MAX >> (128 - u32::from(host_bits))) == 0
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn base_request() -> AccessRuleAddEditRequest {
143        AccessRuleAddEditRequest {
144            name: "My rule".to_string(),
145            description: None,
146            enabled: true,
147            conditions: Vec::new(),
148            single_active_lease: false,
149            default_lease_duration_seconds: None,
150            max_lease_duration_seconds: None,
151            allows_extensions: false,
152            max_extension_duration_seconds: None,
153            collections: Vec::new(),
154        }
155    }
156
157    #[test]
158    fn blank_name_is_invalid() {
159        let mut request = base_request();
160        request.name = "   ".to_string();
161        assert_eq!(
162            validate_request(&request),
163            Err(AccessRuleValidationError::InvalidName)
164        );
165    }
166
167    #[test]
168    fn name_over_256_chars_is_invalid() {
169        let mut request = base_request();
170        request.name = "a".repeat(257);
171        assert_eq!(
172            validate_request(&request),
173            Err(AccessRuleValidationError::InvalidName)
174        );
175    }
176
177    #[test]
178    fn name_at_256_chars_is_valid() {
179        let mut request = base_request();
180        request.name = "a".repeat(256);
181        assert_eq!(validate_request(&request), Ok(()));
182    }
183
184    #[test]
185    fn name_at_256_chars_with_surrounding_whitespace_is_valid() {
186        let mut request = base_request();
187        request.name = format!("  {}  ", "a".repeat(256));
188        assert_eq!(validate_request(&request), Ok(()));
189    }
190
191    #[test]
192    fn allows_extensions_without_max_duration_is_invalid() {
193        let mut request = base_request();
194        request.allows_extensions = true;
195        request.max_extension_duration_seconds = None;
196        assert_eq!(
197            validate_request(&request),
198            Err(AccessRuleValidationError::MissingMaxExtensionDuration)
199        );
200    }
201
202    #[test]
203    fn allows_extensions_with_zero_max_duration_is_invalid() {
204        let mut request = base_request();
205        request.allows_extensions = true;
206        request.max_extension_duration_seconds = Some(0);
207        assert_eq!(
208            validate_request(&request),
209            Err(AccessRuleValidationError::MissingMaxExtensionDuration)
210        );
211    }
212
213    #[test]
214    fn allows_extensions_with_positive_max_duration_is_valid() {
215        let mut request = base_request();
216        request.allows_extensions = true;
217        request.max_extension_duration_seconds = Some(60);
218        assert_eq!(validate_request(&request), Ok(()));
219    }
220
221    #[test]
222    fn more_than_ten_conditions_is_invalid() {
223        let mut request = base_request();
224        request.conditions = (0..11).map(|_| AccessCondition::HumanApproval).collect();
225        assert_eq!(
226            validate_request(&request),
227            Err(AccessRuleValidationError::TooManyConditions)
228        );
229    }
230
231    #[test]
232    fn exactly_ten_conditions_is_valid() {
233        let mut request = base_request();
234        request.conditions = (0..10).map(|_| AccessCondition::HumanApproval).collect();
235        assert_eq!(validate_request(&request), Ok(()));
236    }
237
238    #[test]
239    fn empty_cidr_list_is_invalid() {
240        let mut request = base_request();
241        request.conditions = vec![AccessCondition::IpAllowlist { cidrs: Vec::new() }];
242        assert_eq!(
243            validate_request(&request),
244            Err(AccessRuleValidationError::EmptyCidrList)
245        );
246    }
247
248    #[test]
249    fn valid_ipv4_cidr() {
250        assert!(is_valid_cidr("10.0.0.0/8"));
251    }
252
253    #[test]
254    fn valid_ipv6_cidr() {
255        assert!(is_valid_cidr("2001:db8::/32"));
256    }
257
258    #[test]
259    fn cidr_with_host_bits_set_is_invalid() {
260        assert!(!is_valid_cidr("10.0.0.1/8"));
261    }
262
263    #[test]
264    fn cidr_without_prefix_is_invalid() {
265        assert!(!is_valid_cidr("10.0.0.0"));
266    }
267
268    #[test]
269    fn garbage_cidr_is_invalid() {
270        assert!(!is_valid_cidr("not-a-cidr"));
271    }
272
273    #[test]
274    fn ip_allowlist_with_invalid_cidr_is_rejected() {
275        let mut request = base_request();
276        request.conditions = vec![AccessCondition::IpAllowlist {
277            cidrs: vec!["10.0.0.1/8".to_string()],
278        }];
279        assert_eq!(
280            validate_request(&request),
281            Err(AccessRuleValidationError::InvalidCidr(
282                "10.0.0.1/8".to_string()
283            ))
284        );
285    }
286
287    #[test]
288    fn unknown_condition_kind_is_skipped() {
289        let mut request = base_request();
290        request.conditions = vec![AccessCondition::Unknown(serde_json::json!({
291            "kind": "time_of_day",
292        }))];
293        assert_eq!(validate_request(&request), Ok(()));
294    }
295
296    // --- Edge cases for is_valid_cidr ---
297
298    #[test]
299    fn zero_zero_zero_zero_slash_zero_is_valid() {
300        assert!(is_valid_cidr("0.0.0.0/0"));
301    }
302
303    #[test]
304    fn ipv6_slash_zero_is_valid() {
305        assert!(is_valid_cidr("::/0"));
306    }
307
308    #[test]
309    fn ipv4_nonzero_host_with_slash_zero_is_invalid() {
310        // 10.0.0.0/0 has host bits set because the entire address must be zero for /0
311        assert!(!is_valid_cidr("10.0.0.0/0"));
312    }
313
314    #[test]
315    fn ipv4_full_prefix_is_valid() {
316        assert!(is_valid_cidr("10.0.0.1/32"));
317    }
318
319    #[test]
320    fn ipv6_full_prefix_is_valid() {
321        assert!(is_valid_cidr("2001:db8::1/128"));
322    }
323
324    #[test]
325    fn ipv4_prefix_out_of_range_is_invalid() {
326        assert!(!is_valid_cidr("10.0.0.0/33"));
327    }
328
329    #[test]
330    fn ipv6_prefix_out_of_range_is_invalid() {
331        assert!(!is_valid_cidr("2001:db8::/129"));
332    }
333
334    #[test]
335    fn empty_prefix_is_invalid() {
336        assert!(!is_valid_cidr("10.0.0.0/"));
337    }
338
339    #[test]
340    fn empty_address_is_invalid() {
341        assert!(!is_valid_cidr("/8"));
342    }
343
344    #[test]
345    fn double_slash_prefix_is_invalid() {
346        assert!(!is_valid_cidr("10.0.0.0/8/8"));
347    }
348
349    #[test]
350    fn leading_whitespace_is_invalid() {
351        assert!(!is_valid_cidr(" 10.0.0.0/8"));
352    }
353
354    #[test]
355    fn prefix_with_leading_whitespace_is_invalid() {
356        assert!(!is_valid_cidr("10.0.0.0/ 8"));
357    }
358
359    #[test]
360    fn ipv6_prefix_300_is_invalid() {
361        assert!(!is_valid_cidr("2001:db8::/300"));
362    }
363
364    // --- Signed/non-digit prefix characters ---
365
366    #[test]
367    fn signed_positive_prefix_is_invalid() {
368        assert!(!is_valid_cidr("10.0.0.0/+8"));
369    }
370
371    #[test]
372    fn signed_negative_prefix_is_invalid() {
373        assert!(!is_valid_cidr("10.0.0.0/-8"));
374    }
375
376    // --- Leading zero in prefix (unambiguous decimal — matches .NET behaviour) ---
377
378    #[test]
379    fn prefix_with_leading_zero_is_valid() {
380        assert!(is_valid_cidr("10.0.0.0/08"));
381    }
382
383    // --- Ambiguous / non-canonical address forms ---
384
385    #[test]
386    fn leading_zero_octet_is_invalid() {
387        // .NET parses leading-zero octets as octal (`010` → `8`), so accepting this would let
388        // client and server disagree about which network the rule matches.
389        assert!(!is_valid_cidr("010.0.0.0/8"));
390    }
391
392    #[test]
393    fn hex_octet_is_invalid() {
394        assert!(!is_valid_cidr("0x0A.0.0.0/8"));
395    }
396
397    #[test]
398    fn partial_ipv4_address_is_invalid() {
399        assert!(!is_valid_cidr("1.2.3/24"));
400    }
401
402    #[test]
403    fn ipv6_zone_id_is_invalid() {
404        assert!(!is_valid_cidr("fe80::1%1/64"));
405    }
406
407    // --- Change 1: IPv4-mapped IPv6 CIDRs are rejected ---
408
409    #[test]
410    fn ipv4_mapped_ipv6_is_invalid() {
411        // ::ffff:10.0.0.0/104 is the IPv4-mapped IPv6 form of 10.0.0.0/8; reject it as
412        // ambiguous so client and server always agree on which network a rule matches.
413        assert!(!is_valid_cidr("::ffff:10.0.0.0/104"));
414    }
415
416    #[test]
417    fn ipv6_loopback_is_not_treated_as_mapped() {
418        // ::1 has a small numeric value but is NOT an IPv4-mapped address; it must still be
419        // accepted. Regression guard for the to_ipv4_mapped() vs to_ipv4() distinction.
420        assert!(is_valid_cidr("::1/128"));
421    }
422
423    // --- Change 2: name length is measured in UTF-16 code units ---
424
425    #[test]
426    fn name_with_supplementary_chars_measured_in_utf16() {
427        // U+1D538 MATHEMATICAL DOUBLE-STRUCK CAPITAL A encodes as a surrogate pair in UTF-16
428        // (2 code units). 128 such chars = 256 UTF-16 units → valid; 129 = 258 units → invalid.
429        let base_char = '𝔸';
430        let mut request = base_request();
431
432        request.name = base_char.to_string().repeat(128);
433        assert_eq!(validate_request(&request), Ok(()));
434
435        request.name = base_char.to_string().repeat(129);
436        assert_eq!(
437            validate_request(&request),
438            Err(AccessRuleValidationError::InvalidName)
439        );
440    }
441
442    // --- Change 4: lease durations must be positive when provided ---
443
444    #[test]
445    fn negative_default_lease_duration_is_invalid() {
446        let mut request = base_request();
447        request.default_lease_duration_seconds = Some(-1);
448        assert_eq!(
449            validate_request(&request),
450            Err(AccessRuleValidationError::InvalidLeaseDuration)
451        );
452    }
453
454    #[test]
455    fn zero_default_lease_duration_is_invalid() {
456        let mut request = base_request();
457        request.default_lease_duration_seconds = Some(0);
458        assert_eq!(
459            validate_request(&request),
460            Err(AccessRuleValidationError::InvalidLeaseDuration)
461        );
462    }
463
464    #[test]
465    fn negative_max_lease_duration_is_invalid() {
466        let mut request = base_request();
467        request.max_lease_duration_seconds = Some(-1);
468        assert_eq!(
469            validate_request(&request),
470            Err(AccessRuleValidationError::InvalidLeaseDuration)
471        );
472    }
473
474    #[test]
475    fn positive_lease_durations_are_valid() {
476        let mut request = base_request();
477        request.default_lease_duration_seconds = Some(300);
478        request.max_lease_duration_seconds = Some(3600);
479        assert_eq!(validate_request(&request), Ok(()));
480    }
481
482    #[test]
483    fn none_lease_durations_are_valid() {
484        let mut request = base_request();
485        request.default_lease_duration_seconds = None;
486        request.max_lease_duration_seconds = None;
487        assert_eq!(validate_request(&request), Ok(()));
488    }
489}