diff --git a/.gitignore b/.gitignore index cb808e064..0b6cf6de7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ node_modules bin test dist +tmp .vscode/ .DS_Store diff --git a/.mise/tasks/update-latest-signed-node b/.mise/tasks/update-latest-signed-node new file mode 100755 index 000000000..5a57b7800 --- /dev/null +++ b/.mise/tasks/update-latest-signed-node @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Description: Find the latest Node.js version that has GPG signature available and update NODE_VERIFY_BEFORE constant + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +# Check that we're on the main branch +current_branch="$(git branch --show-current)" +if [[ "$current_branch" != "main" ]]; then + echo "Error: must be on 'main' branch, currently on '$current_branch'" >&2 + exit 1 +fi + +# Pull latest changes from upstream +git pull upstream main + +# Require clean working tree for tracked files +if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + echo "Error: tracked files are dirty. Commit or stash first." >&2 + exit 1 +fi + +# Tooling checks +if ! command -v gh >/dev/null 2>&1; then + echo "Error: gh CLI is required (brew install gh)." >&2 + exit 1 +fi + +echo "Fetching Node.js versions from mise..." + +echo "Checking releases for GPG signatures..." +echo + +latest_signed="" +count=0 + +# Get versions from mise in descending order (newest first) +while IFS= read -r version; do + # mise returns versions without 'v' prefix, add it for the URL + version_with_v="v${version}" + + # Check if SHASUMS256.txt.asc exists for this version + if curl -sf -I "https://nodejs.org/dist/${version_with_v}/SHASUMS256.txt.asc" > /dev/null 2>&1; then + echo "✓ ${version} has GPG signature" + if [[ -z "$latest_signed" ]]; then + latest_signed="$version" + fi + else + echo "✗ ${version} missing GPG signature" + fi + + # Only check first 5 versions to avoid too many requests + count=$((count + 1)) + if [[ $count -ge 5 ]]; then + break + fi +done < <(mise ls-remote node | tac) + +echo +if [[ -z "$latest_signed" ]]; then + echo "No GPG-signed versions found in recent releases" + exit 1 +fi + +echo "Latest Node.js version with GPG signature: $latest_signed" +echo + +file="$repo_root/core/generate/mise_step_builder.go" +if [[ ! -f "$file" ]]; then + echo "Error: $file not found" >&2 + exit 1 +fi + +# Get current value +current=$(awk -F'"' '/NODE_VERIFY_BEFORE[[:space:]]*=/{print $2; exit}' "$file") +echo "Current NODE_VERIFY_BEFORE: $current" +echo "New NODE_VERIFY_BEFORE: $latest_signed" + +if [[ "$current" == "$latest_signed" ]]; then + echo "NODE_VERIFY_BEFORE already set to $latest_signed. Nothing to do." + exit 0 +fi + +branch="chore/update-node-verify-before-$latest_signed" +if git show-ref --verify --quiet "refs/heads/$branch"; then + echo "Local branch $branch already exists." >&2 + exit 1 +fi + +git switch -c "$branch" + +# Replace the NODE_VERIFY_BEFORE line with the new version string +perl -i -pe 's/^(\s*)NODE_VERIFY_BEFORE\s*=.*$/\1NODE_VERIFY_BEFORE = "'"$latest_signed"'"/' "$file" + +new="$(awk -F'"' '/NODE_VERIFY_BEFORE[[:space:]]*=/{print $2; exit}' "$file")" +if [[ "$new" != "$latest_signed" ]]; then + echo "Error: failed to update NODE_VERIFY_BEFORE in $file" >&2 + exit 1 +fi + +mise run check + +# If nothing changed, stop early +if git diff --quiet -- "$file"; then + echo "NODE_VERIFY_BEFORE already set to $latest_signed. Nothing to do." + git switch main + git branch -D "$branch" + exit 0 +fi + +git add -- "$file" +git commit -m "chore: update NODE_VERIFY_BEFORE to $latest_signed" + +gh pr create \ + --title "chore: update NODE_VERIFY_BEFORE to $latest_signed" \ + --body "Update NODE_VERIFY_BEFORE constant to the latest Node.js version with GPG signature available ($latest_signed)." diff --git a/core/generate/mise_step_builder.go b/core/generate/mise_step_builder.go index 3f60b892d..c2a56c466 100644 --- a/core/generate/mise_step_builder.go +++ b/core/generate/mise_step_builder.go @@ -8,6 +8,7 @@ import ( "sort" "strings" + semver "github.com/Masterminds/semver/v3" a "github.com/railwayapp/railpack/core/app" "github.com/railwayapp/railpack/core/mise" "github.com/railwayapp/railpack/core/plan" @@ -18,8 +19,36 @@ const ( MisePackageStepName = "packages:mise" // System-level config at /etc/mise/config.toml is auto-trusted by mise MiseInstallCommand = "mise install" + // NODE_VERIFY_BEFORE is the last Node.js version known to have GPG signatures available. + // When new Node.js versions are released, they may not have GPG keys immediately available + // for signature verification. This constant tracks the latest version that has been verified + // to have GPG signatures. Versions after this will have MISE_NODE_VERIFY=false to prevent + // build failures. + // + // To update this constant when new versions get GPG keys, run: + // mise run update-latest-signed-node + // + // See: https://github.com/railwayapp/railpack/issues/207 + NODE_VERIFY_BEFORE = "22.11.0" ) +// nodeVersionRequiresVerifyDisabled checks if a Node.js version needs GPG verification disabled. +// Newer versions released without GPG keys yet will return true. +// This prevents build failures while maintaining security for older, signed versions. +func nodeVersionRequiresVerifyDisabled(version string) bool { + nodeVer, err := semver.NewVersion(version) + if err != nil { + return false + } + + cutoffVer, err := semver.NewVersion(NODE_VERIFY_BEFORE) + if err != nil { + return false + } + + return nodeVer.GreaterThan(cutoffVer) +} + // represents a app-local mise package type MisePackageInfo struct { Version string @@ -240,15 +269,32 @@ func (b *MiseStepBuilder) Build(p *plan.BuildPlan, options *BuildStepOptions) er if len(b.MisePackages) > 0 { step.AddCommands([]plan.Command{plan.NewPathCommand("/mise/shims")}) + + // Build packages to install map first so we can check Node version + packagesToInstall := make(map[string]string) + for _, pkg := range b.MisePackages { + resolved, ok := options.ResolvedPackages[pkg.Name] + + if ok && resolved.ResolvedVersion != nil && !b.Resolver.Get(pkg.Name).SkipMiseInstall { + packagesToInstall[pkg.Name] = *resolved.ResolvedVersion + } + } + + // Determine whether to disable Node verification based on version + nodeVerify := "true" + if nodeVersion, hasNode := packagesToInstall["node"]; hasNode { + if nodeVersionRequiresVerifyDisabled(nodeVersion) { + nodeVerify = "false" + } + } + maps.Copy(step.Variables, map[string]string{ "MISE_DATA_DIR": "/mise", "MISE_CONFIG_DIR": "/mise", "MISE_CACHE_DIR": "/mise/cache", "MISE_SHIMS_DIR": "/mise/shims", "MISE_INSTALLS_DIR": "/mise/installs", - // Don't verify the asset because recently released versions don't have a public key to verify against - // https://github.com/railwayapp/railpack/issues/207 - "MISE_NODE_VERIFY": "false", + "MISE_NODE_VERIFY": nodeVerify, // Enforces HTTPS and stricter security "MISE_PARANOID": "1", }) @@ -266,16 +312,6 @@ func (b *MiseStepBuilder) Build(p *plan.BuildPlan, options *BuildStepOptions) er }) } - // Setup mise commands - packagesToInstall := make(map[string]string) - for _, pkg := range b.MisePackages { - resolved, ok := options.ResolvedPackages[pkg.Name] - - if ok && resolved.ResolvedVersion != nil && !b.Resolver.Get(pkg.Name).SkipMiseInstall { - packagesToInstall[pkg.Name] = *resolved.ResolvedVersion - } - } - miseToml, err := mise.GenerateMiseToml(packagesToInstall) if err != nil { return fmt.Errorf("failed to generate mise.toml: %w", err) diff --git a/docs/src/content/docs/languages/node.md b/docs/src/content/docs/languages/node.md index e58398027..3f4813114 100644 --- a/docs/src/content/docs/languages/node.md +++ b/docs/src/content/docs/languages/node.md @@ -41,6 +41,32 @@ When Node.js isn't required in the final image but is needed during installation (for native modules), a basic Node.js version will be installed from apt packages. +## Security + +### GPG Verification + +Railpack uses [mise](https://mise.jdx.dev) to install Node.js versions, which +supports GPG signature verification for downloaded binaries. However, when new +Node.js versions are released, GPG signatures may not be immediately available. + +To prevent build failures for newly released Node.js versions, Railpack +conditionally disables GPG verification (`MISE_NODE_VERIFY=false`) for versions +newer than a tracked cutoff version. Once a version has GPG signatures +available, the cutoff is updated. + +**Current behavior:** + +- Node.js versions ≤ 22.11.0: GPG verification **enabled** (secure) +- Node.js versions > 22.11.0: GPG verification **disabled** (allows new + releases) + +All downloads still use HTTPS (`MISE_PARANOID=1`), providing transport security +even when GPG verification is disabled. + +The cutoff version is updated periodically as new Node.js versions receive GPG +signatures. See [issue +#207](https://github.com/railwayapp/railpack/issues/207) for more details. + ## Runtime Variables These variables are available at runtime: