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
40 changes: 39 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.1] - 2026-11-01

A patch release that repairs the **npm install path**, which has been
broken for every user since v1.1.0.

`nodeup-npm/scripts/install.js` validates the redirect GitHub serves
for release-asset downloads against a host allowlist. GitHub now
serves release assets from `release-assets.githubusercontent.com`, but
the allowlist only listed `objects.githubusercontent.com` — so the
wrapper refused the redirect and `npm install -g nodeupx` aborted
before it could fetch the binary:

```
nodeup: refusing to follow redirect from
https://github.com/dipto0321/nodeup/releases/download/v1.2.0/checksums.txt
to https://release-assets.githubusercontent.com/...: host
release-assets.githubusercontent.com is not in the allowlist
```

The allowlist was introduced in #91 (`cb425a9`, 2026-07-04), when
GitHub was still redirecting to `objects.githubusercontent.com`. The CDN
host changed out from under the wrapper, and nothing failed loudly
until the next install — so the breakage was silent for four months.

Both hosts are GitHub-controlled, so allowing both preserves the
security property #91 was after: an attacker still cannot redirect
the download to a host they control.

The Homebrew tap, the Scoop bucket, and direct binary downloads were
never affected — they don't go through this redirect-validation path.

### Fixed
- `nodeup-npm/scripts/install.js`: accept
`release-assets.githubusercontent.com` as a release-asset redirect
target, alongside `objects.githubusercontent.com`. Restores
`npm install -g nodeupx` on every platform. Closes #126.

## [1.2.0] - 2026-11-01

The headline of this release is **Vite+ support** — `nodeup` now
Expand Down Expand Up @@ -872,7 +909,8 @@ without weakening the body-length guard for hand-authored commits.
### Added
- Project blueprint — internal design doc covering language choice, scope, detection engine, version resolution, package migration, architecture, CLI design, edge cases, git workflow, conventional commits, versioning, CI/CD, and distribution. (Superseded by `README.md`; the standalone doc was removed in the Unreleased section.)

