Skip to content

fix(deps): harden npm-wrapper install against slip, gate matrix, partial state#92

Merged
dipto0321 merged 1 commit into
mainfrom
fix/npm/zip-slip-guard
Jul 3, 2026
Merged

fix(deps): harden npm-wrapper install against slip, gate matrix, partial state#92
dipto0321 merged 1 commit into
mainfrom
fix/npm/zip-slip-guard

Conversation

@dipto0321

Copy link
Copy Markdown
Owner

Summary

Three robustness gaps in the npm wrapper's install path — none individually catastrophic, but each a real failure mode the existing code doesn't catch:

  1. No zip-slip / tar-slip guard on extraction. Neither tar.x() nor the Expand-Archive / unzip -o zip path validates that extracted entry paths stay inside the target temp dir. The attack surface is bounded by the SHA256 verification added in security(npm-wrapper): no checksum verification or redirect validation on downloaded binary #64 (a hostile archive would have to defeat that first), but a defensive check costs nothing and removes the dependency entirely. tar 6.x already strips .. entries by default (node_modules/tar/lib/unpack.js:277-286), so the tar side is mostly defense-in-depth + a cleaner error message. The zip side is the real fix: unzip -o and Expand-Archive both happily extract a ../escape.txt if the archive contains one.

  2. check.js lets win32 + arm64 through the preinstall gate even though .goreleaser.yaml line 45-47 explicitly excludes that combination from the build matrix. Pre-fix, the script mapped process.platform and process.arch independently, so both axes returned non-null and the preinstall check passed — then install.js 404'd trying to download an archive that was never built.

  3. Partial state left on disk on a late-stage failure. The try/finally in install.js cleans up the temp dir, not the final bin/nodeup. If fs.renameSync succeeded and chmod then threw, a non-executable binary was left in place; a retry's renameSync would then either fail with EEXIST or silently overwrite the half-broken file.

This PR addresses all three:

  • isPathInside(parent, child) resolves both paths via path.resolve (sibling-prefix-safe: /tmp/ab is NOT inside /tmp/a) and uses path.relative to detect .. traversal and absolute-path escapes.
  • extractTarGz passes a filter callback that runs isPathInside(outDir, entryPath) for every entry and throws with the offending entry + resolved path if it escapes.
  • extractZip goes through safeExtractZip, which lists the archive's entries first (unzip -Z1 on POSIX, [System.IO.Compression.ZipFile]::OpenRead(...).Entries on Windows), validates each entry's resolved path against outDir, and only then runs the actual extraction.
  • check.js replaces the two-map lookup with a single Set keyed on (platform, arch), mirroring the GoReleaser build matrix exactly: linux/{amd64,arm64}, darwin/{amd64,arm64}, win32/amd64. win32/arm64 is intentionally absent.
  • main() calls fs.rmSync(binaryDest, { force: true }) at the start of the install, before any download. Pre-fix, a late-stage failure left a non-executable bin/nodeup on disk; the cleanup makes the install path effectively transactional.

Linked issues

Closes #65

Type of change

  • fix — bug fix (PATCH bump)
  • feat — new feature (MINOR bump)
  • refactor — no behavior change
  • perf — performance improvement
  • docs — documentation only
  • test — tests only
  • chore — maintenance / dependency bump
  • ci — CI/CD only
  • build — build system only
  • Breaking change (MAJOR bump) — explain below

Checklist

  • Title follows Conventional Commits (feat(scope): subject)
  • I ran make ci locally and it passes (Go-side tidy/fmt/vet/lint/test all green; node scripts/install_test.js 20/20 green, node scripts/check_test.js 12/12 green)
  • I added or updated tests for the change (install_test.js extended with isPathInside_* tests and extractTarGz_filterRejectsTraversalEntry; check_test.js new with the SUPPORTED set pinned)
  • I updated relevant docs (CHANGELOG.md, inline doc comments at the top of check.js explain the lookup-pair fix, comments in install.js explain why tar gets a filter and zip gets a list-first validator)
  • No new linter warnings (make lint green, golangci-lint v2.12.2 matches CI)
  • If breaking: I documented the migration path in the PR body and updated CHANGELOG.md

Screenshots / output

Pre-fix on Windows/ARM64:

$ node -e 'process.platform = "win32"; process.arch = "arm64"; require("./check.js")'
# (silent — preinstall check passed)
$ npm install
  ...
  nodeup: downloading https://github.com/dipto0321/nodeup/releases/download/v1.0.0/nodeup_1.0.0_windows_arm64.zip
  nodeup: HTTP 404 — release not found

