fix(install): skip and reject links when extracting .eti archives

Security audit findings EXP2-SEC-01, SEC-IPC-04 and Codex #3 (symlink
redirection through externally extracted archives).

The ordinary install path hands every root `.eti` archive to an external
`unrar x` process and promotes the resulting staging directory to
`local/` as soon as the extractor exits 0. Nothing inspected what unrar
materialised. A symbolic link (or, on Windows, a junction) inside an
archive would survive promotion and later redirect the launch-time
settings rewrite in `apply_launch_settings_once`, the uninstall path,
or any script the game ships. The archives themselves are BLAKE3
verified against the bundled catalog before they can be installed, so a
hostile link would have to be published by the catalog operator; this
is defense in depth rather than a live remote exploit.

Two independent layers now guard promotion:

- Both `unrar` invocations (Tauri sidecar and peer-cli external
  unpacker) pass `-ol-`, which makes unrar 7.x skip symbolic-link
  entries entirely. The bundled 7.10 sidecar was checked against a
  fixture archive.
- `install_inner`/`update_inner` walk the staging tree without
  following links and refuse to promote it if any entry is a symlink or
  (on Windows) carries the reparse-point attribute. The normal rollback
  then removes staging and clears the install intent.

The audit's `-sl-` suggestion does not exist in unrar; `-sl<size>` is a
size filter. Post-extraction digest verification of every extracted
file remains out of scope for the ordinary path; Stream Install already
verifies each output entry.

