bitwarden_pam/access_rules/
validate.rs1use std::net::IpAddr;
2
3use thiserror::Error;
4
5use super::{conditions::AccessCondition, models::AccessRuleAddEditRequest};
6use crate::MAX_REQUEST_ACCESS_WINDOW_SECONDS;
7
8const MAX_NAME_LENGTH: usize = 256;
10const MAX_CONDITIONS: usize = 10;
11
12#[derive(Debug, Error, PartialEq, Eq)]
14pub enum AccessRuleValidationError {
15 #[error("Name must be between 1 and {MAX_NAME_LENGTH} characters")]
17 InvalidName,
18 #[error("A positive max extension duration is required when extensions are allowed")]
21 MissingMaxExtensionDuration,
22 #[error("Lease durations must be positive")]
25 InvalidLeaseDuration,
26 #[error("The default lease duration cannot exceed the maximum lease duration")]
28 DefaultLeaseDurationExceedsMax,
29 #[error("A lease duration cannot exceed {MAX_REQUEST_ACCESS_WINDOW_SECONDS} seconds")]
31 LeaseDurationExceedsGlobalMax,
32 #[error("A rule may have at most {MAX_CONDITIONS} conditions")]
34 TooManyConditions,
35 #[error("Invalid CIDR range: {0}")]
37 InvalidCidr(String),
38 #[error("An IP allowlist condition must contain at least one CIDR range")]
40 EmptyCidrList,
41}
42
43pub 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 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 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#[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 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 ip.to_ipv4_mapped().is_none() && prefix <= 128 && no_host_bits(ip.into(), prefix, 128)
133 }
134 Err(_) => false,
135 }
136}
137
138fn 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 assert!(is_valid_cidr("10.0.0.0/08"));
383 }
384
385 #[test]
386 fn leading_zero_octet_is_invalid() {
387 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 assert!(!is_valid_cidr("::ffff:10.0.0.0/104"));
411 }
412
413 #[test]
414 fn ipv6_loopback_is_not_treated_as_mapped() {
415 assert!(is_valid_cidr("::1/128"));
417 }
418
419 #[test]
420 fn name_with_supplementary_chars_measured_in_utf16() {
421 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}