Post-fix on Windows/ARM64:

$ node scripts/check.js
  nodeup: unsupported platform/architecture
    platform=win32, arch=arm64

  Built binaries are available for:
    linux/amd64
    linux/arm64
    darwin/amd64
    darwin/arm64
    win32/amd64
$ echo $?
1

Pre-fix on a hypothetical slip-bug (Windows zip extraction):

# (no validation — `Expand-Archive` writes ../../escape.txt to disk)

Post-fix:

  nodeup: refusing to extract zip entry "../escape.txt": resolved path
  C:\Users\me\AppData\Local\Temp\nodeup-xxx\..\escape.txt escapes
  C:\Users\me\AppData\Local\Temp\nodeup-xxx

Pre-fix on a late-stage chmod failure:

# binary half-installed — non-executable `bin/nodeup` left on disk;
# retry either fails with EEXIST or silently overwrites.

Post-fix:

# install is effectively transactional — a fresh `npm install` (or
# `npm install -g`) starts by `fs.rmSync(binaryDest, { force: true })`,
# so a prior half-broken install is wiped before the new attempt.

Tests:

$ node scripts/install_test.js
  ok parseChecksumsTxt_twoSpaceSeparator
  ok parseChecksumsTxt_binaryModeStar
  ok parseChecksumsTxt_skipsMalformedLines
  ok parseChecksumsTxt_keyedByBasename
  ok followHops_allowsGitHubToObjectsCdnRedirect
  ok followHops_rejectsOffCdnRedirect
  ok followHops_rejectsExcessiveHops
  ok followHops_rejectsMissingLocation
  ok followHops_rejectsUnexpectedStatus
  ok integrity_missingArchiveInChecksums_throwsHelpfully
  ok integrity_sha256Mismatch_aborts
  ok integrity_streamingHashMatchesDirectHash
  ok pathInside_insideReturnsTrue
  ok pathInside_equalsReturnsTrue
  ok pathInside_parentReturnsFalse
  ok pathInside_siblingPrefixIsNotInside
  ok pathInside_traversalReturnsFalse
  ok pathInside_absoluteOutsideReturnsFalse
  ok pathInside_trailingSlashIsNormalisedAway
  ok extractTarGz_filterRejectsTraversalEntry
0 issues. (20 tests)

$ node scripts/check_test.js
  ok supported_linux_amd64
  ok supported_linux_arm64
  ok supported_darwin_amd64
  ok supported_darwin_arm64
  ok supported_win32_amd64
  ok rejected_win32_arm64_isTheBugFrom65
  ok rejected_freebsd_amd64
  ok rejected_darwin_ia32
  ok rejected_linux_ppc64
  ok rejected_partialTrailingSlash
  ok rejected_partialSiblingPrefix
  ok errorMessageNamesOffenderAndListsSupported
0 issues. (12 tests)

Notes on the JS test framework question

I deliberately did not introduce a JS test framework (jest/mocha/vitest) — same reasoning as #63 and #64. make ci runs the Go pipeline; adding npm test to it would mean a parallel Node-tooling-vs-version-pinning matrix on a project whose primary CI is go test. The 20+12 cases above are the most useful signal for a parsing + path-validation + platform-gate layer that has no real external dependencies. A future PR adding a structured JS test workflow (with proper coverage, fixtures, hermetic HTTP via nock, etc.) is the right place for that tooling.

