Skip to content

fix(deps): verify npm-wrapper binary download against release checksums#91

Merged
dipto0321 merged 1 commit into
mainfrom
fix/npm/checksum-verify
Jul 3, 2026
Merged

fix(deps): verify npm-wrapper binary download against release checksums#91
dipto0321 merged 1 commit into
mainfrom
fix/npm/checksum-verify

Conversation

@dipto0321

Copy link
Copy Markdown
Owner

Summary

nodeup-npm/scripts/install.js did require('https').get(url) and followed the redirect from github.com to objects.githubusercontent.com with zero validation — any host in the Location header would be GET'd and its bytes written straight into ./bin/nodeup. Worse, even if the redirect target was honest, the script trusted the body bytes it received: a TLS-stripping proxy, a compromised CDN bucket, a DNS-poisoned resolver, or a corrupt mirror could swap in an attacker-controlled binary and the user would never know — the file is chmod 755'd and added to $PATH by npm's bin linking.

This PR closes the entire integrity gap before extraction:

  1. Redirect host allowlist. followHops(startUrl, hopsLeft, acceptStatus) is a recursive walker that validates every Location header against ALLOWED_REDIRECT_HOSTS = new Set(['objects.githubusercontent.com']). Any redirect to a non-allowlisted host aborts the install with the offending hostname named. MAX_REDIRECT_HOPS = 2 (the known GitHub chain length — one hop from github.com to the S3-backed CDN) bounds the walker; longer chains throw before any byte is written to disk. Same-host redirects are always permitted (defensive — GitHub never sends a same-host 30x in practice, but a future change to Location: /foo shouldn't break us).

  2. SHA256 verification against the release's checksums.txt. GoReleaser publishes a checksums.txt alongside every artifact (verified at .goreleaser.yaml line 62-64). fetchExpectedHash(repo, tag, archiveName) downloads it first and parses out the expected hash for our exact archive basename — fail fast if the release didn't publish it or our artifact isn't in it, so we don't waste bytes pulling a multi-MB archive we can't verify. downloadTo(url, destPath) then streams the archive to disk while piping it through crypto.createHash('sha256') — the hash is computed as bytes arrive, no second pass over the file. On mismatch, the half-saved archive is unlinkSync'd before die() runs — a forensic investigation of the failing install doesn't leave the (potentially attacker-controlled) bytes sitting in tmp.

  3. parseChecksumsTxt accepts both GoReleaser (<hash> <filename>, two-space) and sha256sum -b / --tag (<hash> *<filename>, binary-mode *) shapes. Comment lines and blank lines are skipped; malformed lines are silently dropped. The trailing-token keying means the parser is robust to any directory prefix a future GoReleaser config might add.

  4. nodeup-npm/scripts/install_test.js (new, 12 tests, no JS test framework) exercises every pure helper. Each test runs through a tiny process.stdout.write + assert.strictEqual shim — failure exits 1, success prints 0 issues. (N tests) (matching golangci-lint's idiom). Coverage:

    • parseChecksumsTxt: two-space separator, binary-mode *, malformed-line skipping, basename keying.
    • followHops: allowlisted redirect (the real GitHub chain), off-CDN redirect rejection, hop-count ceiling, missing-Location-header rejection, unexpected-status rejection.
    • End-to-end integrity: missing-archive-in-checksums error names both the offender and the available set (so the user can debug "did the release publish the wrong checksums?" without diving into the script), hash-mismatch detection, streaming-hash-equals-direct-hash.

Linked issues

Closes #64

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; the JS-side smoke tests are out of make ci's scope by design — make ci runs the Go pipeline that the .github/workflows/ci.yml job runs, and adding node test steps to that pipeline is a separate decision; the install.js tests run with node nodeup-npm/scripts/install_test.js)
  • I added or updated tests for the change (nodeup-npm/scripts/install_test.js, 12 tests covering parseChecksumsTxt / followHops / integrity paths)
  • I updated relevant docs (CHANGELOG.md, inline doc comments at the top of install.js spell out the new threat model + the SHA256 flow)
  • 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 (current main), a redirect to evil.example.com is followed silently:

$ # (no validation — the script just GETs whatever Location says)

Post-fix, the same redirect throws with the offending hostname named:

  nodeup: refusing to follow redirect from https://github.com/.../checksums.txt
  to https://evil.example.com/checksums.txt: host evil.example.com is not
  in the allowlist

Pre-fix, a TLS-stripped body would be chmod 755'd into ./bin/nodeup with no warning. Post-fix:

  nodeup: fetching checksums from https://github.com/dipto0321/nodeup/releases/download/v1.0.0/checksums.txt
  nodeup: expected SHA256 for nodeup_1.0.0_linux_amd64.tar.gz: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
  nodeup: downloading https://github.com/dipto0321/nodeup/releases/download/v1.0.0/nodeup_1.0.0_linux_amd64.tar.gz
  nodeup: computed SHA256 for nodeup_1.0.0_linux_amd64.tar.gz: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
  nodeup: SHA256 mismatch downloading nodeup_1.0.0_linux_amd64.tar.gz:
    expected: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
    actual:   bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
  refusing to extract. This is most likely a TLS-stripping
  proxy, a compromised CDN, or a corrupted download — do NOT
  retry. File an issue at https://github.com/dipto0321/nodeup/issues.

Tests:

$ node nodeup-npm/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
0 issues. (12 tests)

Notes on the JS test framework question

I deliberately did not introduce a JS test framework (jest/mocha/vitest) for what is a 12-case pure-helper smoke check — the same reasoning I used for the tar runtime-dep fix in #63. 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 12 tests above are the most useful signal for a parsing + redirect-validation 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/install.js
    • Adds httpsGet(targetUrl) (single-GET helper for an explicit URL string).
    • Adds followHops(startUrl, hopsLeft, acceptStatus) — recursive redirect walker with host allowlist (ALLOWED_REDIRECT_HOSTS) and hop ceiling (MAX_REDIRECT_HOPS = 2).
    • Adds downloadTo(url, destPath) — streams the body to disk while piping through crypto.createHash('sha256'); returns { path, sha256 }.
    • Adds downloadText(url) — same redirect handling but returns the body as a string (used for checksums.txt).
    • Adds parseChecksumsTxt(body) — accepts GoReleaser / sha256sum -b / sha256sum --tag shapes; key is basename, value is the SHA256 hex.
    • Adds fetchExpectedHash(repo, tag, archiveName) — fetches checksums.txt, parses it, returns the expected hash for our archive name. Errors name both the offender and the available set.
    • Rewrites main(): fetch expected hash first (fail fast), then download archive with streaming hash, then die() on mismatch (after unlinkSync'ing the bad bytes). Extraction runs only on a verified match.
    • Doc-comment block at the top spells out the threat model and what each check blocks.
  • nodeup-npm/scripts/install_test.js — new file, 12 tests, no JS test framework. Mirrors the helpers from install.js (because install.js is a script invoked by npm directly and doesn't module.exports); mirrors the regex + redirect-allowlist semantics exactly. Each test prints ok <name> on success and exits 1 with the offending case on failure.
  • CHANGELOG.md### Fixed entry under [Unreleased] documenting the redirect allowlist, the SHA256 stream-while-downloading pattern, the unlink-on-mismatch cleanup, and the test surface. References security(npm-wrapper): no checksum verification or redirect validation on downloaded binary #64.

Closes #64

Pre-fix, nodeup-npm/scripts/install.js followed the GitHub-releases
redirect from github.com to objects.githubusercontent.com with zero
validation, then trusted whatever bytes came back: any host in the
Location header would be GET'd, and its bytes chmod 755'd into
./bin/nodeup. A TLS-stripping proxy, a compromised CDN bucket, a
DNS-poisoned resolver, or a corrupt mirror could swap in an
attacker-controlled binary and the user would have no way to know.

This commit closes the integrity gap before extraction:

- followHops(startUrl, hopsLeft, acceptStatus) walks the redirect
  chain while validating every Location header against
  ALLOWED_REDIRECT_HOSTS (Set(['objects.githubusercontent.com'])).
  Any off-CDN target aborts the install with the offending hostname
  named. MAX_REDIRECT_HOPS = 2 bounds the walker at the known
  GitHub chain length.

- fetchExpectedHash(repo, tag, archiveName) downloads the release's
  checksums.txt first (fail fast — no point pulling an archive we
  can't verify) and parses out the expected SHA256 for our exact
  archive basename via parseChecksumsTxt. The parser accepts both
  GoReleaser's two-space shape and sha256sum -b / --tag's binary-
  mode * shape, with or without space between * and the filename.

- downloadTo(url, destPath) streams the archive to disk while
  piping it through crypto.createHash('sha256') — the hash is
  computed as bytes arrive, no second pass over the file. On
  mismatch, the half-saved archive is unlinkSync'd before die()
  runs, so a forensic investigation of the failing install
  doesn't leave the (potentially attacker-controlled) bytes
  sitting in tmp.

- nodeup-npm/scripts/install_test.js: 12 pure-helper smoke tests
  covering parseChecksumsTxt (two-space, binary-mode-*, malformed-
  line skipping, basename keying), followHops (allowlisted
  redirect, off-CDN redirect rejection, hop-count ceiling, missing-
  Location-header rejection, unexpected-status rejection), and
  end-to-end integrity (missing-archive-in-checksums error names
  the offender + available set, hash mismatch is detected,
  streaming hash equals direct hash). No JS test framework — same
  reasoning as the tar runtime-dep fix in #63.

CHANGELOG: ### Fixed entry under [Unreleased] documents the
redirect allowlist, the SHA256 stream-while-downloading pattern,
and the unlink-on-mismatch cleanup.

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

cocogitto-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

✔️ b02bde7 - Conventional commits check succeeded.

@dipto0321 dipto0321 merged commit cb425a9 into main Jul 3, 2026
10 checks passed
@dipto0321 dipto0321 deleted the fix/npm/checksum-verify branch July 3, 2026 04:01
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.

security(npm-wrapper): no checksum verification or redirect validation on downloaded binary

1 participant