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
5 changes: 3 additions & 2 deletions verifycache/script_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ func SignVersionScripts(versionDir string) error {
}

// SignScript force-resigns one delegated .cmd/.bat/.js launcher, bypassing the
// TrustedModules gate. Used after the user accepts a trust prompt so VerifyCache
// can be refreshed before the next launch retry.
// TrustedModules gate. In-process callers (nvm firewall trust module) use this
// directly. The --sign-script CLI flag is gated separately: signed proxy parent
// plus an inherited pipe. Do not treat argv as a trust source.
func SignScript(scriptPath string) error {
scriptPath = strings.TrimSpace(scriptPath)
if scriptPath == "" {
Expand Down
216 changes: 216 additions & 0 deletions verifycache/sign_script_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
//go:build windows

package verifycache

import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"unsafe"

"common/settings"
"common/verify"

"golang.org/x/sys/windows"
)

const maxSignScriptPathBytes = 32 * 1024

var (
parentPIDFn = func() uint32 { return uint32(os.Getppid()) }
stdinPipeServerPIDFn = stdinPipeServerPID
verifyParentPublisherFn = verifyParentPublisher

procGetNamedPipeServerProcessId = windows.NewLazySystemDLL("kernel32.dll").NewProc("GetNamedPipeServerProcessId")
)

func verifyParentPublisher(parent string) error {
return verify.VerifySamePublisherAsExecutable(parent)
}

func stdinPipeServerPID() (uint32, bool) {
h := windows.Handle(os.Stdin.Fd())
ft, err := windows.GetFileType(h)
if err != nil || ft != windows.FILE_TYPE_PIPE {
return 0, false
}
var pid uint32
r1, _, _ := procGetNamedPipeServerProcessId.Call(uintptr(h), uintptr(unsafe.Pointer(&pid)))
if r1 == 0 || pid == 0 {
return 0, false
}
return pid, true
}

// AuthorizeSignScriptFromParent is the --sign-script gate. The caller must be
// nvm.exe spawned by a signed proxy image, with the path on an anonymous pipe
// whose server PID is that parent. argv is not a trust source.
func AuthorizeSignScriptFromParent() error {
dataRoot, err := dataRootFromSettings()
if err != nil {
return fmt.Errorf("sign-script refused: %w", err)
}
parent := parentImagePathFn()
if !parentIsNvmProxy(parent, dataRoot) {
return fmt.Errorf("sign-script refused: parent is not proxy")
}
if err := verifyParentPublisherFn(parent); err != nil {
return fmt.Errorf("sign-script refused: %w", err)
}
pipePID, ok := stdinPipeServerPIDFn()
if !ok {
return fmt.Errorf("sign-script refused: missing proxy pipe")
}
if pipePID != parentPIDFn() {
return fmt.Errorf("sign-script refused: pipe is not from parent")
}
return nil
}

// ReadSignScriptPath reads one launcher path from the proxy pipe. Extra
// command-line arguments are ignored by design; this is the only path source.
func ReadSignScriptPath(r io.Reader) (string, error) {
limited := io.LimitReader(r, maxSignScriptPathBytes+1)
raw, err := io.ReadAll(limited)
if err != nil {
return "", fmt.Errorf("sign-script refused: %w", err)
}
if len(raw) > maxSignScriptPathBytes {
return "", fmt.Errorf("sign-script refused: path too long")
}
scriptPath := strings.TrimSpace(string(raw))
if scriptPath == "" {
return "", fmt.Errorf("sign-script refused: empty path")
}
if strings.ContainsAny(scriptPath, "\x00\n\r") {
return "", fmt.Errorf("sign-script refused: invalid path")
}
return scriptPath, nil
}

// IsDelegatedLauncherPath is true for .cmd/.bat/.js files NVM proxy would
// launch: under the installs root, Yarn Classic global bin, pnpm home bin,
// or the roaming npm prefix.
func IsDelegatedLauncherPath(scriptPath string) bool {
scriptPath = filepath.Clean(strings.TrimSpace(scriptPath))
if scriptPath == "" || scriptPath == "." {
return false
}
ext := strings.ToLower(filepath.Ext(scriptPath))
if ext != ".cmd" && ext != ".bat" && ext != ".js" {
return false
}
installRoot := filepath.Clean(strings.TrimSpace(settings.Global().Root))
if installRoot != "" && installRoot != "." && pathUnderDir(scriptPath, installRoot) {
return true
}
if dir := yarnClassicGlobalBinDir(); dir != "" && pathUnderDir(scriptPath, dir) {
return true
}
if dir := pnpmGlobalBinDir(); dir != "" && pathUnderDir(scriptPath, dir) {
return true
}
if dir := npmRoamingBinDir(); dir != "" && pathUnderDir(scriptPath, dir) {
return true
}
return false
}

func parentIsNvmProxy(parent, dataRoot string) bool {
parent = filepath.Clean(strings.TrimSpace(parent))
dataRoot = filepath.Clean(strings.TrimSpace(dataRoot))
if parent == "" || dataRoot == "" || dataRoot == "." {
return false
}
proxy := filepath.Join(dataRoot, "proxy.exe")
if strings.EqualFold(parent, proxy) {
return true
}
shim := filepath.Join(dataRoot, ".shim")
if !pathUnderDir(parent, shim) {
return false
}
if strings.EqualFold(filepath.Base(parent), "node.exe") {
return false
}
if !strings.EqualFold(filepath.Ext(parent), ".exe") {
return false
}
return filesHaveSameContents(parent, proxy)
}

func yarnClassicGlobalBinDir() string {
local := strings.TrimSpace(os.Getenv("LOCALAPPDATA"))
if local == "" {
return ""
}
return filepath.Join(local, "Yarn", "bin")
}

func pnpmGlobalBinDir() string {
home := strings.TrimSpace(os.Getenv("PNPM_HOME"))
if home == "" {
return ""
}
return filepath.Join(home, "bin")
}

func npmRoamingBinDir() string {
appdata, err := os.UserConfigDir()
if err != nil {
return ""
}
appdata = strings.TrimSpace(appdata)
if appdata == "" {
return ""
}
return filepath.Join(appdata, "npm")
}

func filesHaveSameContents(leftPath, rightPath string) bool {
left, err := os.Open(leftPath)
if err != nil {
return false
}
defer left.Close()
right, err := os.Open(rightPath)
if err != nil {
return false
}
defer right.Close()
leftInfo, err := left.Stat()
if err != nil {
return false
}
rightInfo, err := right.Stat()
if err != nil {
return false
}
if leftInfo.Size() != rightInfo.Size() {
return false
}
bufL := make([]byte, 8192)
bufR := make([]byte, 8192)
for {
nL, errL := left.Read(bufL)
nR, errR := right.Read(bufR)
if nL != nR || !bytes.Equal(bufL[:nL], bufR[:nR]) {
return false
}
if errL == io.EOF && errR == io.EOF {
return true
}
if errL != nil && errL != io.EOF {
return false
}
if errR != nil && errR != io.EOF {
return false
}
if (errL == io.EOF) != (errR == io.EOF) {
return false
}
}
}
158 changes: 158 additions & 0 deletions verifycache/sign_script_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//go:build windows

package verifycache

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

func TestParentIsNvmProxy(t *testing.T) {
dataRoot := t.TempDir()
proxy := filepath.Join(dataRoot, "proxy.exe")
shim := filepath.Join(dataRoot, ".shim")
if err := os.MkdirAll(shim, 0o755); err != nil {
t.Fatal(err)
}
payload := []byte("proxy-bytes")
if err := os.WriteFile(proxy, payload, 0o644); err != nil {
t.Fatal(err)
}
vue := filepath.Join(shim, "vue.exe")
if err := os.WriteFile(vue, payload, 0o644); err != nil {
t.Fatal(err)
}
node := filepath.Join(shim, "node.exe")
if err := os.WriteFile(node, []byte("node-shim"), 0o644); err != nil {
t.Fatal(err)
}
evil := filepath.Join(shim, "evil.exe")
if err := os.WriteFile(evil, []byte("not-proxy"), 0o644); err != nil {
t.Fatal(err)
}

if !parentIsNvmProxy(proxy, dataRoot) {
t.Fatal("canonical proxy.exe must match")
}
if !parentIsNvmProxy(vue, dataRoot) {
t.Fatal(".shim vue.exe with proxy bytes must match")
}
if parentIsNvmProxy(node, dataRoot) {
t.Fatal(".shim node.exe must not match")
}
if parentIsNvmProxy(evil, dataRoot) {
t.Fatal("mismatched .shim exe must not match")
}
if parentIsNvmProxy(`C:\Windows\System32\cmd.exe`, dataRoot) {
t.Fatal("cmd.exe must not match")
}
if parentIsNvmProxy("", dataRoot) {
t.Fatal("empty parent must not match")
}
}

func TestAuthorizeSignScriptFromParent(t *testing.T) {
dataRoot := setupVerifyCacheTestProfile(t)
proxy := filepath.Join(dataRoot, "proxy.exe")
shim := filepath.Join(dataRoot, ".shim")
if err := os.MkdirAll(shim, 0o755); err != nil {
t.Fatal(err)
}
payload := []byte("proxy-bytes")
if err := os.WriteFile(proxy, payload, 0o644); err != nil {
t.Fatal(err)
}
vue := filepath.Join(shim, "vue.exe")
if err := os.WriteFile(vue, payload, 0o644); err != nil {
t.Fatal(err)
}

origParent := parentImagePathFn
origPID := parentPIDFn
origPipe := stdinPipeServerPIDFn
origPub := verifyParentPublisherFn
t.Cleanup(func() {
parentImagePathFn = origParent
parentPIDFn = origPID
stdinPipeServerPIDFn = origPipe
verifyParentPublisherFn = origPub
})

parentPIDFn = func() uint32 { return 4242 }
stdinPipeServerPIDFn = func() (uint32, bool) { return 4242, true }
verifyParentPublisherFn = func(string) error { return nil }
parentImagePathFn = func() string { return vue }

if err := AuthorizeSignScriptFromParent(); err != nil {
t.Fatalf("signed proxy parent + matching pipe: %v", err)
}

parentImagePathFn = func() string { return `C:\Windows\System32\cmd.exe` }
if err := AuthorizeSignScriptFromParent(); err == nil {
t.Fatal("cmd.exe parent must fail")
}

parentImagePathFn = func() string { return vue }
stdinPipeServerPIDFn = func() (uint32, bool) { return 0, false }
if err := AuthorizeSignScriptFromParent(); err == nil {
t.Fatal("console stdin must fail")
}

stdinPipeServerPIDFn = func() (uint32, bool) { return 99, true }
if err := AuthorizeSignScriptFromParent(); err == nil {
t.Fatal("pipe from another PID must fail")
}

stdinPipeServerPIDFn = func() (uint32, bool) { return 4242, true }
verifyParentPublisherFn = func(string) error { return os.ErrPermission }
if err := AuthorizeSignScriptFromParent(); err == nil {
t.Fatal("publisher mismatch must fail")
}
}

func TestReadSignScriptPath(t *testing.T) {
got, err := ReadSignScriptPath(strings.NewReader(" C:\\nvm\\installs\\v24\\vue.cmd \n"))
if err != nil {
t.Fatal(err)
}
if got != `C:\nvm\installs\v24\vue.cmd` {
t.Fatalf("got %q", got)
}
if _, err := ReadSignScriptPath(strings.NewReader("")); err == nil {
t.Fatal("empty path must fail")
}
if _, err := ReadSignScriptPath(strings.NewReader("a\nb")); err == nil {
t.Fatal("newline in path must fail")
}
if _, err := ReadSignScriptPath(bytes.NewReader(bytes.Repeat([]byte("x"), maxSignScriptPathBytes+1))); err == nil {
t.Fatal("oversize path must fail")
}
}

func TestIsDelegatedLauncherPath(t *testing.T) {
dataRoot := setupVerifyCacheTestProfile(t)
versionDir := filepath.Join(dataRoot, "installs", "v24.0.0")
ok := filepath.Join(versionDir, "vue.cmd")
nested := filepath.Join(versionDir, "node_modules", "npm", "bin", "npm-cli.js")
if !IsDelegatedLauncherPath(ok) {
t.Fatal("version-dir cmd must pass")
}
if !IsDelegatedLauncherPath(nested) {
t.Fatal("nested js under installs must pass")
}
if IsDelegatedLauncherPath(`C:\Temp\evil.cmd`) {
t.Fatal("temp cmd must fail")
}
if IsDelegatedLauncherPath(filepath.Join(versionDir, "vue.exe")) {
t.Fatal("exe must fail")
}

t.Setenv("LOCALAPPDATA", filepath.Join(dataRoot, "Local"))
yarn := filepath.Join(dataRoot, "Local", "Yarn", "bin", "vue.cmd")
if !IsDelegatedLauncherPath(yarn) {
t.Fatal("Yarn Classic bin must pass")
}
}
Loading