|
| 1 | +//! Path-containment guard for untrusted package metadata. |
| 2 | +//! |
| 3 | +//! A `.ipk` is attacker-controlled input. Its metadata carries path fragments |
| 4 | +//! that get joined onto the extraction directory and then opened — the app id |
| 5 | +//! and service ids from `packageinfo.json`, and the `main`/`executable` entry |
| 6 | +//! from `appinfo.json`/`services.json`. Without a check, a value like |
| 7 | +//! `../../../../dev/zero` or `/etc/passwd` would make the verifier open a file |
| 8 | +//! outside the package (a device read is an easy DoS). `tar`'s `unpack_in` |
| 9 | +//! already blocks traversal when *writing* extracted files; this guards the |
| 10 | +//! *reads* we do afterwards. |
| 11 | +
|
| 12 | +use std::io::{Error, ErrorKind}; |
| 13 | +use std::path::{Component, Path, PathBuf}; |
| 14 | + |
| 15 | +/// Lexically resolve `.` / `..` components without touching the filesystem. |
| 16 | +/// |
| 17 | +/// An extracted package has no on-disk symlinks (they are recorded in memory, |
| 18 | +/// never written), so lexical resolution matches canonicalization for our tree |
| 19 | +/// while never opening the candidate — a traversal path is rejected before it |
| 20 | +/// is ever read. |
| 21 | +pub(crate) fn lexical_normalize(path: &Path) -> PathBuf { |
| 22 | + let mut out = PathBuf::new(); |
| 23 | + for comp in path.components() { |
| 24 | + match comp { |
| 25 | + Component::ParentDir => { |
| 26 | + out.pop(); |
| 27 | + } |
| 28 | + Component::CurDir => {} |
| 29 | + other => out.push(other.as_os_str()), |
| 30 | + } |
| 31 | + } |
| 32 | + out |
| 33 | +} |
| 34 | + |
| 35 | +/// Ensure `candidate` stays within `root` after resolving `.`/`..`, returning |
| 36 | +/// the normalized path. Rejects path traversal via untrusted package metadata. |
| 37 | +pub(crate) fn ensure_within(root: &Path, candidate: &Path) -> Result<PathBuf, Error> { |
| 38 | + let root = lexical_normalize(root); |
| 39 | + let candidate = lexical_normalize(candidate); |
| 40 | + if !candidate.starts_with(&root) { |
| 41 | + return Err(Error::new( |
| 42 | + ErrorKind::InvalidData, |
| 43 | + format!("unsafe path escapes package directory: {}", candidate.display()), |
| 44 | + )); |
| 45 | + } |
| 46 | + Ok(candidate) |
| 47 | +} |
| 48 | + |
| 49 | +#[cfg(test)] |
| 50 | +mod tests { |
| 51 | + use super::*; |
| 52 | + |
| 53 | + #[test] |
| 54 | + fn allows_paths_inside_root() { |
| 55 | + let root = Path::new("/tmp/pkg"); |
| 56 | + assert!(ensure_within(root, Path::new("/tmp/pkg/usr/palm/app/index.html")).is_ok()); |
| 57 | + // `..` that stays within root is fine. |
| 58 | + assert_eq!( |
| 59 | + ensure_within(root, Path::new("/tmp/pkg/usr/../usr/main")).unwrap(), |
| 60 | + PathBuf::from("/tmp/pkg/usr/main") |
| 61 | + ); |
| 62 | + } |
| 63 | + |
| 64 | + #[test] |
| 65 | + fn rejects_traversal_escaping_root() { |
| 66 | + let root = Path::new("/tmp/pkg"); |
| 67 | + assert!(ensure_within(root, Path::new("/tmp/pkg/../../../../etc/passwd")).is_err()); |
| 68 | + assert!(ensure_within(root, Path::new("/tmp/pkg/a/../../../dev/zero")).is_err()); |
| 69 | + } |
| 70 | + |
| 71 | + #[test] |
| 72 | + fn rejects_absolute_paths_outside_root() { |
| 73 | + let root = Path::new("/tmp/pkg"); |
| 74 | + // A `main` of "/etc/passwd" makes join() reset to the absolute path. |
| 75 | + assert!(ensure_within(root, Path::new("/etc/passwd")).is_err()); |
| 76 | + } |
| 77 | +} |
0 commit comments