Files touched

  • nodeup-npm/scripts/check.js
    • Replaces the per-axis PLATFORM_TO_OS / ARCH_TO_GOARCH maps with a single combined-key Set of (platform, arch) pairs that mirrors GoReleaser's build matrix minus the explicit windows/arm64 exclusion. Keys use Node's process.platform values (win32, not windows), so the lookup is consistent with the values it consumes.
    • Doc-comment block at the top explains the original bug and points at .goreleaser.yaml builds[].ignore so a future reviewer can see why win32/arm64 is intentionally absent.
  • nodeup-npm/scripts/install.js
    • Adds isPathInside(parent, child) — resolves both paths via path.resolve and uses path.relative (sibling-prefix-safe) to detect .. and absolute escapes.
    • extractTarGz now passes a filter callback that runs isPathInside for every entry and throws on slip. Defense in depth — tar 6.x already refuses ..-containing entries by default (node_modules/tar/lib/unpack.js:277-286), so this is belt-and-suspenders + a cleaner JS-throw error message.
    • Adds safeExtractZip — lists the archive's entries first (unzip -Z1 on POSIX, [System.IO.Compression.ZipFile]::OpenRead(...).Entries on Windows), validates each entry's resolved path against outDir, and only then runs the actual extraction. This is the real zip-slip fix; tar doesn't have this problem, only the zip path does.
    • extractZip is a thin shim that calls safeExtractZip.
    • main() adds fs.rmSync(binaryDest, { force: true }) at the start of the install so a late-stage failure doesn't leave a half-broken binary on disk.
  • nodeup-npm/scripts/install_test.js — extended:
    • pathInside_* tests (inside / equals / parent / sibling-prefix / .. traversal / absolute-outside / trailing-slash-normalisation).
    • extractTarGz_filterRejectsTraversalEntry — end-to-end on the filter callback itself, with a good entry + slip entry + absolute-outside entry all in one test.
  • nodeup-npm/scripts/check_test.js — new, 12 tests pinning the SUPPORTED set contents (5 supported, 4 unsupported, 2 partial-match attacks, 1 error-message-shape check). The SUPPORTED set is mirrored rather than required because check.js doesn't module.exports.
  • CHANGELOG.md### Fixed entry under [Unreleased] documenting all three changes with bug(npm-wrapper): no zip-slip guard, Windows/ARM64 passes preinstall but 404s at download, partial state on failure #65 references.

…ial state

Closes #65

Three robustness gaps in nodeup-npm's install path:

1. No zip-slip / tar-slip guard on extraction. tar 6.x already
   refuses ..-containing entries by default
   (node_modules/tar/lib/unpack.js:277-286), so the tar side
   is defense-in-depth. The zip side is the real fix:
   `unzip -o` and PowerShell `Expand-Archive` will both
   happily extract a `../escape.txt` if the archive contains
   one. safeExtractZip lists the archive's entries first
   (`unzip -Z1` on POSIX, [System.IO.Compression.ZipFile] on
   Windows), validates each entry's resolved path against
   outDir, and only then runs the actual extraction.

2. check.js lets `win32 + arm64` through the preinstall gate
   even though .goreleaser.yaml line 45-47 explicitly excludes
   that combination from the build matrix. Pre-fix, the script
   mapped process.platform and process.arch independently, so
   both axes returned non-null, the preinstall check passed,
   and install.js then 404'd trying to download an archive
   that was never built. The new lookup is one Set keyed on
   the actual (platform, arch) pair: linux/{amd64,arm64},
   darwin/{amd64,arm64}, win32/amd64. win32/arm64 is
   intentionally absent.

3. Partial state left on disk on a late-stage failure. The
   try/finally in install.js cleaned up the temp dir but not
   the final bin/nodeup. main() now does
   `fs.rmSync(binaryDest, { force: true })` at the start, so
   a fresh install is effectively transactional — a prior
   half-broken install is wiped before the new attempt.

isPathInside(parent, child) resolves both paths via
path.resolve (sibling-prefix-safe: /tmp/ab is NOT inside
/tmp/a) and uses path.relative to detect .. traversal and
absolute-path escapes. extractTarGz passes a `filter` that
runs isPathInside for every entry and throws with the
offending entry + resolved path if it escapes.

install_test.js extended with pathInside_* tests (inside /
equals / parent / sibling-prefix / .. traversal /
absolute-outside / trailing-slash-normalisation) and
extractTarGz_filterRejectsTraversalEntry. check_test.js
new: 12 tests pinning the SUPPORTED set contents (5
supported, 4 unsupported, 2 partial-match attacks, 1
error-message-shape check).

No JS test framework introduced — same reasoning as #63 and
#64. The SUPPORTED set is mirrored in check_test.js rather
than require()'d because check.js doesn't module.exports.

Co-Authored-By: puku-ai-2.8 <noreply@puku.sh>
@cocogitto-bot

cocogitto-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

✔️ ddb6767 - Conventional commits check succeeded.

@dipto0321 dipto0321 merged commit 3c47bc4 into main Jul 3, 2026
10 checks passed
@dipto0321 dipto0321 deleted the fix/npm/zip-slip-guard branch July 3, 2026 05:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(npm-wrapper): no zip-slip guard, Windows/ARM64 passes preinstall but 404s at download, partial state on failure

1 participant