Skip to content

Commit 9b2e2e4

Browse files
committed
feat(firewall): dual lists, 401/4401, npm identity refresh
Align NVM44xx codes so HTTP 401 maps to 4401 and policy mutate is 4410; wire refresh-npm-identity helper for proxy.
1 parent 2d66146 commit 9b2e2e4

23 files changed

Lines changed: 1794 additions & 168 deletions

‎src/bootstrap/activation.go‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,19 @@ var verifyActivationNode = func(path string) error {
2121

2222
var logActivationBlocked = func(versionDir, nodePath, failureKind, detail string) {
2323
log.ErrorStructured("node.security.activation_blocked", log.StructuredPayload{
24-
"action": "activation_blocked",
25-
"detail": detail,
26-
"failure_kind": failureKind,
27-
"node_path": nodePath,
28-
"source": "link-mode",
29-
"version_path": versionDir,
24+
"action": "activation_blocked",
25+
"detail": detail,
26+
"failure_kind": failureKind,
27+
"node_path": nodePath,
28+
"source": "link-mode",
29+
"version_path": versionDir,
30+
"user": log.Actor(),
31+
"sid": log.ActorSid(),
32+
"hostname": log.Hostname(),
33+
"correlation_id": log.NewCorrelationID(),
34+
"parent_process": log.ParentProcess(),
35+
"project_name": log.ProjectName(),
36+
"project_path": log.ProjectPath(),
3037
}, activationBlockedEventCode)
3138
}
3239

‎src/bootstrap/init_test.go‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,8 +368,16 @@ func TestEnsureUserProfileInitializedCleansLegacyPayload(t *testing.T) {
368368
createRegistryKey(t, legacyShellRegistrationBase+`\shell\open\command`, map[string]string{
369369
"": `"` + legacyNvmExe + `" "%1"`,
370370
})
371+
nodejsPath := filepath.Join(root, ".nodejs")
371372
createRegistryKey(t, `Environment`, map[string]string{
372-
"NVM_HOME": root,
373+
"NVM_HOME": root,
374+
"NVM_SYMLINK": nodejsPath,
375+
"Path": strings.Join([]string{
376+
`C:\Windows\system32`,
377+
root,
378+
nodejsPath,
379+
`C:\Tools`,
380+
}, ";"),
373381
})
374382

375383
if err := EnsureUserProfileInitialized(); err != nil {
@@ -386,6 +394,11 @@ func TestEnsureUserProfileInitializedCleansLegacyPayload(t *testing.T) {
386394
assertRegistryKeyMissing(t, legacySyncAppPathKey)
387395
assertRegistryKeyMissing(t, legacyShellRegistrationBase+`\shell\open\command`)
388396
assertRegistryValueMissing(t, `Environment`, "NVM_HOME")
397+
assertRegistryValueMissing(t, `Environment`, "NVM_SYMLINK")
398+
assertUserPathContains(t, `C:\Windows\system32`)
399+
assertUserPathContains(t, nodejsPath)
400+
assertUserPathContains(t, `C:\Tools`)
401+
assertUserPathMissing(t, root)
389402
if len(deletedTasks) != 1 || deletedTasks[0] != "NVM Sync" {
390403
t.Fatalf("deleted tasks = %#v, want [\"NVM Sync\"]", deletedTasks)
391404
}
@@ -687,6 +700,43 @@ func assertRegistryValueMissing(t *testing.T, keyPath, valueName string) {
687700
}
688701
}
689702

703+
func readUserPath(t *testing.T) string {
704+
t.Helper()
705+
key, err := winreg.OpenKey(winreg.CURRENT_USER, `Environment`, winreg.QUERY_VALUE)
706+
if err != nil {
707+
t.Fatalf("OpenKey(Environment) error = %v", err)
708+
}
709+
defer key.Close()
710+
value, _, err := key.GetStringValue("Path")
711+
if err != nil {
712+
t.Fatalf("GetStringValue(Path) error = %v", err)
713+
}
714+
return value
715+
}
716+
717+
func assertUserPathContains(t *testing.T, segment string) {
718+
t.Helper()
719+
path := readUserPath(t)
720+
normSeg := normalizePathMatch(segment)
721+
for _, part := range strings.Split(path, ";") {
722+
if normalizePathMatch(part) == normSeg {
723+
return
724+
}
725+
}
726+
t.Fatalf("user Path %q missing segment %q", path, segment)
727+
}
728+
729+
func assertUserPathMissing(t *testing.T, segment string) {
730+
t.Helper()
731+
path := readUserPath(t)
732+
normSeg := normalizePathMatch(segment)
733+
for _, part := range strings.Split(path, ";") {
734+
if normalizePathMatch(part) == normSeg {
735+
t.Fatalf("user Path %q still contains segment %q", path, segment)
736+
}
737+
}
738+
}
739+
690740
func createProgramSyncSeed(t *testing.T, relPath string, content []byte) string {
691741
t.Helper()
692742

‎src/bootstrap/migration.go‎

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"strings"
1010
"syscall"
1111

12+
"nvm/legacy"
13+
1214
winreg "golang.org/x/sys/windows/registry"
1315
)
1416

@@ -88,24 +90,127 @@ func removeLegacyCurrentUserEnv(dataRoot string) error {
8890
}
8991
defer key.Close()
9092

91-
nvmHome, _, valueErr := key.GetStringValue("NVM_HOME")
92-
if valueErr != nil {
93-
if valueErr == winreg.ErrNotExist {
94-
return nil
93+
changed := false
94+
95+
nvmHome, _, homeErr := key.GetStringValue("NVM_HOME")
96+
if homeErr != nil && homeErr != winreg.ErrNotExist {
97+
return fmt.Errorf("failed to read current-user NVM_HOME: %w", homeErr)
98+
}
99+
nvmSymlink, _, linkErr := key.GetStringValue("NVM_SYMLINK")
100+
if linkErr != nil && linkErr != winreg.ErrNotExist {
101+
return fmt.Errorf("failed to read current-user NVM_SYMLINK: %w", linkErr)
102+
}
103+
104+
forceRemove := map[string]bool{}
105+
if homeErr == nil && (valueReferencesPath(nvmHome, dataRoot) || looksLikeAuthorNvmHome(nvmHome)) {
106+
if err := key.DeleteValue("NVM_HOME"); err != nil && err != winreg.ErrNotExist {
107+
return fmt.Errorf("failed to delete current-user NVM_HOME: %w", err)
95108
}
96-
return fmt.Errorf("failed to read current-user NVM_HOME: %w", valueErr)
109+
forceRemove[normalizePathMatch(nvmHome)] = true
110+
forceRemove[strings.ToLower("%NVM_HOME%")] = true
111+
changed = true
97112
}
98-
if !valueReferencesPath(nvmHome, dataRoot) {
99-
return nil
113+
if linkErr == nil && (valueReferencesPath(nvmSymlink, dataRoot) || looksLikeLegacyNvmSymlink(nvmSymlink)) {
114+
if err := key.DeleteValue("NVM_SYMLINK"); err != nil && err != winreg.ErrNotExist {
115+
return fmt.Errorf("failed to delete current-user NVM_SYMLINK: %w", err)
116+
}
117+
forceRemove[normalizePathMatch(nvmSymlink)] = true
118+
forceRemove[strings.ToLower("%NVM_SYMLINK%")] = true
119+
changed = true
100120
}
101121

102-
if err := key.DeleteValue("NVM_HOME"); err != nil && err != winreg.ErrNotExist {
103-
return fmt.Errorf("failed to delete current-user NVM_HOME: %w", err)
122+
userPath, _, pathErr := key.GetStringValue("Path")
123+
if pathErr != nil {
124+
if pathErr == winreg.ErrNotExist {
125+
if changed {
126+
legacy.BroadcastEnvironmentChange()
127+
}
128+
return nil
129+
}
130+
return fmt.Errorf("failed to read current-user Path: %w", pathErr)
104131
}
105132

133+
// Also strip community program-root PATH entries (keep .nodejs).
134+
cleaned := filterUserPath(userPath, dataRoot, forceRemove)
135+
if cleaned != userPath {
136+
if err := key.SetExpandStringValue("Path", cleaned); err != nil {
137+
return fmt.Errorf("failed to rewrite current-user Path: %w", err)
138+
}
139+
changed = true
140+
}
141+
if changed {
142+
legacy.BroadcastEnvironmentChange()
143+
}
106144
return nil
107145
}
108146

147+
// RemoveLegacyCurrentUserEnv clears leftover HKCU NVM_HOME/NVM_SYMLINK and community
148+
// program-root user PATH segments while keeping dataRoot\.nodejs. Used by MSI
149+
// impersonated install CA and first-launch bootstrap.
150+
func RemoveLegacyCurrentUserEnv(dataRoot string) error {
151+
return removeLegacyCurrentUserEnv(dataRoot)
152+
}
153+
154+
func looksLikeLegacyNvmSymlink(value string) bool {
155+
norm := normalizePathMatch(value)
156+
if norm == "" {
157+
return false
158+
}
159+
trimmed := strings.TrimSpace(value)
160+
return strings.EqualFold(norm, `c:\nodejs`) ||
161+
strings.EqualFold(trimmed, `%NVM_SYMLINK%`) ||
162+
strings.HasSuffix(norm, `\author software\nvm\.link`) ||
163+
strings.HasSuffix(norm, `\author software\nvm\.nodejs`)
164+
}
165+
166+
func looksLikeAuthorNvmHome(value string) bool {
167+
norm := normalizePathMatch(value)
168+
if norm == "" {
169+
return false
170+
}
171+
return strings.HasSuffix(norm, `\author software\nvm`)
172+
}
173+
174+
// filterUserPath drops legacy NVM segments and the community program root for dataRoot
175+
// while keeping dataRoot\.nodejs.
176+
func filterUserPath(userPath, dataRoot string, forceRemove map[string]bool) string {
177+
if forceRemove == nil {
178+
forceRemove = map[string]bool{}
179+
}
180+
dataNorm := normalizePathMatch(dataRoot)
181+
nodejsNorm := normalizePathMatch(filepath.Join(dataRoot, ".nodejs"))
182+
183+
segments := strings.Split(userPath, ";")
184+
kept := make([]string, 0, len(segments))
185+
for _, seg := range segments {
186+
trimmed := strings.TrimSpace(seg)
187+
if trimmed == "" {
188+
continue
189+
}
190+
norm := normalizePathMatch(trimmed)
191+
expanded := normalizePathMatch(os.ExpandEnv(trimmed))
192+
193+
// Keep .nodejs shim path even when NVM_SYMLINK pointed at it.
194+
if norm == nodejsNorm || expanded == nodejsNorm ||
195+
strings.HasSuffix(norm, `\author software\nvm\.nodejs`) ||
196+
strings.HasSuffix(expanded, `\author software\nvm\.nodejs`) {
197+
kept = append(kept, seg)
198+
continue
199+
}
200+
if forceRemove[norm] || forceRemove[expanded] {
201+
continue
202+
}
203+
// Drop community program root (data root itself).
204+
if (dataNorm != "" && (norm == dataNorm || expanded == dataNorm)) ||
205+
strings.HasSuffix(norm, `\author software\nvm`) ||
206+
strings.HasSuffix(expanded, `\author software\nvm`) {
207+
continue
208+
}
209+
kept = append(kept, seg)
210+
}
211+
return strings.Join(kept, ";")
212+
}
213+
109214
func removeLegacyPath(path string) error {
110215
if _, err := os.Lstat(path); err != nil {
111216
if os.IsNotExist(err) {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package bootstrap
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestFilterUserPath_KeepsNodejsDropsProgramRoot(t *testing.T) {
10+
root := `C:\Users\a\AppData\Local\Author Software\nvm`
11+
nodejs := filepath.Join(root, ".nodejs")
12+
in := strings.Join([]string{
13+
`C:\Windows\system32`,
14+
root,
15+
nodejs,
16+
`C:\Tools`,
17+
`%NVM_HOME%`,
18+
}, ";")
19+
force := map[string]bool{
20+
strings.ToLower("%NVM_HOME%"): true,
21+
normalizePathMatch(root): true,
22+
}
23+
24+
got := filterUserPath(in, root, force)
25+
26+
for _, keep := range []string{`C:\Windows\system32`, nodejs, `C:\Tools`} {
27+
if !pathHasSegment(got, keep) {
28+
t.Fatalf("expected keep %q in %q", keep, got)
29+
}
30+
}
31+
for _, drop := range []string{root, `%NVM_HOME%`} {
32+
if pathHasSegment(got, drop) {
33+
t.Fatalf("expected drop %q from %q", drop, got)
34+
}
35+
}
36+
}
37+
38+
func pathHasSegment(path, segment string) bool {
39+
want := normalizePathMatch(segment)
40+
for _, part := range strings.Split(path, ";") {
41+
if normalizePathMatch(part) == want {
42+
return true
43+
}
44+
}
45+
return false
46+
}
47+
48+
func TestLooksLikeLegacyNvmSymlink(t *testing.T) {
49+
if !looksLikeLegacyNvmSymlink(`C:\nodejs`) {
50+
t.Fatal("want classic C:\\nodejs")
51+
}
52+
if !looksLikeLegacyNvmSymlink(`C:\Users\a\AppData\Local\Author Software\nvm\.nodejs`) {
53+
t.Fatal("want Author Software .nodejs")
54+
}
55+
if looksLikeLegacyNvmSymlink(`D:\custom\node-link`) {
56+
t.Fatal("custom link must not match")
57+
}
58+
}

‎src/cmd/layout_warn.go‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,3 @@ func warnCommunityProgramRootIfNeeded() {
3232
_ = err
3333
}
3434
}
35-
36-
func communityEditionWatermark() string {
37-
if license.Edition() != "Community" {
38-
return ""
39-
}
40-
return "Community (per-user LocalAppData install; see nvm doctor)"
41-
}

0 commit comments

Comments
 (0)