From d8a4735e9bdf01afaaff9088c86adf1378892246 Mon Sep 17 00:00:00 2001 From: Corey Butler <770982+coreybutler@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:48:26 -0500 Subject: [PATCH] fix: enforce version firewall and PE on nvm use ActivateVersion calls acl.IsAllowedVersion and compares node.exe FileVersion to the folder so policy and swap attacks fail at use. Signed-off-by: Corey Butler <770982+coreybutler@users.noreply.github.com> --- src/installer/activation.go | 11 +++ src/installer/verification.go | 149 +++++++++++++++++++++++++++++ src/installer/verification_test.go | 45 +++++++++ 3 files changed, 205 insertions(+) diff --git a/src/installer/activation.go b/src/installer/activation.go index 2952db7..d84c52d 100644 --- a/src/installer/activation.go +++ b/src/installer/activation.go @@ -1,6 +1,7 @@ package installer import ( + "common/acl" "common/settings" "common/system" "common/verifycache" @@ -25,6 +26,16 @@ func ActivateVersion(version string) error { return fmt.Errorf("version must not be empty") } + if allowed, err := acl.IsAllowedVersion(version); !allowed { + if err != nil { + return fmt.Errorf("NVM4411: %w", err) + } + return fmt.Errorf("NVM4411: v%s is blocked by version firewall", version) + } + if err := verifyNodeBinaryMatchesVersion(getRoot(version), version); err != nil { + return err + } + lastVersion, err := stringSetting("active_version") if err != nil { return err diff --git a/src/installer/verification.go b/src/installer/verification.go index b5e1016..197c851 100644 --- a/src/installer/verification.go +++ b/src/installer/verification.go @@ -129,3 +129,152 @@ func peCompanyName(exePath, fallback string) string { } return company } + +const vsFixedFileInfoSignature = 0xFEEF04BD + +type vsFixedFileInfo struct { + Signature uint32 + StrucVersion uint32 + FileVersionMS uint32 + FileVersionLS uint32 + ProductVersionMS uint32 + ProductVersionLS uint32 + FileFlagsMask uint32 + FileFlags uint32 + FileOS uint32 + FileType uint32 + FileSubtype uint32 + FileDateMS uint32 + FileDateLS uint32 +} + +type peCoreVersion struct { + major uint32 + minor uint32 + patch uint32 +} + +func parseCoreVersion(raw string) (peCoreVersion, bool) { + s := strings.TrimSpace(raw) + if len(s) >= 1 && (s[0] == 'v' || s[0] == 'V') { + s = s[1:] + } + if s == "" { + return peCoreVersion{}, false + } + if i := strings.IndexAny(s, "-+"); i >= 0 { + s = s[:i] + } + parts := strings.Split(s, ".") + if len(parts) == 0 || parts[0] == "" { + return peCoreVersion{}, false + } + major, err := parseVersionPart(parts[0]) + if err != nil { + return peCoreVersion{}, false + } + minor := uint32(0) + patch := uint32(0) + if len(parts) > 1 { + minor, err = parseVersionPart(parts[1]) + if err != nil { + return peCoreVersion{}, false + } + } + if len(parts) > 2 { + patch, err = parseVersionPart(parts[2]) + if err != nil { + return peCoreVersion{}, false + } + } + return peCoreVersion{major: major, minor: minor, patch: patch}, true +} + +func parseVersionPart(raw string) (uint32, error) { + if raw == "" { + return 0, fmt.Errorf("empty") + } + var n uint64 + for _, c := range raw { + if c < '0' || c > '9' { + return 0, fmt.Errorf("not a number") + } + n = n*10 + uint64(c-'0') + if n > 0xffffffff { + return 0, fmt.Errorf("overflow") + } + } + return uint32(n), nil +} + +func coreVersionEqual(file peCoreVersion, expected string) bool { + folder, ok := parseCoreVersion(expected) + if !ok { + return false + } + return file.major == folder.major && file.minor == folder.minor && file.patch == folder.patch +} + +func peFileVersion(exePath string) (peCoreVersion, error) { + vdll := windows.NewLazySystemDLL("version.dll") + getInfoSize := vdll.NewProc("GetFileVersionInfoSizeW") + getInfo := vdll.NewProc("GetFileVersionInfoW") + queryValue := vdll.NewProc("VerQueryValueW") + + path, err := windows.UTF16PtrFromString(exePath) + if err != nil { + return peCoreVersion{}, err + } + + size, _, _ := getInfoSize.Call(uintptr(unsafe.Pointer(path)), 0) + if size == 0 { + return peCoreVersion{}, fmt.Errorf("VERSIONINFO missing") + } + + buf := make([]byte, size) + ret, _, _ := getInfo.Call(uintptr(unsafe.Pointer(path)), 0, size, uintptr(unsafe.Pointer(&buf[0]))) + if ret == 0 { + return peCoreVersion{}, fmt.Errorf("VERSIONINFO missing") + } + + rootBlock, err := windows.UTF16PtrFromString(`\`) + if err != nil { + return peCoreVersion{}, err + } + var infoPtr uintptr + var infoLen uint32 + ret, _, _ = queryValue.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(rootBlock)), uintptr(unsafe.Pointer(&infoPtr)), uintptr(unsafe.Pointer(&infoLen))) + if ret == 0 || infoPtr == 0 || infoLen < uint32(unsafe.Sizeof(vsFixedFileInfo{})) { + return peCoreVersion{}, fmt.Errorf("VERSIONINFO missing") + } + + info := (*vsFixedFileInfo)(unsafe.Pointer(infoPtr)) + if info.Signature != vsFixedFileInfoSignature { + return peCoreVersion{}, fmt.Errorf("VERSIONINFO missing") + } + + return peCoreVersion{ + major: info.FileVersionMS >> 16, + minor: info.FileVersionMS & 0xffff, + patch: info.FileVersionLS >> 16, + }, nil +} + +func verifyNodeBinaryMatchesVersion(versionDir, version string) error { + exePath := filepath.Join(versionDir, "node.exe") + if _, err := os.Stat(exePath); err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("NVM4314: unable to inspect %s: %w", exePath, err) + } + + file, err := peFileVersion(exePath) + if err != nil { + return fmt.Errorf("NVM4314: unable to read FileVersion from %s: %w", exePath, err) + } + if !coreVersionEqual(file, version) { + return fmt.Errorf("NVM4314: node.exe FileVersion %d.%d.%d does not match folder v%s", file.major, file.minor, file.patch, version) + } + return nil +} diff --git a/src/installer/verification_test.go b/src/installer/verification_test.go index fa4b79a..cb2e179 100644 --- a/src/installer/verification_test.go +++ b/src/installer/verification_test.go @@ -133,3 +133,48 @@ func TestVerifyNodeSHASUMFixtureRegression(t *testing.T) { t.Fatal("verifyNodeSHASUM(fixture) = false, want true") } } + +func TestParseCoreVersion(t *testing.T) { + got, ok := parseCoreVersion("v24.0.0") + if !ok || got != (peCoreVersion{major: 24, minor: 0, patch: 0}) { + t.Fatalf("parseCoreVersion(v24.0.0) = %#v ok=%v", got, ok) + } + got, ok = parseCoreVersion("24.0.0-nightly20240101") + if !ok || got != (peCoreVersion{major: 24, minor: 0, patch: 0}) { + t.Fatalf("parseCoreVersion(prerelease) = %#v ok=%v", got, ok) + } + if _, ok = parseCoreVersion(""); ok { + t.Fatal("parseCoreVersion(empty) ok, want false") + } +} + +func TestCoreVersionEqualCatchesSwap(t *testing.T) { + fileV10 := peCoreVersion{major: 10, minor: 0, patch: 0} + if coreVersionEqual(fileV10, "24.0.0") { + t.Fatal("Node 10 FileVersion must not match folder 24.0.0") + } + if !coreVersionEqual(fileV10, "10.0.0") { + t.Fatal("Node 10 FileVersion must match folder 10.0.0") + } + if !coreVersionEqual(peCoreVersion{major: 24, minor: 1, patch: 0}, "24.1.0-rc.1") { + t.Fatal("prerelease folder must compare on major.minor.patch") + } +} + +func TestVerifyNodeBinaryMatchesVersionSkipsMissingExe(t *testing.T) { + dir := t.TempDir() + if err := verifyNodeBinaryMatchesVersion(dir, "24.0.0"); err != nil { + t.Fatalf("missing node.exe should skip: %v", err) + } +} + +func TestVerifyNodeBinaryMatchesVersionRejectsNonPE(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "node.exe"), []byte("not a pe"), 0o644); err != nil { + t.Fatalf("WriteFile(node.exe) error = %v", err) + } + err := verifyNodeBinaryMatchesVersion(dir, "24.0.0") + if err == nil || !strings.Contains(err.Error(), "NVM4314") { + t.Fatalf("non-PE node.exe error = %v, want NVM4314", err) + } +}