Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
AllowedManagerNames ↔ All() parity and IsAllowedManagerName
behaviour (incl. traversal payloads, case-fold negativity, and
near-miss strings). Closes #51.
- `nodeup-npm/scripts/install.js`: verify the downloaded binary
archive against the SHA256 published in the release's
`checksums.txt` before extracting. Pre-fix, the install script
trusted whatever bytes came back from `github.com` /
`objects.githubusercontent.com` and wrote them straight to
`./bin/nodeup` — a TLS-stripping proxy, compromised CDN, or
manipulated DNS could swap in an attacker-controlled binary
and the user would have no way to know. Two independent fixes
in this change:
- **Redirect allowlist** (`followHops`): the one-hop redirect
from `github.com` to `objects.githubusercontent.com` is now
validated against `ALLOWED_REDIRECT_HOSTS` (`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)
bounds the walker; longer chains throw before any byte is
written to disk.
- **SHA256 verification**: `fetchExpectedHash(repo, tag,
archiveName)` downloads `checksums.txt` first (fail fast —
no point 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')`, so
the hash is computed as the bytes arrive (no second pass
over the file). On mismatch, the half-saved archive is
unlinked before the script dies — a forensic investigation
of the failing install doesn't leave the
(potentially attacker-controlled) bytes sitting in tmp.
- `parseChecksumsTxt` accepts both GoReleaser's `<hash> <filename>`
(two-space) shape and the `sha256sum -b` / `--tag` `<hash>
*<filename>` (binary-mode `*`) shape, with or without space
between the `*` and the filename. Comment lines and blank
lines are skipped; malformed lines are silently dropped.
- `nodeup-npm/scripts/install_test.js` (new file): 12 pure-
helper smoke tests covering `parseChecksumsTxt` (two-space,
binary-mode-`*`, malformed-line skipping, basename keying),
`followHops` (allow-listed redirect, off-CDN redirect
rejection, hop-count ceiling, missing-Location-header
rejection, unexpected-status rejection), and the end-to-end
integrity path (missing-archive-in-checksums error names
both the offender and the available set, hash mismatch is
detected, streaming hash equals direct hash). Tests run
via `node nodeup-npm/scripts/install_test.js` and exit 0
on green; on failure they print the offending case and exit
1. No JS test framework is introduced (the project already
has a 1-line `require('tar')` check that didn't justify
pulling in jest/mocha/vitest either; see #63). Closes #64.

## [0.0.0] - 2024-07-01

Expand Down
237 changes: 198 additions & 39 deletions nodeup-npm/scripts/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
//
// install.js — runs on `npm install` (postinstall) AND `npm install -g`
// (postinstall). Downloads the nodeup Go binary that matches this
// package's `binaryVersion` field for the user's OS/arch, extracts
// package's `binaryVersion` field for the user's OS/arch, verifies
// its SHA256 against the release's published checksums.txt, extracts
// it to ./bin/, and chmods it executable on POSIX.
//
// We deliberately pin to a specific version (the one in package.json's
Expand All @@ -25,12 +26,27 @@
// Format overrides (line 53-55 of .goreleaser.yaml):
// - format_overrides: { goos: windows, format: zip }
// So unix archives are .tar.gz, windows is .zip.
//
// Integrity / supply-chain hygiene (see #64):
// - The download URL follows one redirect via `request()`. Redirect
// targets must be on the `objects.githubusercontent.com` host
// (GitHub Releases' S3-backed CDN); any other host is rejected.
// This blocks the trivial MITM-via-redirect attack surface.
// - Before extraction, we download `checksums.txt` from the same
// release tag, parse out the SHA256 for our exact archive name,
// and recompute the archive's hash as we stream it to disk. A
// mismatch aborts the install before the archive is ever
// extracted. This catches any silent substitution (TLS-
// inspecting proxy, compromised CDN, manipulated DNS, etc.)
// even if the binary itself doesn't execute.

'use strict';

const fs = require('fs');
const path = require('path');
const https = require('https');
const crypto = require('crypto');
const url = require('url');
const { execFileSync } = require('child_process');
// `tar` is an EXPLICIT runtime dependency declared in this package's
// package.json `dependencies` block. The pre-fix comment
Expand Down Expand Up @@ -82,56 +98,175 @@ function platformOsArch() {
arm64: 'arm64',
};

const os = PLATFORM_TO_OS[platform];
const osName = PLATFORM_TO_OS[platform];
const goarch = ARCH_TO_GOARCH[arch];
if (!os || !goarch) {
if (!osName || !goarch) {
die(`unsupported platform/architecture: ${platform}/${arch}`);
}
return { os, goarch };
return { os: osName, goarch };
}

function archiveName(os, arch, version) {
const ext = os === 'windows' ? 'zip' : 'tar.gz';
return `nodeup_${version}_${os}_${arch}.${ext}`;
}

function downloadTo(url, destPath) {
// ---- networking ----------------------------------------------------------
//
// `requestOnce` performs a single GET against `targetUrl`, returning the
// `http.IncomingMessage` whose status code has already been validated
// against `expectedStatus` (default 200). It does NOT follow redirects;
// callers must inspect `res.headers.location` themselves and validate
// the target before calling again.
//
// `httpsGet` is the same, but for an explicit (parsed) `url.URL`. We
// keep them split so callers that already have a URL object don't pay
// for the parse twice.

// Allowed redirect targets: GitHub Releases redirects to their S3-
// backed CDN. We allowlist that single host — anything else (a
// compromised CDN, a TLS-stripping proxy, a typo in `Location`) is
// rejected. See #64.
const ALLOWED_REDIRECT_HOSTS = new Set(['objects.githubusercontent.com']);

// GitHub release URL → redirect target. Two hops is the known maximum
// (`github.com` → `objects.githubusercontent.com`). Anything beyond
// that is either a bug or an attack.
const MAX_REDIRECT_HOPS = 2;

function httpsGet(targetUrl) {
return new Promise((resolve, reject) => {
const parsed = new url.URL(targetUrl);
const req = https.get(
parsed,
{ headers: { 'user-agent': 'nodeup-npm-wrapper' } },
(res) => resolve(res)
);
req.on('error', reject);
});
}

function followHops(startUrl, hopsLeft, acceptStatus) {
// Walk a chain of GitHub-releases redirects, returning the first
// response whose status code is in `acceptStatus`. Throws if the
// chain leaves the allowlisted host set or exceeds `hopsLeft`.
return (async function walk(currentUrl, hopsUsed) {
const res = await httpsGet(currentUrl);
if (acceptStatus.includes(res.statusCode)) {
return res;
}
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303 || res.statusCode === 307 || res.statusCode === 308) {
if (hopsUsed >= hopsLeft) {
throw new Error(`too many redirects (max ${hopsLeft}) from ${currentUrl}`);
}
const next = res.headers.location;
if (!next) {
throw new Error(`redirect with no Location header from ${currentUrl}`);
}
// Resolve relative → absolute.
const nextUrl = new url.URL(next, currentUrl).toString();
const nextHost = new url.URL(nextUrl).hostname;
if (!ALLOWED_REDIRECT_HOSTS.has(nextHost)) {
throw new Error(
`refusing to follow redirect from ${currentUrl} to ${nextUrl}: ` +
`host ${nextHost} is not in the allowlist`
);
}
// Drain the body so the connection can close cleanly before
// we walk further down the chain.
res.resume();
return walk(nextUrl, hopsUsed + 1);
}
throw new Error(
`unexpected HTTP ${res.statusCode} from ${currentUrl}: ` +
`wanted one of [${acceptStatus.join(', ')}]`
);
})(startUrl, 0);
}

// downloadTo fetches `url` and streams the body to `destPath`, while
// also piping the body through a SHA-256 hasher. When the stream
// finishes, the resolved value is `{ path: destPath, sha256: hex }`.
// Callers MUST verify the hash against an authoritative source before
// using the downloaded artifact.
function downloadTo(urlStr, destPath) {
info(`downloading ${urlStr}`);
return new Promise((resolve, reject) => {
info(`downloading ${url}`);
const request = (targetUrl) => {
https
.get(targetUrl, { headers: { 'user-agent': 'nodeup-npm-wrapper' } }, (res) => {
// GitHub releases redirect to S3. Follow one redirect (the
// pattern is consistent enough that two-hop handling would
// be overkill for this use case).
if (res.statusCode === 302 || res.statusCode === 301) {
const redirect = res.headers.location;
if (!redirect) {
reject(new Error(`redirect with no Location header from ${targetUrl}`));
return;
}
request(redirect);
return;
}
if (res.statusCode !== 200) {
reject(
new Error(
`download failed: HTTP ${res.statusCode} from ${targetUrl}`
)
);
return;
}
const out = fs.createWriteStream(destPath);
res.pipe(out);
out.on('finish', () => out.close(() => resolve(destPath)));
out.on('error', reject);
})
.on('error', reject);
};
request(url);
followHops(urlStr, MAX_REDIRECT_HOPS, [200])
.then((res) => {
const hasher = crypto.createHash('sha256');
const out = fs.createWriteStream(destPath);
res.on('data', (chunk) => hasher.update(chunk));
res.on('error', reject);
out.on('error', reject);
out.on('finish', () => {
out.close(() => resolve({ path: destPath, sha256: hasher.digest('hex') }));
});
res.pipe(out);
})
.catch(reject);
});
}

// downloadText fetches a small text body, following the same redirect
// chain as downloadTo but returning the full body as a string. Used
// for checksums.txt (always tiny). Resolves to a string.
function downloadText(urlStr) {
return new Promise((resolve, reject) => {
followHops(urlStr, MAX_REDIRECT_HOPS, [200])
.then((res) => {
const chunks = [];
res.setEncoding('utf8');
res.on('data', (chunk) => chunks.push(chunk));
res.on('error', reject);
res.on('end', () => resolve(chunks.join('')));
})
.catch(reject);
});
}

// parseChecksumsTxt turns the standard GoReleaser / `sha256sum -b`
// output (one `<hex> <basename>` per line) into a Map keyed by
// the basename. Whitespace between the hash and the filename is
// either two spaces (GoReleaser convention) or `* ` (binary mode
// flag, also accepted by coreutils when `sha256sum` is run with
// `--binary`). Both shapes decode the same.
function parseChecksumsTxt(body) {
const out = new Map();
for (const raw of body.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
// Match "<64-hex> [<star>] [<filename>]". The optional `*`
// marks binary mode and may be adjacent to the filename
// (`sha256sum -b` style: `<hash> *<filename>`) or separated
// by whitespace (`sha256sum --tag` style: `<hash> * <filename>`).
// GoReleaser emits the plain two-space form. We accept all three.
const m = /^([a-f0-9]{64})\s+\*?\s*(.+)$/.exec(line);
if (!m) continue;
out.set(m[2], m[1]);
}
return out;
}

// fetchExpectedHash downloads `checksums.txt` from the same release
// tag and returns the expected SHA256 for `archiveName`.
async function fetchExpectedHash(repo, tag, archiveName) {
const checksumsUrl = `https://github.com/${repo}/releases/download/${tag}/checksums.txt`;
info(`fetching checksums from ${checksumsUrl}`);
const body = await downloadText(checksumsUrl);
const checksums = parseChecksumsTxt(body);
const expected = checksums.get(archiveName);
if (!expected) {
throw new Error(
`checksums.txt from ${tag} does not include an entry for ${archiveName}; ` +
`available entries: ${Array.from(checksums.keys()).join(', ') || '(none)'}`
);
}
return expected;
}

// ---- extraction ----------------------------------------------------------

function extractTarGz(archivePath, outDir) {
return tar.x({
file: archivePath,
Expand Down Expand Up @@ -187,7 +322,31 @@ function chmodExec(filePath) {
fs.mkdirSync(binDir, { recursive: true });

try {
await downloadTo(downloadUrl, archivePath);
// Download the expected hash FIRST — fail fast if the release
// didn't publish checksums.txt or our artifact isn't in it, so
// we don't waste bytes pulling an archive we can't verify.
const expectedHash = await fetchExpectedHash(repo, tag, archive);
info(`expected SHA256 for ${archive}: ${expectedHash}`);

// Download the archive, hashing as we stream to disk.
const { sha256: actualHash } = await downloadTo(downloadUrl, archivePath);
info(`computed SHA256 for ${archive}: ${actualHash}`);

if (actualHash !== expectedHash) {
// Delete the half-saved archive before aborting so a
// forensic investigation of the failing install at least
// doesn't leave the (potentially attacker-controlled)
// bytes sitting in the user's tmp dir.
try { fs.unlinkSync(archivePath); } catch (_) {}
die(
`SHA256 mismatch downloading ${archive}:\n` +
` expected: ${expectedHash}\n` +
` actual: ${actualHash}\n` +
`refusing to extract. This is most likely a TLS-stripping\n` +
`proxy, a compromised CDN, or a corrupted download — do NOT\n` +
`retry. File an issue at https://github.com/${repo}/issues.`
);
}

if (os === 'windows') {
extractZip(archivePath, tmpDir);
Expand All @@ -214,4 +373,4 @@ function chmodExec(filePath) {
}
})().catch((err) => {
die(err && err.message ? err.message : String(err));
});
});
Loading
Loading