Test plan: `just test` (new unix test installs with a fake unpacker
that plants a symlink and asserts install fails, `local/` is absent and
the intent is cleared; the peer-cli controlled-unrar test checks the
new argument position). Manual: install a fixture game via peer-cli.

Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
This commit is contained in:
2026-09-02 22:32:43 +02:00
parent 887e245638
commit 84cbabfeba
3 changed files with 99 additions and 5 deletions
+5 -1
View File
@@ -347,6 +347,9 @@ impl Unpacker for ExternalUnrarUnpacker {
std::ffi::OsString::from("x"), std::ffi::OsString::from("x"),
std::ffi::OsString::from("-o+"), std::ffi::OsString::from("-o+"),
std::ffi::OsString::from("-p-"), std::ffi::OsString::from("-p-"),
// Skip symbolic links inside the archive; the install
// transaction also audits the extracted tree for links.
std::ffi::OsString::from("-ol-"),
archive.as_os_str().to_owned(), archive.as_os_str().to_owned(),
dest.as_os_str().to_owned(), dest.as_os_str().to_owned(),
], ],
@@ -649,7 +652,8 @@ mod tests {
r#"#!/bin/sh r#"#!/bin/sh
set -eu set -eu
[ "$3" = "-p-" ] [ "$3" = "-p-" ]
dest=$5 [ "$4" = "-ol-" ]
dest=$6
printf '%s' "$$" > "$dest/child.pid" printf '%s' "$$" > "$dest/child.pid"
printf 'started' > "$dest/started" printf 'started' > "$dest/started"
while [ ! -e "$dest/release" ]; do :; done while [ ! -e "$dest/release" ]; do :; done
@@ -462,6 +462,7 @@ async fn install_inner(
prepare_owned_empty_dir(&staging)?; prepare_owned_empty_dir(&staging)?;
root_capability.sync_game_root()?; root_capability.sync_game_root()?;
unpack_archives(game_root, &staging, unpacker, cancel_token).await?; unpack_archives(game_root, &staging, unpacker, cancel_token).await?;
reject_links_in_staging(&staging)?;
rename_path(&staging, &local) rename_path(&staging, &local)
.wrap_err_with(|| format!("failed to promote install for {id}"))?; .wrap_err_with(|| format!("failed to promote install for {id}"))?;
root_capability.sync_game_root()?; root_capability.sync_game_root()?;
@@ -490,6 +491,7 @@ async fn update_inner(
prepare_owned_empty_dir(&staging)?; prepare_owned_empty_dir(&staging)?;
root_capability.sync_game_root()?; root_capability.sync_game_root()?;
unpack_archives(game_root, &staging, unpacker, cancel_token).await?; unpack_archives(game_root, &staging, unpacker, cancel_token).await?;
reject_links_in_staging(&staging)?;
rename_path(&staging, &local).wrap_err_with(|| format!("failed to promote update for {id}"))?; rename_path(&staging, &local).wrap_err_with(|| format!("failed to promote update for {id}"))?;
root_capability.sync_game_root()?; root_capability.sync_game_root()?;
Ok(()) Ok(())
@@ -543,6 +545,53 @@ async fn unpack_archives(
Ok(()) Ok(())
} }
/// Refuses to promote an extracted tree that contains symbolic links or, on
/// Windows, any reparse point (junctions, mount points).
///
/// Extraction is delegated to an external `unrar` process whose output is
/// otherwise trusted verbatim. The archives themselves are BLAKE3-verified
/// against the bundled catalog, so a link would have to originate from the
/// catalog publisher; this audit is defense in depth so that a link can never
/// redirect later launch-time rewrites (`apply_launch_settings_once`) or an
/// uninstall outside the game directory. `unrar` is additionally invoked
/// with `-ol-` so links are skipped at extraction time.
fn reject_links_in_staging(staging: &Path) -> eyre::Result<()> {
let staging = staging.to_path_buf();
scoped_blocking(move || {
for entry in walkdir::WalkDir::new(&staging).follow_links(false) {
let entry = entry.wrap_err_with(|| {
format!("failed to audit extracted tree {}", staging.display())
})?;
let metadata = fs::symlink_metadata(entry.path()).wrap_err_with(|| {
format!(
"failed to inspect extracted entry {}",
entry.path().display()
)
})?;
if is_link_or_reparse_point(&metadata) {
eyre::bail!(
"extracted archive contains a link at {}; refusing to install it",
entry.path().display()
);
}
}
Ok(())
})
}
#[cfg(windows)]
fn is_link_or_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt as _;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
metadata.file_type().is_symlink()
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn is_link_or_reparse_point(metadata: &fs::Metadata) -> bool {
metadata.file_type().is_symlink()
}
pub(crate) fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> { pub(crate) fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
scoped_blocking(|| { scoped_blocking(|| {
let mut archives = Vec::new(); let mut archives = Vec::new();
@@ -1149,6 +1198,7 @@ mod tests {
struct FakeUnpacker { struct FakeUnpacker {
fail: bool, fail: bool,
create_commit_conflict: bool, create_commit_conflict: bool,
create_symlink: bool,
archives: Mutex<Vec<PathBuf>>, archives: Mutex<Vec<PathBuf>>,
} }
@@ -1156,16 +1206,22 @@ mod tests {
fn failing() -> Self { fn failing() -> Self {
Self { Self {
fail: true, fail: true,
create_commit_conflict: false, ..Self::default()
archives: Mutex::new(Vec::new()),
} }
} }
fn commit_conflict() -> Self { fn commit_conflict() -> Self {
Self { Self {
fail: false,
create_commit_conflict: true, create_commit_conflict: true,
archives: Mutex::new(Vec::new()), ..Self::default()
}
}
#[cfg(unix)]
fn symlinking() -> Self {
Self {
create_symlink: true,
..Self::default()
} }
} }
} }
@@ -1187,6 +1243,11 @@ mod tests {
} }
scoped_blocking(|| { scoped_blocking(|| {
fs::write(dest.join("payload.txt"), b"installed")?; fs::write(dest.join("payload.txt"), b"installed")?;
#[cfg(unix)]
if self.create_symlink {
fs::create_dir_all(dest.join("nested"))?;
std::os::unix::fs::symlink("/", dest.join("nested").join("escape"))?;
}
if self.create_commit_conflict { if self.create_commit_conflict {
let game_root = dest let game_root = dest
.parent() .parent()
@@ -1695,6 +1756,31 @@ mod tests {
assert_intent_missing(state.path(), &root, "game"); assert_intent_missing(state.path(), &root, "game");
} }
#[cfg(unix)]
#[tokio::test]
async fn install_refuses_extracted_trees_that_contain_links() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
let err = install(
&root,
state.path(),
"game",
Arc::new(FakeUnpacker::symlinking()),
CancellationToken::new(),
)
.await
.expect_err("install should refuse a link in the extracted tree");
assert!(err.to_string().contains("contains a link"), "{err:#}");
assert!(!root.join("local").exists());
assert!(!root.join(".local.installing").exists());
assert_intent_missing(state.path(), &root, "game");
}
#[tokio::test] #[tokio::test]
async fn update_commit_conflict_preserves_every_ambiguous_directory() { async fn update_commit_conflict_preserves_every_ambiguous_directory() {
let temp = TempDir::new("lanspread-install"); let temp = TempDir::new("lanspread-install");
@@ -3201,6 +3201,10 @@ async fn run_unrar_sidecar(
[ [
std::ffi::OsString::from("x"), std::ffi::OsString::from("x"),
std::ffi::OsString::from("-p-"), std::ffi::OsString::from("-p-"),
// Skip symbolic links inside the archive entirely. The install
// transaction additionally refuses to promote a tree containing
// links, so a link can never redirect later file operations.
std::ffi::OsString::from("-ol-"),
paths.archive.as_os_str().to_owned(), paths.archive.as_os_str().to_owned(),
std::ffi::OsString::from("-y"), std::ffi::OsString::from("-y"),
std::ffi::OsString::from("-o"), std::ffi::OsString::from("-o"),