Skip to content

Commit bab14dd

Browse files
committed
archive: Fix another PAX header desync (GHSA-3cv2-h65g-fgmm)
Per POSIX pax, a PAX `x` header applies to the next *file* entry, not any intermediary extension headers (GNU LongName `L`, etc.). Violating this lets an attacker craft a tar that extracts differently under tar-rs than under other parsers — a file smuggling vector. Mirrors astral-tokio-tar commit 36e734d (GHSA-3cv2-h65g-fgmm). Assisted-by: OpenCode (claude-sonnet-4-6@default) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 2349b49 commit bab14dd

4 files changed

Lines changed: 78 additions & 2 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ contents are never required to be entirely resident in memory all at once.
2323
filetime = "0.2.8"
2424

2525
[dev-dependencies]
26-
astral-tokio-tar = "0.6"
26+
astral-tokio-tar = "0.6.2"
2727
rand = { version = "0.8", features = ["small_rng"] }
2828
tempfile = "3"
2929
tokio = { version = "1", features = ["macros", "rt"] }

src/archive.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,16 @@ impl<'a> EntriesFields<'a> {
334334
return Err(other("archive header checksum mismatch"));
335335
}
336336

337+
// PAX extensions describe the *next file entry*, not intermediary
338+
// extension headers (GNU LongName `L`, GNU LongLink `K`, PAX `x`/`g`).
339+
let entry_type = header.entry_type();
340+
let is_extension_header = entry_type.is_gnu_longname()
341+
|| entry_type.is_gnu_longlink()
342+
|| entry_type.is_pax_local_extensions()
343+
|| entry_type.is_pax_global_extensions();
344+
337345
let mut pax_size: Option<u64> = None;
338-
if let Some(pax_extensions_ref) = &pax_extensions {
346+
if let Some(pax_extensions_ref) = pax_extensions.filter(|_| !is_extension_header) {
339347
pax_size = pax_extensions_value(pax_extensions_ref, PAX_SIZE);
340348

341349
if let Some(pax_uid) = pax_extensions_value(pax_extensions_ref, PAX_UID) {

tests/all.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2100,3 +2100,71 @@ async fn pax_size_smuggle_matches_astral_tokio_tar() {
21002100
got: {async_entries:?}"
21012101
);
21022102
}
2103+
2104+
#[test]
2105+
fn pax_size_does_not_apply_to_extension_headers() {
2106+
// This archive is ordered as `x (PAX, size=2048) → L (GNU longname) → file_a → file_b`.
2107+
// If the PAX `size=` is wrongly applied to the intermediary `L` header, the
2108+
// parser advances the cursor by 2048 bytes after `L` instead of by `L`'s true
2109+
// payload size, landing in the middle of `file_a`'s body and causing a
2110+
// checksum error that hides both `file_a` and `file_b`. This is Scenario A of
2111+
// GHSA-3cv2-h65g-fgmm. Correct parsing applies PAX only to the next *file*
2112+
// entry, so the L longname renames `file_a` to `longname.txt` and the stream
2113+
// yields ["longname.txt", "file_b"]. Mirrors astral-tokio-tar commit 36e734d.
2114+
let bytes = tar!("pax-overrides-extension-header.tar");
2115+
let mut ar = Archive::new(Cursor::new(bytes));
2116+
let entries: Vec<String> = ar
2117+
.entries()
2118+
.unwrap()
2119+
.map(|e| {
2120+
let e = e.unwrap();
2121+
e.path().unwrap().to_str().unwrap().to_owned()
2122+
})
2123+
.collect();
2124+
assert_eq!(entries, vec!["longname.txt", "file_b"]);
2125+
}
2126+
2127+
/// Cross-validate that `tar` and `astral-tokio-tar` (>= 0.6.2) parse the
2128+
/// GHSA-3cv2-h65g-fgmm test archive identically, guarding against any future
2129+
/// re-introduction of a parsing differential between the two crates.
2130+
#[tokio::test]
2131+
async fn pax_extension_header_matches_astral_tokio_tar() {
2132+
use tokio_stream::StreamExt;
2133+
2134+
let bytes = tar!("pax-overrides-extension-header.tar");
2135+
2136+
// Parse with sync tar-rs.
2137+
let sync_entries: Vec<String> = {
2138+
let mut ar = Archive::new(Cursor::new(bytes));
2139+
ar.entries()
2140+
.unwrap()
2141+
.map(|e| {
2142+
let e = e.unwrap();
2143+
e.path().unwrap().to_str().unwrap().to_owned()
2144+
})
2145+
.collect()
2146+
};
2147+
2148+
// Parse with async astral-tokio-tar (>= 0.6.2, which carries the fix).
2149+
let async_entries: Vec<String> = {
2150+
let mut ar = tokio_tar::Archive::new(bytes);
2151+
let mut entries = ar.entries().unwrap();
2152+
let mut result = Vec::new();
2153+
while let Some(e) = entries.next().await {
2154+
let e = e.unwrap();
2155+
result.push(e.path().unwrap().to_str().unwrap().to_owned());
2156+
}
2157+
result
2158+
};
2159+
2160+
let expected = vec!["longname.txt".to_owned(), "file_b".to_owned()];
2161+
2162+
assert_eq!(
2163+
sync_entries, expected,
2164+
"tar-rs produced unexpected entries\ngot: {sync_entries:?}"
2165+
);
2166+
assert_eq!(
2167+
async_entries, expected,
2168+
"astral-tokio-tar produced unexpected entries\ngot: {async_entries:?}"
2169+
);
2170+
}
6 KB
Binary file not shown.

0 commit comments

Comments
 (0)