/
githubmirror
/
hugo
Обзор
Документация
Войти
/
githubmirror
/
hugo
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
common/hexec/exec.go
557 строк
15 KB
Bjørn Erik Pedersen
common/hexec: Make NODE_PATH a fallback for ESM bare imports
13 май 2026, 23:00
13 май 2026, 23:00
ae7bf74
Код
Авторство
О чём код?
// Copyright 2026 The Hugo Authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package hexec import ( "bufio" "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "slices" "strings" "github.com/bep/logg" "github.com/gohugoio/hugo/common/hmaps" "github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/config/security" ) var WithDir = func(dir string) func(c *commandeer) { return func(c *commandeer) { c.dir = dir } } var WithContext = func(ctx context.Context) func(c *commandeer) { return func(c *commandeer) { c.ctx = ctx } } var WithStdout = func(w io.Writer) func(c *commandeer) { return func(c *commandeer) { c.stdout = w } } var WithStderr = func(w io.Writer) func(c *commandeer) { return func(c *commandeer) { c.stderr = w } } var WithStdin = func(r io.Reader) func(c *commandeer) { return func(c *commandeer) { c.stdin = r } } var WithEnviron = func(env []string) func(c *commandeer) { return func(c *commandeer) { setOrAppend := func(s string) { k1, _ := config.SplitEnvVar(s) var found bool for i, v := range c.env { k2, _ := config.SplitEnvVar(v) if k1 == k2 { found = true c.env[i] = s } } if !found { c.env = append(c.env, s) } } for _, s := range env { setOrAppend(s) } } } // New creates a new Exec using the provided security config. func New(cfg security.Config, workingDir string, log loggers.Logger) *Exec { var baseEnviron []string for _, v := range os.Environ() { k, _ := config.SplitEnvVar(v) if cfg.Exec.OsEnv.Accept(k) { baseEnviron = append(baseEnviron, v) } } return &Exec{ sc: cfg, workingDir: workingDir, infol: log.InfoCommand("exec"), baseEnviron: baseEnviron, nodeRunnerCache: hmaps.NewCache[string, func(arg ...any) (Runner, error)](), } } // IsNotFound reports whether this is an error about a binary not found. func IsNotFound(err error) bool { var notFoundErr *NotFoundError return errors.As(err, ¬FoundErr) } // Exec enforces a security policy for commands run via os/exec. type Exec struct { sc security.Config workingDir string infol logg.LevelLogger // os.Environ filtered by the Exec.OsEnviron whitelist filter. baseEnviron []string // Additional absolute paths to allow reading from in the Node.js permission model. nodeReadPaths []string nodeRunnerCache *hmaps.Cache[string, func(arg ...any) (Runner, error)] } // SetNodeReadPaths sets additional absolute paths to allow reading from // in the Node.js permission model (e.g. Hugo module cache directories). func (e *Exec) SetNodeReadPaths(paths []string) { e.nodeReadPaths = paths } func (e *Exec) New(name string, arg ...any) (Runner, error) { return e.new(name, "", arg...) } // New will fail if name is not allowed according to the configured security policy. // Else a configured Runner will be returned ready to be Run. func (e *Exec) new(name string, fullyQualifiedName string, arg ...any) (Runner, error) { if err := e.sc.CheckAllowedExec(name); err != nil { return nil, err } env := make([]string, len(e.baseEnviron)) copy(env, e.baseEnviron) cm := &commandeer{ name: name, fullyQualifiedName: fullyQualifiedName, env: env, } return cm.command(arg...) } type binaryLocation int func (b binaryLocation) String() string { switch b { case binaryLocationNodeModules: return "node_modules/.bin" case binaryLocationPath: return "PATH" } return "unknown" } const ( binaryLocationNodeModules binaryLocation = iota + 1 binaryLocationPath ) // Npx finds and runs a Node.js tool. The binary is located first in // WORKINGDIR/node_modules/.bin, then in PATH. The tool is always invoked via // "node [--permission <flags>] <script> <args>"; the --permission flags are // added when the Node.js permission model is enabled. func (e *Exec) Npx(name string, arg ...any) (Runner, error) { if err := e.sc.CheckAllowedExec(name); err != nil { return nil, err } if err := e.sc.CheckAllowedExec("node"); err != nil { // Legacy path: We replaced npx with node in v0.161.0, and anyone using these tools with a custom security.exec.allow list // would get an error when upgrading. To avoid this, check for npx as well. if err2 := e.sc.CheckAllowedExec("npx"); err2 != nil { return nil, err } } newRunner, err := e.nodeRunnerCache.GetOrCreate(name, func() (func(...any) (Runner, error), error) { var resolvedBin string var loc binaryLocation nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name) if p, err := exec.LookPath(nodeBinFilename); err == nil { resolvedBin = p loc = binaryLocationNodeModules } else if p, err := exec.LookPath(name); err == nil { resolvedBin = p loc = binaryLocationPath } else { return nil, &NotFoundError{name: name, method: "in PATH"} } scriptPath := resolveNodeBin(resolvedBin) e.infol.WithFields(logg.Fields{ logg.Field{Name: "location", Value: loc}, logg.Field{Name: "bin", Value: resolvedBin}, logg.Field{Name: "script", Value: scriptPath}, }).Logf("resolve %q", name) if scriptPath == "" { return nil, fmt.Errorf("binary %q is not a Node.js script", name) } return func(arg2 ...any) (Runner, error) { return e.newNode(name, scriptPath, arg2...) }, nil }) if err != nil { return nil, err } return newRunner(arg...) } // newNode runs a Node.js script via "node [--permission <flags>] <scriptPath> <args>". func (e *Exec) newNode(name, scriptPath string, arg ...any) (Runner, error) { var allArgs []any for _, pa := range e.nodePermissionArgs(name, scriptPath) { allArgs = append(allArgs, pa) } // Install an ESM resolver hook that makes NODE_PATH a fallback for failed // bare imports, so postcss.config.js / babel.config.js / etc. written in // ESM work when loaded from the Hugo module cache. See esmloader.mjs. allArgs = append(allArgs, nodeESMLoaderImportArg()) allArgs = append(allArgs, scriptPath) allArgs = append(allArgs, arg...) // When the script lives outside the working dir (a globally installed // tool), point NODE_PATH at the script's node_modules ancestor so Node's // resolver (and tools that honor it, e.g. tailwindcss v4) can locate the // tool's sibling packages. tailwindcss v4's CSS resolver treats NODE_PATH // as a single path, not a list, so we don't concatenate with the local // path here. For local installs the caller's NODE_PATH (set by // hugo.GetExecEnviron to <workDir>/node_modules) already covers the need. localNM := filepath.Join(e.workingDir, "node_modules") if p := nodeScriptReadPath(scriptPath); p != "" && p != localNM { allArgs = append(allArgs, WithEnviron([]string{"NODE_PATH=" + p})) } return e.New("node", allArgs...) } // nodePermissionArgs builds the Node.js --permission flags from the security config. func (e *Exec) nodePermissionArgs(name, scriptPath string) []string { perms := e.sc.Node.Permissions if !perms.IsEnabled() { return nil } args := []string{"--permission"} for _, p := range e.resolveNodePermPaths(perms.AllowRead) { args = append(args, "--allow-fs-read="+p) } for _, p := range e.nodeReadPaths { args = append(args, "--allow-fs-read="+p) } if p := nodeScriptReadPath(scriptPath); p != "" { args = append(args, "--allow-fs-read="+p) } for _, p := range e.resolveNodePermPaths(perms.AllowWrite) { args = append(args, "--allow-fs-write="+p) } var silenceSecurityWarnings bool if slices.Contains(perms.AllowAddons, name) { silenceSecurityWarnings = true args = append(args, "--allow-addons") } if slices.Contains(perms.AllowWorker, name) { silenceSecurityWarnings = true args = append(args, "--allow-worker") } if slices.Contains(perms.AllowChildProcess, name) { silenceSecurityWarnings = true args = append(args, "--allow-child-process") } if silenceSecurityWarnings { // There are no more fine grained way to do this, see https://github.com/nodejs/node/issues/59818 // If the process is configured to allow either workers or addons, Node will print warnings that's not very helpful. args = append(args, "--disable-warning=SecurityWarning") } return args } // resolveNodePermPaths resolves relative paths against the working directory. func (e *Exec) resolveNodePermPaths(paths []string) []string { resolved := make([]string, len(paths)) for i, p := range paths { switch { case p == "*": resolved[i] = "*" case filepath.IsAbs(p): resolved[i] = p default: resolved[i] = filepath.Join(e.workingDir, p) } } return resolved } const nodeModulesBinPath = "node_modules/.bin" // nodeScriptReadPath returns a path to add to the Node.js read allow-list so // a script can load its dependencies. For scripts inside a node_modules tree // it returns the nearest ancestor "node_modules" directory, so both nested // and hoisted deps are reachable. Otherwise the script's own directory. func nodeScriptReadPath(scriptPath string) string { if scriptPath == "" { return "" } dir := filepath.Dir(scriptPath) for { if filepath.Base(dir) == "node_modules" { return dir } parent := filepath.Dir(dir) if parent == dir { return filepath.Dir(scriptPath) } dir = parent } } // resolveNodeBin resolves a binary path to the underlying Node.js script. // Returns the path to the JS entry point, or "" if the binary is not a Node script. func resolveNodeBin(path string) string { // 1. If the file is a symlink, resolve it (macOS/Linux npm creates symlinks in node_modules/.bin). if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { if resolved, err := filepath.EvalSymlinks(path); err == nil { if hasJSExtension(resolved) || isNodeScript(resolved) { return resolved } } return "" } // 2. Check if the file itself is a Node script (e.g. globally installed with #!/usr/bin/env node). if isNodeScript(path) { return path } // 3. Try extracting JS entry point from an npm wrapper script (.cmd or shell). return extractNodeEntryPoint(path) } // nodeEntryPointRe matches a relative path in npm-generated wrapper scripts. // The entry may be a .js/.mjs/.cjs file or an extensionless Node shebang // script (e.g. postcss-cli 7's bin/postcss). Local installs reference the // entry via "..", global installs via "node_modules" (notably on Windows, // where npm does not symlink global binaries). // Examples: // // Local shell: "$basedir/../postcss-cli/index.js" // Local cmd: "%dp0%\..\postcss-cli\index.js" // Scoped: "$basedir/../@babel/cli/bin/babel.js" // No ext: "%dp0%\..\postcss-cli\bin\postcss" // Global shell: "$basedir/node_modules/postcss-cli/index.js" // Global cmd: "%dp0%\node_modules\postcss-cli\index.js" var nodeEntryPointRe = regexp.MustCompile(`[/\\]((?:\.\.|node_modules)[/\\][\w@][\w@./\\-]*)`) // extractNodeEntryPoint reads an npm wrapper script and extracts the Node // entry point path, validating that it's a JS file or a Node shebang script. func extractNodeEntryPoint(wrapperPath string) string { data, err := os.ReadFile(wrapperPath) if err != nil { return "" } m := nodeEntryPointRe.FindSubmatch(data) if m == nil { return "" } // Normalize backslashes from Windows .cmd wrappers. relPath := strings.ReplaceAll(string(m[1]), "\\", "/") resolved := filepath.Join(filepath.Dir(wrapperPath), relPath) if _, err := os.Stat(resolved); err != nil { return "" } if !hasJSExtension(resolved) && !isNodeScript(resolved) { return "" } return resolved } func hasJSExtension(path string) bool { switch filepath.Ext(path) { case ".js", ".mjs", ".cjs": return true } return false } // isNodeScript reports whether the file at path has a Node.js shebang. func isNodeScript(path string) bool { f, err := os.Open(path) if err != nil { return false } defer f.Close() r := bufio.NewReader(f) line, err := r.ReadString('\n') if err != nil && len(line) == 0 { return false } return strings.HasPrefix(line, "#!") && strings.Contains(line, "node") } // Sec returns the security policies this Exec is configured with. func (e *Exec) Sec() security.Config { return e.sc } type NotFoundError struct { name string method string } func (e *NotFoundError) Error() string { return fmt.Sprintf("binary with name %q not found %s", e.name, e.method) } // Runner wraps a *os.Cmd. type Runner interface { Run() error StdinPipe() (io.WriteCloser, error) } type cmdWrapper struct { name string c *exec.Cmd outerr *bytes.Buffer } var notFoundRe = regexp.MustCompile(`(?s)not found:|could not determine executable`) func (c *cmdWrapper) Run() error { err := c.c.Run() if err == nil { return nil } if notFoundRe.MatchString(c.outerr.String()) { return &NotFoundError{name: c.name, method: "in PATH"} } return fmt.Errorf("failed to execute binary %q with args %v: %s", c.name, c.c.Args[1:], c.outerr.String()) } func (c *cmdWrapper) StdinPipe() (io.WriteCloser, error) { return c.c.StdinPipe() } type commandeer struct { stdout io.Writer stderr io.Writer stdin io.Reader dir string ctx context.Context name string fullyQualifiedName string env []string } func (c *commandeer) command(arg ...any) (*cmdWrapper, error) { if c == nil { return nil, nil } var args []string for _, a := range arg { switch v := a.(type) { case string: args = append(args, v) case func(*commandeer): v(c) default: return nil, fmt.Errorf("invalid argument to command: %T", a) } } var bin string if c.fullyQualifiedName != "" { bin = c.fullyQualifiedName } else { var err error bin, err = exec.LookPath(c.name) if err != nil { return nil, &NotFoundError{ name: c.name, method: "in PATH", } } } outerr := &bytes.Buffer{} if c.stderr == nil { c.stderr = outerr } else { c.stderr = io.MultiWriter(c.stderr, outerr) } var cmd *exec.Cmd if c.ctx != nil { cmd = exec.CommandContext(c.ctx, bin, args...) } else { cmd = exec.Command(bin, args...) } cmd.Stdin = c.stdin cmd.Stderr = c.stderr cmd.Stdout = c.stdout cmd.Env = c.env cmd.Dir = c.dir return &cmdWrapper{outerr: outerr, c: cmd, name: c.name}, nil } // InPath reports whether binaryName is in $PATH. func InPath(binaryName string) bool { if strings.Contains(binaryName, "/") { panic("binary name should not contain any slash") } _, err := exec.LookPath(binaryName) return err == nil } // LookPath finds the path to binaryName in $PATH. // Returns "" if not found. func LookPath(binaryName string) string { if strings.Contains(binaryName, "/") { panic("binary name should not contain any slash") } s, err := exec.LookPath(binaryName) if err != nil { return "" } return s }