Skip to main content

bitwarden_pam/access_rules/
validate.rs

1use std::net::IpAddr;
2
3use thiserror::Error;
4
5use super::{conditions::AccessCondition, models::AccessRuleAddEditRequest};
6use crate::MAX_REQUEST_ACCESS_WINDOW_SECONDS;
7
8/// Maximum length of an access rule's `name` field, matching the server's constraint.
9const MAX_NAME_LENGTH: usize = 256;
10const MAX_CONDITIONS: usize = 10;
11
12/// Errors from validating an [`AccessRuleAddEditRequest`] before it is sent to the server.
13#[derive(Debug, Error, PartialEq, Eq)]
14pub enum AccessRuleValidationError {
15    /// `name` was empty (after trimming whitespace) or exceeded 256 characters.
16    #[error("Name must be between 1 and {MAX_NAME_LENGTH} characters")]
17    InvalidName,
18    /// `allows_extensions` was `true` but `max_extension_duration_seconds` was missing or not
19    /// positive.
20    #[error("A positive max extension duration is required when extensions are allowed")]
21    MissingMaxExtensionDuration,
22    /// `default_lease_duration_seconds` or `max_lease_duration_seconds` was present but not
23    /// positive.
24    #[error("Lease durations must be positive")]
25    InvalidLeaseDuration,
26    /// `default_lease_duration_seconds` exceeded `max_lease_duration_seconds`.
27    #[error("The default lease duration cannot exceed the maximum lease duration")]
28    DefaultLeaseDurationExceedsMax,
29    /// A lease duration exceeded the global ceiling.
30    #[error("A lease duration cannot exceed {MAX_REQUEST_ACCESS_WINDOW_SECONDS} seconds")]
31    LeaseDurationExceedsGlobalMax,
32    /// More than 10 conditions were provided.
33    #[error("A rule may have at most {MAX_CONDITIONS} conditions")]
34    TooManyConditions,
35    /// An `ip_allowlist` condition contained an invalid CIDR range.
36    #[error("Invalid CIDR range: {0}")]
37    InvalidCidr(String),
38    /// An `ip_allowlist` condition was provided without any CIDR ranges.
39    #[error("An IP allowlist condition must contain at least one CIDR range")]
40    EmptyCidrList,
41}
42
43/// Validates a request before it is sent to the server. Unknown condition kinds are left to the
44/// server.
45pub fn validate_request(
46    request: &AccessRuleAddEditRequest,
47) -> Result<(), AccessRuleValidationError> {
48    let trimmed_name = request.name.trim();
49    if trimmed_name.is_empty() || trimmed_name.encode_utf16().count() > MAX_NAME_LENGTH {
50        return Err(AccessRuleValidationError::InvalidName);
51    }
52
53    if request.allows_extensions
54        && request
55            .max_extension_duration_seconds
56            .is_none_or(|seconds| seconds <= 0)
57    {
58        return Err(AccessRuleValidationError::MissingMaxExtensionDuration);
59    }
60
61    if request
62        .default_lease_duration_seconds
63        .is_some_and(|d| d <= 0)
64        || request.max_lease_duration_seconds.is_some_and(|m| m <= 0)
65    {
66        return Err(AccessRuleValidationError::InvalidLeaseDuration);
67    }
68
69    // An absent max is "no cap", so it never constrains the default.
70    if let (Some(default), Some(max)) = (
71        request.default_lease_duration_seconds,
72        request.max_lease_duration_seconds,
73    ) && default > max
74    {
75        return Err(AccessRuleValidationError::DefaultLeaseDurationExceedsMax);
76    }
77
78    // The lease paths never check an uncapped default or the extension length against the
79    // ceiling. Bounds each value, not the cumulative length of a repeatedly extended lease.
80    let ceiling = i64::from(MAX_REQUEST_ACCESS_WINDOW_SECONDS);
81    if [
82        request.default_lease_duration_seconds,
83        request.max_lease_duration_seconds,
84        request.max_extension_duration_seconds,
85    ]
86    .iter()
87    .any(|seconds| seconds.is_some_and(|s| i64::from(s) > ceiling))
88    {
89        return Err(AccessRuleValidationError::LeaseDurationExceedsGlobalMax);
90    }
91
92    if request.conditions.len() > MAX_CONDITIONS {
93        return Err(AccessRuleValidationError::TooManyConditions);
94    }
95
96    for condition in &request.conditions {
97        if let AccessCondition::IpAllowlist { cidrs } = condition {
98            if cidrs.is_empty() {
99                return Err(AccessRuleValidationError::EmptyCidrList);
100            }
101            for cidr in cidrs {
102                if !is_valid_cidr(cidr) {
103                    return Err(AccessRuleValidationError::InvalidCidr(cidr.clone()));
104                }
105            }
106        }
107    }
108
109    Ok(())
110}
111
112/// Returns `true` for a canonical CIDR range such as `10.0.0.0/8`, with no host bits set.
113/// Ambiguous forms (leading-zero or hex octets, IPv4-mapped IPv6) are rejected so client and
114/// server agree on which network a rule matches.
115#[bitwarden_ffi::wasm_export]
116pub fn is_valid_cidr(value: &str) -> bool {
117    let Some((addr, prefix)) = value.split_once('/') else {
118        return false;
119    };
120    // `u8::from_str` accepts a leading `+`, which is not a valid CIDR prefix.
121    if !prefix.bytes().all(|b| b.is_ascii_digit()) {
122        return false;
123    }
124    let Ok(prefix) = prefix.parse::<u8>() else {
125        return false;
126    };
127    match addr.parse::<IpAddr>() {
128        Ok(IpAddr::V4(ip)) => prefix <= 32 && no_host_bits(u32::from(ip).into(), prefix, 32),
129        Ok(IpAddr::V6(ip)) => {
130            // Reject IPv4-mapped addresses (::ffff:a.b.c.d). `to_ipv4()` would also match the
131            // IPv4-compatible range (::a.b.c.d) and wrongly reject ::/0 and ::1.
132            ip.to_ipv4_mapped().is_none() && prefix <= 128 && no_host_bits(ip.into(), prefix, 128)
133        }
134        Err(_) => false,
135    }
136}
137
138/// Returns true when the low `width - prefix` host bits of `addr` are all zero. Requires
139/// `prefix <= width`; the `host_bits == 0` check also avoids `u128::MAX >> 128`, which panics.
140fn no_host_bits(addr: u128, prefix: u8, width: u8) -> bool {
141    debug_assert!(prefix <= width);
142    let host_bits = width - prefix;
143    host_bits == 0 || addr & (u128::MAX >> (128 - u32::from(host_bits))) == 0
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn base_request() -> AccessRuleAddEditRequest {
151        AccessRuleAddEditRequest {
152            name: "My rule".to_string(),
153            description: None,
154            enabled: true,
155            conditions: Vec::new(),
156            single_active_lease: false,
157            default_lease_duration_seconds: None,
158            max_lease_duration_seconds: None,
159            allows_extensions: false,
160            max_extension_duration_seconds: None,
161            collections: Vec::new(),
162        }
163    }
164
165    #[test]
166    fn blank_name_is_invalid() {
167        let mut request = base_request();
168        request.name = "   ".to_string();
169        assert_eq!(
170            validate_request(&request),
171            Err(AccessRuleValidationError::InvalidName)
172        );
173    }
174
175    #[test]
176    fn name_over_256_chars_is_invalid() {
177        let mut request = base_request();
178        request.name = "a".repeat(257);
179        assert_eq!(
180            validate_request(&request),
181            Err(AccessRuleValidationError::InvalidName)
182        );
183    }
184
185    #[test]
186    fn name_at_256_chars_is_valid() {
187        let mut request = base_request();
188        request.name = "a".repeat(256);
189        assert_eq!(validate_request(&request), Ok(()));
190    }
191
192    #[test]
193    fn name_at_256_chars_with_surrounding_whitespace_is_valid() {
194        let mut request = base_request();
195        request.name = format!("  {}  ", "a".repeat(256));
196        assert_eq!(validate_request(&request), Ok(()));
197    }
198
199    #[test]
200    fn allows_extensions_without_max_duration_is_invalid() {
201        let mut request = base_request();
202        request.allows_extensions = true;
203        request.max_extension_duration_seconds = None;
204        assert_eq!(
205            validate_request(&request),
206            Err(AccessRuleValidationError::MissingMaxExtensionDuration)
207        );
208    }
209
210    #[test]
211    fn allows_extensions_with_zero_max_duration_is_invalid() {
212        let mut request = base_request();
213        request.allows_extensions = true;
214        request.max_extension_duration_seconds = Some(0);
215        assert_eq!(
216            validate_request(&request),
217            Err(AccessRuleValidationError::MissingMaxExtensionDuration)
218        );
219    }
220
221    #[test]
222    fn allows_extensions_with_positive_max_duration_is_valid() {
223        let mut request = base_request();
224        request.allows_extensions = true;
225        request.max_extension_duration_seconds = Some(60);
226        assert_eq!(validate_request(&request), Ok(()));
227    }
228
229    #[test]
230    fn more_than_ten_conditions_is_invalid() {
231        let mut request = base_request();
232        request.conditions = (0..11).map(|_| AccessCondition::HumanApproval).collect();
233        assert_eq!(
234            validate_request(&request),
235            Err(AccessRuleValidationError::TooManyConditions)
236        );
237    }
238
239    #[test]
240    fn exactly_ten_conditions_is_valid() {
241        let mut request = base_request();
242        request.conditions = (0..10).map(|_| AccessCondition::HumanApproval).collect();
243        assert_eq!(validate_request(&request), Ok(()));
244    }
245
246    #[test]
247    fn empty_cidr_list_is_invalid() {
248        let mut request = base_request();
249        request.conditions = vec![AccessCondition::IpAllowlist { cidrs: Vec::new() }];
250        assert_eq!(
251            validate_request(&request),
252            Err(AccessRuleValidationError::EmptyCidrList)
253        );
254    }
255
256    #[test]
257    fn valid_ipv4_cidr() {
258        assert!(is_valid_cidr("10.0.0.0/8"));
259    }
260
261    #[test]
262    fn valid_ipv6_cidr() {
263        assert!(is_valid_cidr("2001:db8::/32"));
264    }
265
266    #[test]
267    fn cidr_with_host_bits_set_is_invalid() {
268        assert!(!is_valid_cidr("10.0.0.1/8"));
269    }
270
271    #[test]
272    fn cidr_without_prefix_is_invalid() {
273        assert!(!is_valid_cidr("10.0.0.0"));
274    }
275
276    #[test]
277    fn garbage_cidr_is_invalid() {
278        assert!(!is_valid_cidr("not-a-cidr"));
279    }
280
281    #[test]
282    fn ip_allowlist_with_invalid_cidr_is_rejected() {
283        let mut request = base_request();
284        request.conditions = vec![AccessCondition::IpAllowlist {
285            cidrs: vec!["10.0.0.1/8".to_string()],
286        }];
287        assert_eq!(
288            validate_request(&request),
289            Err(AccessRuleValidationError::InvalidCidr(
290                "10.0.0.1/8".to_string()
291            ))
292        );
293    }
294
295    #[test]
296    fn unknown_condition_kind_is_skipped() {
297        let mut request = base_request();
298        request.conditions = vec![AccessCondition::Unknown(serde_json::json!({
299            "kind": "time_of_day",
300        }))];
301        assert_eq!(validate_request(&request), Ok(()));
302    }
303
304    #[test]
305    fn zero_zero_zero_zero_slash_zero_is_valid() {
306        assert!(is_valid_cidr("0.0.0.0/0"));
307    }
308
309    #[test]
310    fn ipv6_slash_zero_is_valid() {
311        assert!(is_valid_cidr("::/0"));
312    }
313
314    #[test]
315    fn ipv4_nonzero_host_with_slash_zero_is_invalid() {
316        assert!(!is_valid_cidr("10.0.0.0/0"));
317    }
318
319    #[test]
320    fn ipv4_full_prefix_is_valid() {
321        assert!(is_valid_cidr("10.0.0.1/32"));
322    }
323
324    #[test]
325    fn ipv6_full_prefix_is_valid() {
326        assert!(is_valid_cidr("2001:db8::1/128"));
327    }
328
329    #[test]
330    fn ipv4_prefix_out_of_range_is_invalid() {
331        assert!(!is_valid_cidr("10.0.0.0/33"));
332    }
333
334    #[test]
335    fn ipv6_prefix_out_of_range_is_invalid() {
336        assert!(!is_valid_cidr("2001:db8::/129"));
337    }
338
339    #[test]
340    fn empty_prefix_is_invalid() {
341        assert!(!is_valid_cidr("10.0.0.0/"));
342    }
343
344    #[test]
345    fn empty_address_is_invalid() {
346        assert!(!is_valid_cidr("/8"));
347    }
348
349    #[test]
350    fn double_slash_prefix_is_invalid() {
351        assert!(!is_valid_cidr("10.0.0.0/8/8"));
352    }
353
354    #[test]
355    fn leading_whitespace_is_invalid() {
356        assert!(!is_valid_cidr(" 10.0.0.0/8"));
357    }
358
359    #[test]
360    fn prefix_with_leading_whitespace_is_invalid() {
361        assert!(!is_valid_cidr("10.0.0.0/ 8"));
362    }
363
364    #[test]
365    fn ipv6_prefix_300_is_invalid() {
366        assert!(!is_valid_cidr("2001:db8::/300"));
367    }
368
369    #[test]
370    fn signed_positive_prefix_is_invalid() {
371        assert!(!is_valid_cidr("10.0.0.0/+8"));
372    }
373
374    #[test]
375    fn signed_negative_prefix_is_invalid() {
376        assert!(!is_valid_cidr("10.0.0.0/-8"));
377    }
378
379    #[test]
380    fn prefix_with_leading_zero_is_valid() {
381        // A leading zero in the prefix is unambiguous decimal, as .NET also reads it.
382        assert!(is_valid_cidr("10.0.0.0/08"));
383    }
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    #[test]
408    fn ipv4_mapped_ipv6_is_invalid() {
409        // The IPv4-mapped form of 10.0.0.0/8.
410        assert!(!is_valid_cidr("::ffff:10.0.0.0/104"));
411    }
412
413    #[test]
414    fn ipv6_loopback_is_not_treated_as_mapped() {
415        // `::1` is not IPv4-mapped, though `to_ipv4()` would treat it as IPv4-compatible.
416        assert!(is_valid_cidr("::1/128"));
417    }
418
419    #[test]
420    fn name_with_supplementary_chars_measured_in_utf16() {
421        // U+1D538 takes two UTF-16 code units, so 128 of them make 256 (valid) and 129 make 258.
422        let base_char = '𝔸';
423        let mut request = base_request();
424
425        request.name = base_char.to_string().repeat(128);
426        assert_eq!(validate_request(&request), Ok(()));
427
428        request.name = base_char.to_string().repeat(129);
429        assert_eq!(
430            validate_request(&request),
431            Err(AccessRuleValidationError::InvalidName)
432        );
433    }
434
435    #[test]
436    fn negative_default_lease_duration_is_invalid() {
437        let mut request = base_request();
438        request.default_lease_duration_seconds = Some(-1);
439        assert_eq!(
440            validate_request(&request),
441            Err(AccessRuleValidationError::InvalidLeaseDuration)
442        );
443    }
444
445    #[test]
446    fn zero_default_lease_duration_is_invalid() {
447        let mut request = base_request();
448        request.default_lease_duration_seconds = Some(0);
449        assert_eq!(
450            validate_request(&request),
451            Err(AccessRuleValidationError::InvalidLeaseDuration)
452        );
453    }
454
455    #[test]
456    fn negative_max_lease_duration_is_invalid() {
457        let mut request = base_request();
458        request.max_lease_duration_seconds = Some(-1);
459        assert_eq!(
460            validate_request(&request),
461            Err(AccessRuleValidationError::InvalidLeaseDuration)
462        );
463    }
464
465    #[test]
466    fn positive_lease_durations_are_valid() {
467        let mut request = base_request();
468        request.default_lease_duration_seconds = Some(300);
469        request.max_lease_duration_seconds = Some(3600);
470        assert_eq!(validate_request(&request), Ok(()));
471    }
472
473    #[test]
474    fn lease_durations_past_the_global_ceiling_are_invalid() {
475        let ceiling = i32::try_from(MAX_REQUEST_ACCESS_WINDOW_SECONDS).unwrap();
476        for (default, max) in [(None, Some(ceiling + 1)), (Some(ceiling + 1), None)] {
477            let mut request = base_request();
478            request.default_lease_duration_seconds = default;
479            request.max_lease_duration_seconds = max;
480
481            assert_eq!(
482                validate_request(&request),
483                Err(AccessRuleValidationError::LeaseDurationExceedsGlobalMax),
484                "default {default:?} max {max:?}"
485            );
486        }
487    }
488
489    #[test]
490    fn a_max_extension_past_the_global_ceiling_is_invalid() {
491        let mut request = base_request();
492        request.allows_extensions = true;
493        request.max_extension_duration_seconds =
494            Some(i32::try_from(MAX_REQUEST_ACCESS_WINDOW_SECONDS).unwrap() + 1);
495
496        assert_eq!(
497            validate_request(&request),
498            Err(AccessRuleValidationError::LeaseDurationExceedsGlobalMax)
499        );
500    }
501
502    #[test]
503    fn lease_durations_at_the_global_ceiling_are_valid() {
504        let ceiling = i32::try_from(MAX_REQUEST_ACCESS_WINDOW_SECONDS).unwrap();
505        let mut request = base_request();
506        request.default_lease_duration_seconds = None;
507        request.max_lease_duration_seconds = Some(ceiling);
508        assert_eq!(validate_request(&request), Ok(()));
509    }
510
511    #[test]
512    fn none_lease_durations_are_valid() {
513        let mut request = base_request();
514        request.default_lease_duration_seconds = None;
515        request.max_lease_duration_seconds = None;
516        assert_eq!(validate_request(&request), Ok(()));
517    }
518
519    #[test]
520    fn default_lease_duration_above_max_is_invalid() {
521        let mut request = base_request();
522        request.default_lease_duration_seconds = Some(3600);
523        request.max_lease_duration_seconds = Some(900);
524        assert_eq!(
525            validate_request(&request),
526            Err(AccessRuleValidationError::DefaultLeaseDurationExceedsMax)
527        );
528    }
529
530    #[test]
531    fn default_lease_duration_equal_to_max_is_valid() {
532        let mut request = base_request();
533        request.default_lease_duration_seconds = Some(900);
534        request.max_lease_duration_seconds = Some(900);
535        assert_eq!(validate_request(&request), Ok(()));
536    }
537
538    #[test]
539    fn default_lease_duration_without_a_max_is_valid() {
540        let mut request = base_request();
541        request.default_lease_duration_seconds = Some(7 * 86_400);
542        request.max_lease_duration_seconds = None;
543        assert_eq!(validate_request(&request), Ok(()));
544    }
545}