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    pub fn check_read(&self, path: &str) -> Result<(), PermissionDenied> {
130        match &self.inner {
131            None => Ok(()),
132            Some(perm) => {
133                if perm.is_read_allowed(path) {
134                    Ok(())
135                } else {
136                    Err(PermissionDenied {
137                        category: "read".into(),
138                        resource: path.into(),
139                    })
140                }
141            }
142        }
143    }
144
145    pub fn check_write(&self, path: &str) -> Result<(), PermissionDenied> {
146        match &self.inner {
147            None => Ok(()),
148            Some(perm) => {
149                if perm.is_write_allowed(path) {
150                    Ok(())
151                } else {
152                    Err(PermissionDenied {
153                        category: "write".into(),
154                        resource: path.into(),
155                    })
156                }
157            }
158        }
159    }
160
161    pub fn check_net(&self, host: &str) -> Result<(), PermissionDenied> {
162        match &self.inner {
163            None => Ok(()),
164            Some(perm) => {
165                if perm.is_net_allowed(host) {
166                    Ok(())
167                } else {
168                    Err(PermissionDenied {
169                        category: "net".into(),
170                        resource: host.into(),
171                    })
172                }
173            }
174        }
175    }
176
177    pub fn check_env(&self) -> Result<(), PermissionDenied> {
178        match &self.inner {
179            None => Ok(()),
180            Some(perm) => {
181                if perm.is_env_allowed() {
182                    Ok(())
183                } else {
184                    Err(PermissionDenied {
185                        category: "env".into(),
186                        resource: "*".into(),
187                    })
188                }
189            }
190        }
191    }
192
193    pub fn check_run(&self) -> Result<(), PermissionDenied> {
194        match &self.inner {
195            None => Ok(()),
196            Some(perm) => {
197                if perm.is_run_allowed() {
198                    Ok(())
199                } else {
200                    Err(PermissionDenied {
201                        category: "run".into(),
202                        resource: "*".into(),
203                    })
204                }
205            }
206        }
207    }
208}
209
210#[derive(Debug, Clone)]
211pub struct PermissionDenied {
212    pub category: String,
213    pub resource: String,
214}
215
216impl std::fmt::Display for PermissionDenied {
217    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
218        write!(
219            f,
220            "Permission denied: {} on {}",
221            self.category, self.resource
222        )
223    }
224}
225
226impl std::error::Error for PermissionDenied {}
227
228#[cfg(test)]
229mod tests {
230    // @trace REQ-LIB-004 [req:REQ-LIB-003,REQ-LIB-004] [level:unit]
231    use super::*;
232
233    #[test]
234    fn permission_default_allows_all() {
235        let perm = Permission::default();
236        assert!(perm.is_read_allowed("/any/path"));
237        assert!(perm.is_write_allowed("/any/path"));
238        assert!(perm.is_net_allowed("any.host.com"));
239        assert!(perm.is_env_allowed());
240        assert!(perm.is_run_allowed());
241    }
242
243    #[test]
244    fn read_prefix_matching() {
245        let perm = Permission {
246            read: Some(vec!["/home".into(), "/tmp/bao".into()]),
247            ..Default::default()
248        };
249        assert!(perm.is_read_allowed("/home"));
250        assert!(perm.is_read_allowed("/home/user/file.txt"));
251        assert!(perm.is_read_allowed("/tmp/bao"));
252        assert!(perm.is_read_allowed("/tmp/bao/cache/data.bin"));
253        assert!(!perm.is_read_allowed("/etc/passwd"));
254        assert!(!perm.is_read_allowed("/tmp/other"));
255    }
256
257    #[test]
258    fn write_prefix_matching() {
259        let perm = Permission {
260            write: Some(vec!["/var/log/bao".into(), "/tmp".into()]),
261            ..Default::default()
262        };
263        assert!(perm.is_write_allowed("/var/log/bao"));
264        assert!(perm.is_write_allowed("/var/log/bao/app.log"));
265        assert!(perm.is_write_allowed("/tmp"));
266        assert!(perm.is_write_allowed("/tmp/session.dat"));
267        assert!(!perm.is_write_allowed("/var/log/other"));
268        assert!(!perm.is_write_allowed("/usr/bin"));
269    }
270
271    #[test]
272    fn net_exact_and_subdomain() {
273        let perm = Permission {
274            net: Some(vec!["example.com".into()]),
275            ..Default::default()
276        };
277        assert!(perm.is_net_allowed("example.com"));
278        assert!(perm.is_net_allowed("sub.example.com"));
279        assert!(perm.is_net_allowed("deep.sub.example.com"));
280    }
281
282    #[test]
283    fn net_partial_mismatch() {
284        let perm = Permission {
285            net: Some(vec!["example.com".into()]),
286            ..Default::default()
287        };
288        assert!(!perm.is_net_allowed("notexample.com"));
289        assert!(!perm.is_net_allowed("xnotexample.com"));
290        assert!(!perm.is_net_allowed("other.com"));
291    }
292
293    #[test]
294    fn env_false_blocks() {
295        let perm = Permission {
296            env: Some(false),
297            ..Default::default()
298        };
299        assert!(!perm.is_env_allowed());
300    }
301
302    #[test]
303    fn run_false_blocks() {
304        let perm = Permission {
305            run: Some(false),
306            ..Default::default()
307        };
308        assert!(!perm.is_run_allowed());
309    }
310
311    #[test]
312    fn guard_none_allows_all() {
313        let guard = PermissionGuard::none();
314        assert!(!guard.is_restricted());
315        assert!(guard.check_read("/secret").is_ok());
316        assert!(guard.check_write("/secret").is_ok());
317        assert!(guard.check_net("evil.com").is_ok());
318        assert!(guard.check_env().is_ok());
319        assert!(guard.check_run().is_ok());
320    }
321
322    #[test]
323    fn guard_new_is_restricted() {
324        let guard = PermissionGuard::new(Permission::default());
325        assert!(guard.is_restricted());
326    }
327
328    #[test]
329    fn guard_check_read_denied_has_correct_category_resource() {
330        let guard = PermissionGuard::new(Permission {
331            read: Some(vec!["/allowed".into()]),
332            ..Default::default()
333        });
334        let err = guard.check_read("/forbidden").unwrap_err();
335        assert_eq!(err.category, "read");
336        assert_eq!(err.resource, "/forbidden");
337    }
338
339    #[test]
340    fn guard_check_net_denied_message() {
341        let guard = PermissionGuard::new(Permission {
342            net: Some(vec!["safe.com".into()]),
343            ..Default::default()
344        });
345        let err = guard.check_net("unsafe.com").unwrap_err();
346        assert_eq!(format!("{err}"), "Permission denied: net on unsafe.com");
347    }
348
349    #[test]
350    fn permission_denied_display_format() {
351        let err = PermissionDenied {
352            category: "env".into(),
353            resource: "*".into(),
354        };
355        assert_eq!(format!("{err}"), "Permission denied: env on *");
356    }
357
358    #[test]
359    fn permission_clone_works() {
360        let perm = Permission {
361            read: Some(vec!["/data".into()]),
362            write: None,
363            net: Some(vec!["api.com".into()]),
364            env: Some(false),
365            run: Some(true),
366            ..Default::default()
367        };
368        let cloned = perm.clone();
369        assert!(cloned.is_read_allowed("/data/file"));
370        assert!(cloned.is_write_allowed("/anything"));
371        assert!(cloned.is_net_allowed("api.com"));
372        assert!(!cloned.is_env_allowed());
373        assert!(cloned.is_run_allowed());
374    }
375
376    #[test]
377    fn permission_guard_clone_works() {
378        let guard = PermissionGuard::new(Permission {
379            read: Some(vec!["/app".into()]),
380            ..Default::default()
381        });
382        let cloned = guard.clone();
383        assert!(cloned.is_restricted());
384        assert!(cloned.check_read("/app/config").is_ok());
385        assert!(cloned.check_read("/other").is_err());
386    }
387
388    #[test]
389    fn empty_allowed_list_denies_all() {
390        let perm = Permission {
391            read: Some(vec![]),
392            write: Some(vec![]),
393            net: Some(vec![]),
394            env: None,
395            run: None,
396            ..Default::default()
397        };
398        assert!(!perm.is_read_allowed("/any"));
399        assert!(!perm.is_write_allowed("/any"));
400        assert!(!perm.is_net_allowed("any.com"));
401    }
402
403    // ─── Permission extended edge case tests ─────────────────────
404    // @trace REQ-CDP-008 [req:REQ-CDP-008] [level:unit]
405
406    #[test]
407    fn net_exact_match_no_subdomain_false() {
408        // "example.com" should NOT match just "example" (no .com)
409        let perm = Permission {
410            net: Some(vec!["example.com".into()]),
411            ..Default::default()
412        };
413        assert!(!perm.is_net_allowed("example"));
414        assert!(!perm.is_net_allowed("com"));
415    }
416
417    #[test]
418    fn net_multiple_domains_one_match() {
419        let perm = Permission {
420            net: Some(vec!["safe.com".into(), "trusted.io".into()]),
421            ..Default::default()
422        };
423        assert!(perm.is_net_allowed("safe.com"));
424        assert!(perm.is_net_allowed("sub.safe.com"));
425        assert!(perm.is_net_allowed("trusted.io"));
426        assert!(!perm.is_net_allowed("unsafe.com"));
427    }
428
429    #[test]
430    fn read_path_traversal_not_allowed() {
431        // BCE-20260627-001 regression: /allowed/../secret must be DENIED.
432        // Previous impl documented this as "known design choice (prefix match)"
433        // but that was a security hole — fixed by normalize_path_for_check.
434        let perm = Permission {
435            read: Some(vec!["/allowed".into()]),
436            ..Default::default()
437        };
438        assert!(
439            !perm.is_read_allowed("/allowed/../secret"),
440            "path traversal must be rejected"
441        );
442    }
443
444    #[test]
445    fn read_path_traversal_url_encoded_rejected() {
446        // BCE-20260627-001 regression: %2e%2e must decode to .. and be rejected.
447        let perm = Permission {
448            read: Some(vec!["/allowed".into()]),
449            ..Default::default()
450        };
451        assert!(
452            !perm.is_read_allowed("/allowed/%2e%2e/secret"),
453            "URL-encoded traversal must be rejected"
454        );
455        assert!(
456            !perm.is_read_allowed("/allowed/%2E%2Fsecret"),
457            "uppercase URL-encoded traversal must be rejected"
458        );
459        assert!(
460            !perm.is_read_allowed("/allowed/%2f..%2fsecret"),
461            "mixed URL-encoded traversal must be rejected"
462        );
463    }
464
465    #[test]
466    fn read_path_traversal_backslash_rejected() {
467        // BCE-20260627-001 regression: %5c decodes to backslash; mixed-separator
468        // traversal must be rejected.
469        let perm = Permission {
470            read: Some(vec!["/allowed".into()]),
471            ..Default::default()
472        };
473        assert!(
474            !perm.is_read_allowed("/allowed/..\\secret"),
475            "backslash traversal must be rejected"
476        );
477    }
478
479    #[test]
480    fn read_path_control_byte_rejected() {
481        // BCE-20260627-001 regression: NUL and control bytes rejected outright.
482        let perm = Permission {
483            read: Some(vec!["/allowed".into()]),
484            ..Default::default()
485        };
486        assert!(
487            !perm.is_read_allowed("/allowed/\0secret"),
488            "NUL injection must be rejected"
489        );
490        assert!(
491            !perm.is_read_allowed("/allowed/\tsecret"),
492            "control-byte injection must be rejected"
493        );
494    }
495
496    #[test]
497    fn write_path_traversal_rejected() {
498        // BCE-20260627-001 regression on write path.
499        let perm = Permission {
500            write: Some(vec!["/tmp/bao".into()]),
501            ..Default::default()
502        };
503        assert!(
504            !perm.is_write_allowed("/tmp/bao/../../etc/passwd"),
505            "write path traversal must be rejected"
506        );
507        assert!(
508            !perm.is_write_allowed("/tmp/bao/%2e%2e/%2e%2e/etc/shadow"),
509            "write URL-encoded traversal must be rejected"
510        );
511    }
512
513    #[test]
514    fn net_subdomain_confusion_rejected() {
515        // BCE-20260627-001 regression: safe.com allowed does NOT mean
516        // safe.com.evil.com is allowed. The old impl would have matched
517        // because safe.com.evil.com starts with safe.com.
518        let perm = Permission {
519            net: Some(vec!["safe.com".into()]),
520            ..Default::default()
521        };
522        assert!(
523            !perm.is_net_allowed("safe.com.evil.com"),
524            "subdomain suffix confusion must be rejected"
525        );
526        assert!(
527            !perm.is_net_allowed("notsafe.com"),
528            "prefix-only match must be rejected"
529        );
530        assert!(perm.is_net_allowed("safe.com"), "exact match must pass");
531        assert!(
532            perm.is_net_allowed("sub.safe.com"),
533            "subdomain match must pass"
534        );
535    }
536
537    #[test]
538    fn net_port_userinfo_rejected() {
539        // BCE-20260627-001 regression: ports/userinfo/path chars rejected.
540        let perm = Permission {
541            net: Some(vec!["safe.com".into()]),
542            ..Default::default()
543        };
544        assert!(
545            !perm.is_net_allowed("safe.com:8080"),
546            "port injection must be rejected"
547        );
548        assert!(
549            !perm.is_net_allowed("user@safe.com"),
550            "userinfo injection must be rejected"
551        );
552        assert!(
553            !perm.is_net_allowed("safe.com/path"),
554            "path injection must be rejected"
555        );
556    }
557
558    #[test]
559    fn net_trailing_dot_normalized() {
560        // BCE-20260627-001 regression: RFC 1034 trailing dot normalized.
561        let perm = Permission {
562            net: Some(vec!["safe.com".into()]),
563            ..Default::default()
564        };
565        assert!(
566            perm.is_net_allowed("safe.com."),
567            "trailing dot must be normalized to bare host"
568        );
569        assert!(
570            perm.is_net_allowed("sub.safe.com."),
571            "trailing dot subdomain must be normalized"
572        );
573    }
574
575    #[test]
576    fn net_case_insensitive() {
577        // BCE-20260627-001 regression: case-insensitive host comparison.
578        let perm = Permission {
579            net: Some(vec!["safe.com".into()]),
580            ..Default::default()
581        };
582        assert!(perm.is_net_allowed("SAFE.com"), "uppercase host must match");
583        assert!(
584            perm.is_net_allowed("sub.SAFE.com"),
585            "uppercase subdomain must match"
586        );
587    }
588
589    #[test]
590    fn env_true_allows() {
591        let perm = Permission {
592            env: Some(true),
593            ..Default::default()
594        };
595        assert!(perm.is_env_allowed());
596    }
597
598    #[test]
599    fn run_true_allows() {
600        let perm = Permission {
601            run: Some(true),
602            ..Default::default()
603        };
604        assert!(perm.is_run_allowed());
605    }
606
607    #[test]
608    fn guard_check_write_denied_category() {
609        let guard = PermissionGuard::new(Permission {
610            write: Some(vec!["/tmp".into()]),
611            ..Default::default()
612        });
613        let err = guard.check_write("/etc/passwd").unwrap_err();
614        assert_eq!(err.category, "write");
615        assert_eq!(err.resource, "/etc/passwd");
616    }
617
618    #[test]
619    fn guard_check_env_denied_category() {
620        let guard = PermissionGuard::new(Permission {
621            env: Some(false),
622            ..Default::default()
623        });
624        let err = guard.check_env().unwrap_err();
625        assert_eq!(err.category, "env");
626        assert_eq!(err.resource, "*");
627    }
628
629    #[test]
630    fn guard_check_run_denied_category() {
631        let guard = PermissionGuard::new(Permission {
632            run: Some(false),
633            ..Default::default()
634        });
635        let err = guard.check_run().unwrap_err();
636        assert_eq!(err.category, "run");
637        assert_eq!(err.resource, "*");
638    }
639
640    #[test]
641    fn sys_default_allows() {
642        let perm = Permission::default();
643        assert!(perm.is_sys_allowed());
644    }
645
646    #[test]
647    fn sys_explicit_true_allows() {
648        let perm = Permission {
649            sys: Some(true),
650            ..Default::default()
651        };
652        assert!(perm.is_sys_allowed());
653    }
654
655    #[test]
656    fn sys_explicit_false_denies() {
657        let perm = Permission {
658            sys: Some(false),
659            ..Default::default()
660        };
661        assert!(!perm.is_sys_allowed());
662    }
663}