Skip to content

Commit 3d4b987

Browse files
committed
ipk: guard against path traversal in untrusted package metadata
A .ipk is attacker-controlled. Several path fragments from its metadata were joined onto the extraction directory and then opened without a containment check: the app id and service ids from packageinfo.json, and the main/executable entry from appinfo.json/services.json. A value like "../../../../etc/passwd" or "/dev/zero" would make the verifier open a file outside the package (a device read is an easy DoS; tar's unpack_in only guards the extraction writes, not these later reads). Add a lexical path-containment guard (ensure_within) in ipk-lib and apply it at every join site. Traversal is now rejected before the file is opened, with a clear error, so the malicious path is never read. Also cap the index.html read in webdetect to MAX_FILE_BYTES so a pathological entry can't exhaust memory. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNiK747P4W8C2M9RRhRFS3
1 parent 7645359 commit 3d4b987

5 files changed

Lines changed: 112 additions & 16 deletions

File tree

common/ipk/src/component.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use path_slash::CowExt;
1010

1111
use bin_lib::{BinaryInfo, LibraryInfo, LibraryPriority};
1212

13+
use crate::path::ensure_within;
1314
use crate::{AppInfo, Component, ServiceInfo, Symlinks};
1415

1516
impl AppInfo {
@@ -43,7 +44,8 @@ impl Component<AppInfo> {
4344
if !info.is_native() {
4445
// Web/hosted app: detect the frontend framework and JS syntax level
4546
// from the shipped HTML/JS while the extracted files still exist.
46-
let index_html = dir.join(Cow::from_slash(&info.main));
47+
// `main` is untrusted; keep it inside the app directory.
48+
let index_html = ensure_within(dir, &dir.join(Cow::from_slash(&info.main)))?;
4749
let mut info = info;
4850
info.web = Some(webdetect_lib::detect_web_app(dir, &index_html));
4951
return Ok(Self {
@@ -53,7 +55,7 @@ impl Component<AppInfo> {
5355
libs: Default::default(),
5456
});
5557
}
56-
let exe_path = dir.join(Cow::from_slash(&info.main));
58+
let exe_path = ensure_within(dir, &dir.join(Cow::from_slash(&info.main)))?;
5759
let bin_info = BinaryInfo::parse(
5860
File::open(&exe_path).map_err(|e| {
5961
Error::new(
@@ -101,9 +103,9 @@ impl Component<ServiceInfo> {
101103
});
102104
}
103105
let executable = info.executable.as_ref().unwrap();
104-
let exe_path = dir.join(Cow::from_slash(executable));
106+
let exe_path = ensure_within(dir, &dir.join(Cow::from_slash(executable)))?;
105107
let bin_info = BinaryInfo::parse(
106-
File::open(dir.join(&exe_path))?,
108+
File::open(&exe_path)?,
107109
exe_path.file_name().unwrap().to_string_lossy(),
108110
true,
109111
)

common/ipk/src/ipk.rs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::path::Path;
77
use debpkg::{Control, DebPkg};
88
use path_slash::CowExt;
99

10+
use crate::path::ensure_within;
1011
use crate::{AppInfo, Component, Package, PackageInfo, ServiceInfo, Symlinks};
1112

1213
impl Package {
@@ -49,29 +50,35 @@ impl Package {
4950
}
5051
}
5152
let links = Symlinks::new(links);
52-
let package_info = File::open(tmp.as_ref().join(Cow::from_slash(&format!(
53-
"usr/palm/packages/{id}/packageinfo.json"
54-
))))?;
53+
// The package id and the app/service ids come from untrusted metadata;
54+
// guard every path joined onto the extraction dir against traversal.
55+
let root = tmp.as_ref();
56+
let package_info_path = ensure_within(
57+
root,
58+
&root.join(Cow::from_slash(&format!(
59+
"usr/palm/packages/{id}/packageinfo.json"
60+
))),
61+
)?;
62+
let package_info = File::open(package_info_path)?;
5563
let package_info: PackageInfo = serde_json::from_reader(package_info).map_err(|e| {
5664
Error::new(
5765
ErrorKind::InvalidData,
5866
format!("Bad packageinfo.json: {e:?}"),
5967
)
6068
})?;
61-
let app = Component::<AppInfo>::parse(
62-
tmp.as_ref().join(Cow::from_slash(&format!(
69+
let app_dir = ensure_within(
70+
root,
71+
&root.join(Cow::from_slash(&format!(
6372
"usr/palm/applications/{}",
6473
package_info.app
6574
))),
66-
&links,
6775
)?;
76+
let app = Component::<AppInfo>::parse(app_dir, &links)?;
6877
let mut services = Vec::new();
6978
for id in &package_info.services {
70-
let service = Component::<ServiceInfo>::parse(
71-
tmp.as_ref()
72-
.join(Cow::from_slash(&format!("usr/palm/services/{id}"))),
73-
&links,
74-
)?;
79+
let service_dir =
80+
ensure_within(root, &root.join(Cow::from_slash(&format!("usr/palm/services/{id}"))))?;
81+
let service = Component::<ServiceInfo>::parse(service_dir, &links)?;
7582
services.push(service);
7683
}
7784
return Ok(Self {

common/ipk/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use webdetect_lib::{ServiceRuntimeDetection, WebAppDetection};
88
mod component;
99
mod ipk;
1010
mod links;
11+
mod path;
1112

1213
#[derive(Debug)]
1314
pub struct Package {

common/ipk/src/path.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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+
}

common/webdetect/src/web.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const MAX_REMOTE: usize = 20;
3030
/// Detect the framework, webOSTV.js SDK, ES syntax level and remote resources
3131
/// of the web app rooted at `dir`, whose HTML entry point is `index_html`.
3232
pub fn detect_web_app(dir: &Path, index_html: &Path) -> WebAppDetection {
33-
let html = fs::read_to_string(index_html).unwrap_or_default();
33+
let html = read_capped(index_html);
3434

3535
let mut js: Vec<(String, String)> = Vec::new();
3636
collect_js(dir, 0, &mut js);
@@ -51,6 +51,15 @@ pub fn detect_web_app(dir: &Path, index_html: &Path) -> WebAppDetection {
5151
}
5252
}
5353

54+
/// Read a file to a string, but skip (return empty) anything larger than
55+
/// [`MAX_FILE_BYTES`] so a pathological entry can't exhaust memory.
56+
fn read_capped(path: &Path) -> String {
57+
if fs::metadata(path).map(|m| m.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES {
58+
return String::new();
59+
}
60+
fs::read_to_string(path).unwrap_or_default()
61+
}
62+
5463
/// Recursively gather `*.js` file contents (skipping source maps), bounded by
5564
/// the depth/count/size caps above.
5665
fn collect_js(dir: &Path, depth: usize, out: &mut Vec<(String, String)>) {

0 commit comments

Comments
 (0)