Summary
ctg identity import (packages/cli/src/commands/identity.command.ts:87-104) unpacks an archive containing Stellar secret keys into ~/.config/stellar. The recent path-traversal hardening (464221b) closed the ../ case, but two gaps remain in untarDirectory (identity.command.ts:36-62).
1. Archive-controlled file permissions
await mkdir(targetDir, { recursive: true, mode: 0o700 });
await assertNoPathTraversal(archiveFile, targetDir);
await execa("tar", ["-xzf", archiveFile, "-C", targetDir], { stdio: "inherit" });
The 0o700 applies to the target directory only. GNU tar restores each member's recorded mode, so an archive whose entries are stored 0644/0666 yields world-readable secret keys inside a 0700 directory — and if any intermediate directory in the archive is stored 0755, the containing directory becomes traversable too. On a shared CI runner or multi-user host, that exposes signing keys to any local user.
The rest of the command is careful about exactly this (withSecureTempDir uses mkdtemp + rm, the staged archive is written with mode: 0o600) — the extraction step is the one place the guarantee is dropped.
2. Symlink escape is not covered by a name-only pre-check
assertNoPathTraversal runs tar -tzf and resolves each entry name against the target. It cannot see entry types. An archive containing:
link -> symlink to /home/victim/.ssh
link/authorized_keys
passes the check — path.resolve(target, "link/authorized_keys") stays inside target — but extraction may write through the symlink created moments earlier by the same archive. This is the classic node-tar/Zip-Slip-adjacent symlink escape; GNU tar's defenses against it vary by version and flags, so relying on them implicitly is fragile.
There is also a TOCTOU gap in principle (list pass and extract pass are two separate reads of the file), though it is small here since the archive is staged in a 0700 temp dir the command itself owns.
Failure scenario
A developer follows a CI runbook and runs ctg identity import ci-identity.b64 on a shared build machine, using an archive produced by an untrusted or compromised pipeline. The archive's members are stored mode 0644; every other user on that host can now read the deployer's Stellar secret key. A second archive with a symlink member writes into ~/.ssh/authorized_keys instead of the Stellar config dir.
Suggested fix
- Extract with
--no-same-permissions --no-same-owner and a restrictive umask (or chmod -R go-rwx the target immediately after extraction) so imported key material is never group/world readable.
- Reject archives containing symlink or hardlink members outright.
tar -tvzf (verbose) exposes the type character, or switch to a library extractor with an explicit filter that rejects non-regular-file/non-directory entries.
- Consider
--no-overwrite-dir and extracting to a fresh staging dir, then moving into place, so a partially-validated archive never touches ~/.config/stellar.
Scope: audit was read-only, no code changed. Related to the already-shipped traversal fix in 464221b — this is the remainder of that surface.
Summary
ctg identity import(packages/cli/src/commands/identity.command.ts:87-104) unpacks an archive containing Stellar secret keys into~/.config/stellar. The recent path-traversal hardening (464221b) closed the../case, but two gaps remain inuntarDirectory(identity.command.ts:36-62).1. Archive-controlled file permissions
The
0o700applies to the target directory only. GNUtarrestores each member's recorded mode, so an archive whose entries are stored0644/0666yields world-readable secret keys inside a 0700 directory — and if any intermediate directory in the archive is stored0755, the containing directory becomes traversable too. On a shared CI runner or multi-user host, that exposes signing keys to any local user.The rest of the command is careful about exactly this (
withSecureTempDirusesmkdtemp+rm, the staged archive is written withmode: 0o600) — the extraction step is the one place the guarantee is dropped.2. Symlink escape is not covered by a name-only pre-check
assertNoPathTraversalrunstar -tzfand resolves each entry name against the target. It cannot see entry types. An archive containing:passes the check —
path.resolve(target, "link/authorized_keys")stays insidetarget— but extraction may write through the symlink created moments earlier by the same archive. This is the classic node-tar/Zip-Slip-adjacent symlink escape; GNU tar's defenses against it vary by version and flags, so relying on them implicitly is fragile.There is also a TOCTOU gap in principle (list pass and extract pass are two separate reads of the file), though it is small here since the archive is staged in a 0700 temp dir the command itself owns.
Failure scenario
A developer follows a CI runbook and runs
ctg identity import ci-identity.b64on a shared build machine, using an archive produced by an untrusted or compromised pipeline. The archive's members are stored mode0644; every other user on that host can now read the deployer's Stellar secret key. A second archive with a symlink member writes into~/.ssh/authorized_keysinstead of the Stellar config dir.Suggested fix
--no-same-permissions --no-same-ownerand a restrictiveumask(orchmod -R go-rwxthe target immediately after extraction) so imported key material is never group/world readable.tar -tvzf(verbose) exposes the type character, or switch to a library extractor with an explicitfilterthat rejects non-regular-file/non-directory entries.--no-overwrite-dirand extracting to a fresh staging dir, then moving into place, so a partially-validated archive never touches~/.config/stellar.Scope: audit was read-only, no code changed. Related to the already-shipped traversal fix in 464221b — this is the remainder of that surface.