[Unreleased]: https://github.com/dipto0321/nodeup/compare/v1.2.0...HEAD
[Unreleased]: https://github.com/dipto0321/nodeup/compare/v1.2.1...HEAD
[1.2.1]: https://github.com/dipto0321/nodeup/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/dipto0321/nodeup/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/dipto0321/nodeup/compare/v1.0.1...v1.1.0
[0.0.0]: https://github.com/dipto0321/nodeup/releases/tag/v0.0.0
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ orchestrator.
| 5 — Config subsystem | Done | merged |
| 6 — Cross-platform polish | Done | merged (interrupted-upgrade sentinel, `QuotePath`, system-node classifier) |
| 7 — Distribution packaging | Done | GoReleaser, brew/scoop/npm wrappers all shipped (#17, #18); manual v1.0.0 npm publish (#35) bootstrapped the OIDC trust |
| 8 — v1.0.0 → v1.2.0 releases | Done | `nodeupx@1.2.0` live on npm, matching the Go binary; trusted-publisher OIDC flow active for every tag (`#109`) |
| 8 — v1.0.0 → v1.2.1 releases | Done | `nodeupx@1.2.1` live on npm (pinned to the v1.2.0 Go binary); trusted-publisher OIDC flow active for every tag (`#109`) |

## On-disk data layout

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,9 @@ See [`docs/configuration.md`](./docs/configuration.md) for the full schema.

## Project status

Current release: **v1.2.0** (`nodeupx@1.2.0` on npm — the wrapper
Current release: **v1.2.1** (`nodeupx@1.2.1` on npm — the wrapper
publishes automatically alongside the tag). See `CHANGELOG.md` for the
v1.1.0 → v1.2.0 history.
v1.1.0 → v1.2.1 history.

| Phase | Status | What shipped |
|---|---|---|
Expand All @@ -257,7 +257,7 @@ v1.1.0 → v1.2.0 history.
| 5 — Config subsystem | ✅ | YAML config at `~/.nodeup/config.yaml` + `config {show,get,set,init}` + env vars |
| 6 — Cross-platform polish | ✅ | `QuotePath`, interrupted-upgrade replay, system-node classifier, lock file |
| 7 — Distribution packaging | ✅ | GoReleaser + brew tap + Scoop bucket + npm wrapper (`nodeupx` via OIDC Trusted Publishing) |
| 8 — Tagged releases | ✅ | `v1.0.0` shipped (PR #35); `v1.1.0` shipped with the `internal/ui` rollout (`#117`/`#118`/`#119`); `v1.2.0` adds Vite+ support (`#124`) |
| 8 — Tagged releases | ✅ | `v1.0.0` shipped (PR #35); `v1.1.0` shipped with the `internal/ui` rollout (`#117`/`#118`/`#119`); `v1.2.0` adds Vite+ support (`#124`); `v1.2.1` repairs the npm install path (`#126`) |

**Release train:** every `v*.*.*` tag triggers GoReleaser → brew formula
push → Scoop manifest push → `nodeupx` publish via OIDC. To ship a
Expand Down
2 changes: 1 addition & 1 deletion nodeup-npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nodeupx",
"version": "1.2.0",
"version": "1.2.1",
"description": "Automated Node.js version upgrade + global package migration CLI. Static Go binary wrapped for npm distribution.",
"license": "MIT",
"author": "Dipto Karmakar <dipto@local>",
Expand Down
34 changes: 25 additions & 9 deletions nodeup-npm/scripts/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
//
// 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.
// targets must be on a GitHub-owned release-asset host (see
// ALLOWED_REDIRECT_HOSTS below); 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,
Expand Down Expand Up @@ -123,15 +123,31 @@ function archiveName(os, arch, version) {
// 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']);
// Allowed redirect targets: GitHub Releases redirects release-asset
// downloads to a GitHub-owned host. We allowlist those hosts — anything
// else (a compromised CDN, a TLS-stripping proxy, a typo in `Location`)
// is rejected. See #64.
//
// The host has changed over time:
// - `objects.githubusercontent.com` — the older S3-backed CDN.
// - `release-assets.githubusercontent.com` — the current host
// (observed on v1.2.0, 2026-11-01). GitHub moved release-asset
// serving here, and the wrapper's install path broke as a result:
// the redirect was refused as "host not in the allowlist", so
// `npm install -g nodeupx` failed outright.
//
// Both are GitHub-controlled, so allowing both keeps the redirect
// allowlist intact (the security property #64 was after — an attacker
// can't redirect the download to a host they control) while surviving
// GitHub's CDN migration.
const ALLOWED_REDIRECT_HOSTS = new Set([
'objects.githubusercontent.com',
'release-assets.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.
// (`github.com` → the release-asset CDN host). Anything beyond that is
// either a bug or an attack.
const MAX_REDIRECT_HOPS = 2;

function httpsGet(targetUrl) {
Expand Down
24 changes: 23 additions & 1 deletion nodeup-npm/scripts/install_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ const path = require('path');
// If you change a helper in install.js, mirror the change here in
// the `// --- mirrored from install.js ---` block below.

const ALLOWED_REDIRECT_HOSTS = new Set(['objects.githubusercontent.com']);
const ALLOWED_REDIRECT_HOSTS = new Set([
'objects.githubusercontent.com',
'release-assets.githubusercontent.com',
]);
const MAX_REDIRECT_HOPS = 2;

// --- mirrored from install.js: parseChecksumsTxt -------------------------
Expand Down Expand Up @@ -197,6 +200,25 @@ test('followHops_allowsGitHubToObjectsCdnRedirect', () => {
assert.strictEqual(result.body, 'checksums body');
});

// followHops: a redirect to `release-assets.githubusercontent.com` is
// accepted. GitHub moved release-asset serving to this host — observed
// on v1.2.0 — and the wrapper's install path broke as a result: the
// redirect was refused as "host not in the allowlist", so
// `npm install -g nodeupx` failed outright. Regression guard.
test('followHops_allowsGitHubToReleaseAssetsCdnRedirect', () => {
const result = followHopsTest(
'https://github.com/dipto0321/nodeup/releases/download/v1.2.0/checksums.txt',
MAX_REDIRECT_HOPS,
[
{ status: 302, location: 'https://release-assets.githubusercontent.com/abc/checksums.txt' },
{ status: 200, body: 'checksums body' },
],
[200]
);
assert.strictEqual(result.url, 'https://release-assets.githubusercontent.com/abc/checksums.txt');
assert.strictEqual(result.body, 'checksums body');
});

// followHops: a redirect to a non-allowlisted host is rejected.
test('followHops_rejectsOffCdnRedirect', () => {
assert.throws(
Expand Down
Loading