Skip to main content

bao_browser/
permission.rs

1// @trace REQ-LIB-004  REQ-LIB-003 REQ-SEC-003: Permission sandbox with zero-overhead none mode
2// BCE-20260627-001 根治: path canonicalization 防止 traversal/symlink/case bypass
3
4/// Normalize a path string for permission checking: reject traversals and
5/// URL-encoded escapes before prefix matching. We do NOT touch the filesystem
6/// (this runs in the JS thread, FS access would block), but we do refuse
7/// suspicious sequences and decode `%2e` / `%2f` so encoded variants cannot
8/// bypass the starts_with check.
9fn normalize_path_for_check(path: &str) -> Option<String> {
10    // Reject NUL and control bytes outright.
11    if path.bytes().any(|b| b == 0 || b < 0x20) {
12        return None;
13    }
14    // URL-decode %2e (.), %2f (/), %5c (\) and uppercase variants so attackers
15    // cannot smuggle traversal characters through percent-encoding.
16    let lower = path.to_ascii_lowercase();
17    let decoded = lower
18        .replace("%2e", ".")
19        .replace("%2f", "/")
20        .replace("%5c", "/");
21    // Normalize backslash to forward slash (Windows path compatibility).
22    let decoded = decoded.replace('\\', "/");
23    // Reject any `..` segment (parent directory traversal) and `.` segment
24    // (current directory - not dangerous but suspicious in untrusted input).
25    for seg in decoded.split('/') {
26        if seg == ".." || seg == "." {
27            return None;
28        }
29    }
30    Some(decoded)
31}
32
33/// Normalize a hostname for permission checking: lowercase and reject any
34/// embedded port / userinfo / trailing-dot tricks.
35fn normalize_host_for_check(host: &str) -> Option<String> {
36    // Strip trailing dot (RFC 1034: "example.com." == "example.com").
37    let trimmed = host.trim_end_matches('.').to_ascii_lowercase();
38    // Reject anything that contains '@', '/', ':', or whitespace — those
39    // indicate userinfo, path, or port injection attempts and must not appear
40    // in a bare host comparison.
41    if trimmed
42        .chars()
43        .any(|c| c == '@' || c == '/' || c == ':' || c.is_whitespace())
44    {
45        return None;
46    }
47    Some(trimmed)
48}
49
50#[derive(Debug, Clone, Default)]
51pub struct Permission {
52    pub read: Option<Vec<String>>,
53    pub write: Option<Vec<String>>,
54    pub net: Option<Vec<String>>,
55    pub env: Option<bool>,
56    pub run: Option<bool>,
57    pub sys: Option<bool>,
58}
59
60impl Permission {
61    pub fn is_read_allowed(&self, path: &str) -> bool {
62        match &self.read {
63            None => true,
64            Some(allowed) => match normalize_path_for_check(path) {
65                None => false,
66                Some(normalized) => allowed
67                    .iter()
68                    .any(|prefix| normalized.starts_with(prefix.as_str())),
69            },
70        }
71    }
72
73    pub fn is_write_allowed(&self, path: &str) -> bool {
74        match &self.write {
75            None => true,
76            Some(allowed) => match normalize_path_for_check(path) {
77                None => false,
78                Some(normalized) => allowed
79                    .iter()
80                    .any(|prefix| normalized.starts_with(prefix.as_str())),
81            },
82        }
83    }
84
85    pub fn is_net_allowed(&self, host: &str) -> bool {
86        match &self.net {
87            None => true,
88            Some(allowed) => match normalize_host_for_check(host) {
89                None => false,
90                Some(normalized) => allowed.iter().any(|domain| {
91                    let d = domain.to_ascii_lowercase();
92                    normalized == d || normalized.ends_with(&format!(".{d}"))
93                }),
94            },
95        }
96    }
97
98    pub fn is_env_allowed(&self) -> bool {
99        self.env.unwrap_or(true)
100    }
101
102    pub fn is_run_allowed(&self) -> bool {
103        self.run.unwrap_or(true)
104    }
105
106    pub fn is_sys_allowed(&self) -> bool {
107        self.sys.unwrap_or(true)
108    }
109}
110
111#[derive(Debug, Clone, Default)]
112pub struct PermissionGuard {
113    inner: Option<Permission>,
114}
115
116impl PermissionGuard {
117    pub fn none() -> Self {
118        PermissionGuard { inner: None }
119    }
120
121    pub fn new(perm: Permission) -> Self {
122        PermissionGuard { inner: Some(perm) }
123    }
124
125    pub fn is_restricted(&self) -> bool {
126        self.inner.is_some()
127    }
128
129    /// Shared decision core: an unrestricted guard (no `Permission`) always
130    /// passes; a restricted guard passes only when `allowed` holds, otherwise
131    /// denies with the given category/resource.
132    fn check_inner(
133        inner: &Option<Permission>,
134        allowed: bool,
135        category: &str,
136        resource: &str,
137    ) -> Result<(), PermissionDenied> {
138        match inner {
139            None => Ok(()),
140            Some(_) if allowed => Ok(()),
141            Some(_) => Err(PermissionDenied {
142                category: category.into(),
143                resource: resource.into(),
144            }),
145        }
146    }
147
148    pub fn check_read(&self, path: &str) -> Result<(), PermissionDenied> {
149        let allowed = self
150            .inner
151            .as_ref()
152            .map_or(true, |perm| perm.is_read_allowed(path));
153        Self::check_inner(&self.inner, allowed, "read", path)
154    }
155
156    pub fn check_write(&self, path: &str) -> Result<(), PermissionDenied> {
157        let allowed = self
158            .inner
159            .as_ref()
160            .map_or(true, |perm| perm.is_write_allowed(path));
161        Self::check_inner(&self.inner, allowed, "write", path)
162    }
163
164    pub fn check_net(&self, host: &str) -> Result<(), PermissionDenied> {
165        let allowed = self
166            .inner
167            .as_ref()
168            .map_or(true, |perm| perm.is_net_allowed(host));
169        Self::check_inner(&self.inner, allowed, "net", host)
170    }
171
172    /// Shared core for zero-resource flag checks (env/run): an unrestricted
173    /// guard always passes; a restricted guard passes only when `predicate`
174    /// holds on the inner `Permission`, otherwise denies with the given
175    /// category (resource is always "*").
176    fn check_flag(
177        &self,
178        predicate: fn(&Permission) -> bool,
179        category: &str,
180    ) -> Result<(), PermissionDenied> {
181        let allowed = self.inner.as_ref().map_or(true, predicate);
182        Self::check_inner(&self.inner, allowed, category, "*")
183    }
184
185    pub fn check_env(&self) -> Result<(), PermissionDenied> {
186        self.check_flag(Permission::is_env_allowed, "env")
187    }
188
189    pub fn check_run(&self) -> Result<(), PermissionDenied> {
190        self.check_flag(Permission::is_run_allowed, "run")
191    }
192}
193
194#[derive(Debug, Clone)]
195pub struct PermissionDenied {
196    pub category: String,
197    pub resource: String,
198}
199
200impl std::fmt::Display for PermissionDenied {
201    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
202        write!(
203            f,
204            "Permission denied: {} on {}",
205            self.category, self.resource
206        )
207    }
208}
209
210impl std::error::Error for PermissionDenied {}
211
212#[cfg(test)]
213mod tests {
214    // @trace REQ-LIB-004 [req:REQ-LIB-003,REQ-LIB-004] [level:unit]
215    use super::*;
216
217    #[test]
218    fn permission_default_allows_all() {
219        let perm = Permission::default();
220        assert!(perm.is_read_allowed("/any/path"));
221        assert!(perm.is_write_allowed("/any/path"));
222        assert!(perm.is_net_allowed("any.host.com"));
223        assert!(perm.is_env_allowed());
224        assert!(perm.is_run_allowed());
225    }
226
227    #[test]
228    fn read_prefix_matching() {
229        let perm = Permission {
230            read: Some(vec!["/home".into(), "/tmp/bao".into()]),
231            ..Default::default()
232        };
233        assert!(perm.is_read_allowed("/home"));
234        assert!(perm.is_read_allowed("/home/user/file.txt"));
235        assert!(perm.is_read_allowed("/tmp/bao"));
236        assert!(perm.is_read_allowed("/tmp/bao/cache/data.bin"));
237        assert!(!perm.is_read_allowed("/etc/passwd"));
238        assert!(!perm.is_read_allowed("/tmp/other"));
239    }
240
241    #[test]
242    fn write_prefix_matching() {
243        let perm = Permission {
244            write: Some(vec!["/var/log/bao".into(), "/tmp".into()]),
245            ..Default::default()
246        };
247        assert!(perm.is_write_allowed("/var/log/bao"));
248        assert!(perm.is_write_allowed("/var/log/bao/app.log"));
249        assert!(perm.is_write_allowed("/tmp"));
250        assert!(perm.is_write_allowed("/tmp/session.dat"));
251        assert!(!perm.is_write_allowed("/var/log/other"));
252        assert!(!perm.is_write_allowed("/usr/bin"));
253    }
254
255    #[test]
256    fn net_exact_and_subdomain() {
257        let perm = Permission {
258            net: Some(vec!["example.com".into()]),
259            ..Default::default()
260        };
261        assert!(perm.is_net_allowed("example.com"));
262        assert!(perm.is_net_allowed("sub.example.com"));
263        assert!(perm.is_net_allowed("deep.sub.example.com"));
264    }
265
266    #[test]
267    fn net_partial_mismatch() {
268        let perm = Permission {
269            net: Some(vec!["example.com".into()]),
270            ..Default::default()
271        };
272        assert!(!perm.is_net_allowed("notexample.com"));
273        assert!(!perm.is_net_allowed("xnotexample.com"));
274        assert!(!perm.is_net_allowed("other.com"));
275    }
276
277    #[test]
278    fn env_false_blocks() {
279        let perm = Permission {
280            env: Some(false),
281            ..Default::default()
282        };
283        assert!(!perm.is_env_allowed());
284    }
285
286    #[test]
287    fn run_false_blocks() {
288        let perm = Permission {
289            run: Some(false),
290            ..Default::default()
291        };
292        assert!(!perm.is_run_allowed());
293    }
294
295    #[test]
296    fn guard_none_allows_all() {
297        let guard = PermissionGuard::none();
298        assert!(!guard.is_restricted());
299        assert!(guard.check_read("/secret").is_ok());
300        assert!(guard.check_write("/secret").is_ok());
301        assert!(guard.check_net("evil.com").is_ok());
302        assert!(guard.check_env().is_ok());
303        assert!(guard.check_run().is_ok());
304    }
305
306    #[test]
307    fn guard_new_is_restricted() {
308        let guard = PermissionGuard::new(Permission::default());
309        assert!(guard.is_restricted());
310    }
311
312    #[test]
313    fn guard_check_read_denied_has_correct_category_resource() {
314        let guard = PermissionGuard::new(Permission {
315            read: Some(vec!["/allowed".into()]),
316            ..Default::default()
317        });
318        let err = guard.check_read("/forbidden").unwrap_err();
319        assert_eq!(err.category, "read");
320        assert_eq!(err.resource, "/forbidden");
321    }
322
323    #[test]
324    fn guard_check_net_denied_message() {
325        let guard = PermissionGuard::new(Permission {
326            net: Some(vec!["safe.com".into()]),
327            ..Default::default()
328        });
329        let err = guard.check_net("unsafe.com").unwrap_err();
330        assert_eq!(format!("{err}"), "Permission denied: net on unsafe.com");
331    }
332
333    #[test]
334    fn permission_denied_display_format() {
335        let err = PermissionDenied {
336            category: "env".into(),
337            resource: "*".into(),
338        };
339        assert_eq!(format!("{err}"), "Permission denied: env on *");
340    }
341
342    #[test]
343    fn permission_clone_works() {
344        let perm = Permission {
345            read: Some(vec!["/data".into()]),
346            write: None,
347            net: Some(vec!["api.com".into()]),
348            env: Some(false),
349            run: Some(true),
350            ..Default::default()
351        };
352        let cloned = perm.clone();
353        assert!(cloned.is_read_allowed("/data/file"));
354        assert!(cloned.is_write_allowed("/anything"));
355        assert!(cloned.is_net_allowed("api.com"));
356        assert!(!cloned.is_env_allowed());
357        assert!(cloned.is_run_allowed());
358    }
359
360    #[test]
361    fn permission_guard_clone_works() {
362        let guard = PermissionGuard::new(Permission {
363            read: Some(vec!["/app".into()]),
364            ..Default::default()
365        });
366        let cloned = guard.clone();
367        assert!(cloned.is_restricted());
368        assert!(cloned.check_read("/app/config").is_ok());
369        assert!(cloned.check_read("/other").is_err());
370    }
371
372    #[test]
373    fn empty_allowed_list_denies_all() {
374        let perm = Permission {
375            read: Some(vec![]),
376            write: Some(vec![]),
377            net: Some(vec![]),
378            env: None,
379            run: None,
380            ..Default::default()
381        };
382        assert!(!perm.is_read_allowed("/any"));
383        assert!(!perm.is_write_allowed("/any"));
384        assert!(!perm.is_net_allowed("any.com"));
385    }
386
387    // ─── Permission extended edge case tests ─────────────────────
388    // @trace REQ-CDP-008 [req:REQ-CDP-008] [level:unit]
389
390    #[test]
391    fn net_exact_match_no_subdomain_false() {
392        // "example.com" should NOT match just "example" (no .com)
393        let perm = Permission {
394            net: Some(vec!["example.com".into()]),
395            ..Default::default()
396        };
397        assert!(!perm.is_net_allowed("example"));
398        assert!(!perm.is_net_allowed("com"));
399    }
400
401    #[test]
402    fn net_multiple_domains_one_match() {
403        let perm = Permission {
404            net: Some(vec!["safe.com".into(), "trusted.io".into()]),
405            ..Default::default()
406        };
407        assert!(perm.is_net_allowed("safe.com"));
408        assert!(perm.is_net_allowed("sub.safe.com"));
409        assert!(perm.is_net_allowed("trusted.io"));
410        assert!(!perm.is_net_allowed("unsafe.com"));
411    }
412
413    #[test]
414    fn read_path_traversal_not_allowed() {
415        // BCE-20260627-001 regression: /allowed/../secret must be DENIED.
416        // Previous impl documented this as "known design choice (prefix match)"
417        // but that was a security hole — fixed by normalize_path_for_check.
418        let perm = Permission {
419            read: Some(vec!["/allowed".into()]),
420            ..Default::default()
421        };
422        assert!(
423            !perm.is_read_allowed("/allowed/../secret"),
424            "path traversal must be rejected"
425        );
426    }
427
428    #[test]
429    fn read_path_traversal_url_encoded_rejected() {
430        // BCE-20260627-001 regression: %2e%2e must decode to .. and be rejected.
431        let perm = Permission {
432            read: Some(vec!["/allowed".into()]),
433            ..Default::default()
434        };
435        assert!(
436            !perm.is_read_allowed("/allowed/%2e%2e/secret"),
437            "URL-encoded traversal must be rejected"
438        );
439        assert!(
440            !perm.is_read_allowed("/allowed/%2E%2Fsecret"),
441            "uppercase URL-encoded traversal must be rejected"
442        );
443        assert!(
444            !perm.is_read_allowed("/allowed/%2f..%2fsecret"),
445            "mixed URL-encoded traversal must be rejected"
446        );
447    }
448
449    #[test]
450    fn read_path_traversal_backslash_rejected() {
451        // BCE-20260627-001 regression: %5c decodes to backslash; mixed-separator
452        // traversal must be rejected.
453        let perm = Permission {
454            read: Some(vec!["/allowed".into()]),
455            ..Default::default()
456        };
457        assert!(
458            !perm.is_read_allowed("/allowed/..\\secret"),
459            "backslash traversal must be rejected"
460        );
461    }
462
463    #[test]
464    fn read_path_control_byte_rejected() {
465        // BCE-20260627-001 regression: NUL and control bytes rejected outright.
466        let perm = Permission {
467            read: Some(vec!["/allowed".into()]),
468            ..Default::default()
469        };
470        assert!(
471            !perm.is_read_allowed("/allowed/\0secret"),
472            "NUL injection must be rejected"
473        );
474        assert!(
475            !perm.is_read_allowed("/allowed/\tsecret"),
476            "control-byte injection must be rejected"
477        );
478    }
479
480    #[test]
481    fn write_path_traversal_rejected() {
482        // BCE-20260627-001 regression on write path.
483        let perm = Permission {
484            write: Some(vec!["/tmp/bao".into()]),
485            ..Default::default()
486        };
487        assert!(
488            !perm.is_write_allowed("/tmp/bao/../../etc/passwd"),
489            "write path traversal must be rejected"
490        );
491        assert!(
492            !perm.is_write_allowed("/tmp/bao/%2e%2e/%2e%2e/etc/shadow"),
493            "write URL-encoded traversal must be rejected"
494        );
495    }
496
497    #[test]
498    fn net_subdomain_confusion_rejected() {
499        // BCE-20260627-001 regression: safe.com allowed does NOT mean
500        // safe.com.evil.com is allowed. The old impl would have matched
501        // because safe.com.evil.com starts with safe.com.
502        let perm = Permission {
503            net: Some(vec!["safe.com".into()]),
504            ..Default::default()
505        };
506        assert!(
507            !perm.is_net_allowed("safe.com.evil.com"),
508            "subdomain suffix confusion must be rejected"
509        );
510        assert!(
511            !perm.is_net_allowed("notsafe.com"),
512            "prefix-only match must be rejected"
513        );
514        assert!(perm.is_net_allowed("safe.com"), "exact match must pass");
515        assert!(
516            perm.is_net_allowed("sub.safe.com"),
517            "subdomain match must pass"
518        );
519    }
520
521    #[test]
522    fn net_port_userinfo_rejected() {
523        // BCE-20260627-001 regression: ports/userinfo/path chars rejected.
524        let perm = Permission {
525            net: Some(vec!["safe.com".into()]),
526            ..Default::default()
527        };
528        assert!(
529            !perm.is_net_allowed("safe.com:8080"),
530            "port injection must be rejected"
531        );
532        assert!(
533            !perm.is_net_allowed("user@safe.com"),
534            "userinfo injection must be rejected"
535        );
536        assert!(
537            !perm.is_net_allowed("safe.com/path"),
538            "path injection must be rejected"
539        );
540    }
541
542    #[test]
543    fn net_trailing_dot_normalized() {
544        // BCE-20260627-001 regression: RFC 1034 trailing dot normalized.
545        let perm = Permission {
546            net: Some(vec!["safe.com".into()]),
547            ..Default::default()
548        };
549        assert!(
550            perm.is_net_allowed("safe.com."),
551            "trailing dot must be normalized to bare host"
552        );
553        assert!(
554            perm.is_net_allowed("sub.safe.com."),
555            "trailing dot subdomain must be normalized"
556        );
557    }
558
559    #[test]
560    fn net_case_insensitive() {
561        // BCE-20260627-001 regression: case-insensitive host comparison.
562        let perm = Permission {
563            net: Some(vec!["safe.com".into()]),
564            ..Default::default()
565        };
566        assert!(perm.is_net_allowed("SAFE.com"), "uppercase host must match");
567        assert!(
568            perm.is_net_allowed("sub.SAFE.com"),
569            "uppercase subdomain must match"
570        );
571    }
572
573    #[test]
574    fn env_true_allows() {
575        let perm = Permission {
576            env: Some(true),
577            ..Default::default()
578        };
579        assert!(perm.is_env_allowed());
580    }
581
582    #[test]
583    fn run_true_allows() {
584        let perm = Permission {
585            run: Some(true),
586            ..Default::default()
587        };
588        assert!(perm.is_run_allowed());
589    }
590
591    #[test]
592    fn guard_check_write_denied_category() {
593        let guard = PermissionGuard::new(Permission {
594            write: Some(vec!["/tmp".into()]),
595            ..Default::default()
596        });
597        let err = guard.check_write("/etc/passwd").unwrap_err();
598        assert_eq!(err.category, "write");
599        assert_eq!(err.resource, "/etc/passwd");
600    }
601
602    #[test]
603    fn guard_check_env_denied_category() {
604        let guard = PermissionGuard::new(Permission {
605            env: Some(false),
606            ..Default::default()
607        });
608        let err = guard.check_env().unwrap_err();
609        assert_eq!(err.category, "env");
610        assert_eq!(err.resource, "*");
611    }
612
613    #[test]
614    fn guard_check_run_denied_category() {
615        let guard = PermissionGuard::new(Permission {
616            run: Some(false),
617            ..Default::default()
618        });
619        let err = guard.check_run().unwrap_err();
620        assert_eq!(err.category, "run");
621        assert_eq!(err.resource, "*");
622    }
623
624    #[test]
625    fn sys_default_allows() {
626        let perm = Permission::default();
627        assert!(perm.is_sys_allowed());
628    }
629
630    #[test]
631    fn sys_explicit_true_allows() {
632        let perm = Permission {
633            sys: Some(true),
634            ..Default::default()
635        };
636        assert!(perm.is_sys_allowed());
637    }
638
639    #[test]
640    fn sys_explicit_false_denies() {
641        let perm = Permission {
642            sys: Some(false),
643            ..Default::default()
644        };
645        assert!(!perm.is_sys_allowed());
646    }
647}