]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Harden Node tool execution with --permission flag
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Sat, 11 Apr 2026 10:41:48 +0000 (12:41 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 22 Apr 2026 13:47:34 +0000 (15:47 +0200)
Add security.node.permissions config to run Node tools (PostCSS, Babel,
TailwindCSS) under Node's permission model, restricting file system access
to the working directory by default.

The binary resolution is simplified to node_modules/.bin → PATH (npx removed).
For both locations, the actual JS entry point is resolved via symlinks (macOS/Linux)
or by parsing npm wrapper scripts (Windows .cmd), then executed as
"node --permission --allow-fs-read=<path> --allow-fs-write=<path> <script>".

Users can opt out by removing "node" from security.exec.allow.

Closes #7287

16 files changed:
.github/workflows/test.yml
common/hexec/exec.go
common/hexec/exec_integration_test.go [new file with mode: 0644]
common/hexec/exec_test.go [new file with mode: 0644]
common/hugo/hugo.go
config/security/securityConfig.go
config/security/securityConfig_test.go
deps/deps.go
hugolib/filesystems/basefs.go
hugolib/integrationtest_builder.go
markup/asciidocext/asciidoc_integration_test.go
resources/resource_transformers/babel/babel.go
resources/resource_transformers/babel/babel_integration_test.go
resources/resource_transformers/cssjs/postcss.go
resources/resource_transformers/cssjs/postcss_integration_test.go
resources/resource_transformers/cssjs/tailwindcss.go

index ec1e73ae8b8997ea27b018e862d5eb0f57de4519..c1d7031beb3f876e05818eb436a07c5d776b2ec0 100644 (file)
@@ -42,6 +42,10 @@ jobs:
           cache-dependency-path: |
             **/go.sum
             **/go.mod
+      - name: Install Node
+        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+        with:
+          node-version: "22"
       - name: Install Ruby
         uses: ruby/setup-ruby@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0
         with:
index 79d48a48392ddbee841536513092aa2a705ffd1a..baec6608618e1f4541b2d98cf56d24459205e279 100644 (file)
@@ -1,4 +1,4 @@
-// Copyright 2020 The Hugo Authors. All rights reserved.
+// 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.
@@ -14,6 +14,7 @@
 package hexec
 
 import (
+       "bufio"
        "bytes"
        "context"
        "errors"
@@ -23,8 +24,8 @@ import (
        "os/exec"
        "path/filepath"
        "regexp"
+       "slices"
        "strings"
-       "sync"
 
        "github.com/bep/logg"
        "github.com/gohugoio/hugo/common/hmaps"
@@ -98,11 +99,11 @@ func New(cfg security.Config, workingDir string, log loggers.Logger) *Exec {
        }
 
        return &Exec{
-               sc:                cfg,
-               workingDir:        workingDir,
-               infol:             log.InfoCommand("exec"),
-               baseEnviron:       baseEnviron,
-               newNPXRunnerCache: hmaps.NewCache[string, func(arg ...any) (Runner, error)](),
+               sc:              cfg,
+               workingDir:      workingDir,
+               infol:           log.InfoCommand("exec"),
+               baseEnviron:     baseEnviron,
+               nodeRunnerCache: hmaps.NewCache[string, func(arg ...any) (Runner, error)](),
        }
 }
 
@@ -121,9 +122,16 @@ type Exec struct {
        // os.Environ filtered by the Exec.OsEnviron whitelist filter.
        baseEnviron []string
 
-       newNPXRunnerCache *hmaps.Cache[string, func(arg ...any) (Runner, error)]
-       npxInit           sync.Once
-       npxAvailable      bool
+       // 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) {
@@ -155,8 +163,6 @@ func (b binaryLocation) String() string {
        switch b {
        case binaryLocationNodeModules:
                return "node_modules/.bin"
-       case binaryLocationNpx:
-               return "npx"
        case binaryLocationPath:
                return "PATH"
        }
@@ -165,64 +171,55 @@ func (b binaryLocation) String() string {
 
 const (
        binaryLocationNodeModules binaryLocation = iota + 1
-       binaryLocationNpx
        binaryLocationPath
 )
 
-// Npx will in order:
-// 1. Try fo find the binary in the WORKINGDIR/node_modules/.bin directory.
-// 2. If not found, and npx is available, run npx --no-install <name> <args>.
-// 3. Fall back to the PATH.
-// If name is "tailwindcss", we will try the PATH as the second option.
+// 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
        }
-
-       newRunner, err := e.newNPXRunnerCache.GetOrCreate(name, func() (func(...any) (Runner, error), error) {
-               type tryFunc func() func(...any) (Runner, error)
-               tryFuncs := map[binaryLocation]tryFunc{
-                       binaryLocationNodeModules: func() func(...any) (Runner, error) {
-                               nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name)
-                               _, err := exec.LookPath(nodeBinFilename)
-                               if err != nil {
-                                       return nil
-                               }
-                               return func(arg2 ...any) (Runner, error) {
-                                       return e.new(name, nodeBinFilename, arg2...)
-                               }
-                       },
-                       binaryLocationNpx: func() func(...any) (Runner, error) {
-                               e.checkNpx()
-                               if !e.npxAvailable {
-                                       return nil
-                               }
-                               return func(arg2 ...any) (Runner, error) {
-                                       return e.npx(name, arg2...)
-                               }
-                       },
-                       binaryLocationPath: func() func(...any) (Runner, error) {
-                               if _, err := exec.LookPath(name); err != nil {
-                                       return nil
-                               }
-                               return func(arg2 ...any) (Runner, error) {
-                                       return e.New(name, arg2...)
-                               }
-                       },
+       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
                }
+       }
 
-               locations := []binaryLocation{binaryLocationNodeModules, binaryLocationNpx, binaryLocationPath}
-               if name == "tailwindcss" {
-                       // See https://github.com/gohugoio/hugo/issues/13221#issuecomment-2574801253
-                       locations = []binaryLocation{binaryLocationNodeModules, binaryLocationPath, binaryLocationNpx}
+       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"}
                }
-               for _, loc := range locations {
-                       if f := tryFuncs[loc](); f != nil {
-                               e.infol.Logf("resolve %q using %s", name, loc)
-                               return f, nil
-                       }
+
+               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 nil, &NotFoundError{name: name, method: fmt.Sprintf("in %s", locations[len(locations)-1])}
+
+               return func(arg2 ...any) (Runner, error) {
+                       return e.newNode(name, scriptPath, arg2...)
+               }, nil
        })
        if err != nil {
                return nil, err
@@ -231,22 +228,190 @@ func (e *Exec) Npx(name string, arg ...any) (Runner, error) {
        return newRunner(arg...)
 }
 
-const (
-       npxNoInstall       = "--no-install"
-       npxBinary          = "npx"
-       nodeModulesBinPath = "node_modules/.bin"
-)
+// 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)
+       }
+       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}))
+       }
 
-func (e *Exec) checkNpx() {
-       e.npxInit.Do(func() {
-               e.npxAvailable = InPath(npxBinary)
-       })
+       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 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
 }
 
-// npx is a convenience method to create a Runner running npx --no-install <name> <args.
-func (e *Exec) npx(name string, arg ...any) (Runner, error) {
-       arg = append(arg[:0], append([]any{npxNoInstall, name}, arg[0:]...)...)
-       return e.New(npxBinary, arg...)
+// 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.
@@ -283,14 +448,8 @@ func (c *cmdWrapper) Run() error {
        if err == nil {
                return nil
        }
-       name := c.name
-       method := "in PATH"
-       if name == npxBinary {
-               name = c.c.Args[2]
-               method = "using npx"
-       }
        if notFoundRe.MatchString(c.outerr.String()) {
-               return &NotFoundError{name: name, method: method}
+               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())
 }
diff --git a/common/hexec/exec_integration_test.go b/common/hexec/exec_integration_test.go
new file mode 100644 (file)
index 0000000..b76527b
--- /dev/null
@@ -0,0 +1,74 @@
+// 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_test
+
+import (
+       "testing"
+
+       "github.com/gohugoio/hugo/htesting"
+       "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestNPMGlobalInstalls(t *testing.T) {
+       if !htesting.IsRealCI() {
+               t.Skip("We only ever want to run this in CI.")
+       }
+       files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term"]
+[security.exec]
+allow = ['^(babel|node|postcss|tailwindcss)$']
+
+-- package.json --
+{}
+-- hugo_stats.json --
+-- assets/js/main.js --
+console.log("Hello, world!");
+-- assets/css/main1.css --
+body { color: red }
+-- assets/css/main2.css --
+@import "tailwindcss";
+@plugin "@tailwindcss/typography";
+@source "hugo_stats.json";
+body { color: blue }
+-- layouts/home.html --
+{{ with resources.Get "css/main1.css"  }}
+       {{ with . | css.PostCSS  }}
+                CSS1 size: {{ .Content | len }}|{{ .RelPermalink }}|
+       {{ end }}
+{{ end }}
+ {{ with resources.Get "css/main2.css"  }}
+       {{ with . | css.TailwindCSS  }}
+                CSS2 size: {{ .Content | len }}|{{ .RelPermalink }}|
+       {{ end }}
+{{ end }}
+{{ with resources.Get "js/main.js"  }}
+       {{ with . | js.Babel  }}
+                JS size: {{ .Content | len }}|{{ .RelPermalink }}|
+       {{ end }}
+{{ end }}
+`
+
+       b := hugolib.Test(t, files, hugolib.TestOptOsFs(), hugolib.TestOptWithNpmInstallGlobal(
+               "postcss", "postcss-cli",
+               "@babel/core", "@babel/cli",
+               "tailwindcss", "@tailwindcss/cli", "@tailwindcss/typography",
+       ))
+
+       b.AssertFileContent("public/index.html",
+               "CSS1 size: 233|/css/main1.css|",
+               "CSS2 size: 4557|/css/main2.css|",
+               "JS size: 31|/js/main.js|",
+       )
+}
diff --git a/common/hexec/exec_test.go b/common/hexec/exec_test.go
new file mode 100644 (file)
index 0000000..89af3f3
--- /dev/null
@@ -0,0 +1,470 @@
+// 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 (
+       "os"
+       "path/filepath"
+       "runtime"
+       "testing"
+
+       qt "github.com/frankban/quicktest"
+       "github.com/gohugoio/hugo/config/security"
+)
+
+func TestNodePermissionArgs(t *testing.T) {
+       c := qt.New(t)
+
+       // Use t.TempDir() so paths are absolute on any OS (avoids Windows volume assumptions).
+       base := t.TempDir()
+       site := filepath.Join(base, "site")
+       tmp := filepath.Join(base, "tmp")
+       cacheDir := filepath.Join(base, "home", "user", ".cache", "hugo_cache", "modules")
+
+       c.Run("Default config tailwindcss", func(c *qt.C) {
+               e := &Exec{
+                       sc:         security.DefaultConfig,
+                       workingDir: site,
+               }
+               args := e.nodePermissionArgs("tailwindcss", "")
+               c.Assert(args, qt.DeepEquals, []string{
+                       "--permission",
+                       "--allow-fs-read=" + site,
+                       "--allow-addons",
+                       "--allow-worker",
+                       "--disable-warning=SecurityWarning",
+               })
+       })
+
+       c.Run("Default config postcss", func(c *qt.C) {
+               e := &Exec{
+                       sc:         security.DefaultConfig,
+                       workingDir: site,
+               }
+               args := e.nodePermissionArgs("postcss", "")
+               c.Assert(args, qt.DeepEquals, []string{
+                       "--permission",
+                       "--allow-fs-read=" + site,
+               })
+       })
+
+       c.Run("Multiple paths", func(c *qt.C) {
+               cfg := security.DefaultConfig
+               cfg.Node.Permissions.AllowRead = []string{".", tmp}
+               cfg.Node.Permissions.AllowWrite = []string{"."}
+               e := &Exec{
+                       sc:         cfg,
+                       workingDir: site,
+               }
+               args := e.nodePermissionArgs("tailwindcss", "")
+               c.Assert(args, qt.DeepEquals, []string{
+                       "--permission",
+                       "--allow-fs-read=" + site,
+                       "--allow-fs-read=" + tmp,
+                       "--allow-fs-write=" + site,
+                       "--allow-addons",
+                       "--allow-worker",
+                       "--disable-warning=SecurityWarning",
+               })
+       })
+
+       c.Run("Wildcard", func(c *qt.C) {
+               cfg := security.DefaultConfig
+               cfg.Node.Permissions.AllowRead = []string{"*"}
+               cfg.Node.Permissions.AllowWrite = []string{"*"}
+               e := &Exec{
+                       sc:         cfg,
+                       workingDir: site,
+               }
+               args := e.nodePermissionArgs("tailwindcss", "")
+               c.Assert(args, qt.DeepEquals, []string{
+                       "--permission",
+                       "--allow-fs-read=*",
+                       "--allow-fs-write=*",
+                       "--allow-addons",
+                       "--allow-worker",
+                       "--disable-warning=SecurityWarning",
+               })
+       })
+
+       c.Run("Disabled", func(c *qt.C) {
+               cfg := security.DefaultConfig
+               cfg.Node.Permissions.Disable = true
+               e := &Exec{
+                       sc:         cfg,
+                       workingDir: site,
+               }
+               args := e.nodePermissionArgs("tailwindcss", "")
+               c.Assert(args, qt.IsNil)
+       })
+
+       c.Run("No fs flags", func(c *qt.C) {
+               cfg := security.DefaultConfig
+               cfg.Node.Permissions.AllowRead = nil
+               cfg.Node.Permissions.AllowAddons = nil
+               cfg.Node.Permissions.AllowWorker = nil
+               e := &Exec{
+                       sc:         cfg,
+                       workingDir: site,
+               }
+               args := e.nodePermissionArgs("postcss", "")
+               c.Assert(args, qt.DeepEquals, []string{"--permission"})
+       })
+
+       c.Run("Read only", func(c *qt.C) {
+               cfg := security.DefaultConfig
+               cfg.Node.Permissions.AllowRead = []string{"."}
+               cfg.Node.Permissions.AllowWrite = nil
+               e := &Exec{
+                       sc:         cfg,
+                       workingDir: site,
+               }
+               args := e.nodePermissionArgs("postcss", "")
+               c.Assert(args, qt.DeepEquals, []string{
+                       "--permission",
+                       "--allow-fs-read=" + site,
+               })
+       })
+
+       c.Run("With additional read paths", func(c *qt.C) {
+               e := &Exec{
+                       sc:            security.DefaultConfig,
+                       workingDir:    site,
+                       nodeReadPaths: []string{cacheDir},
+               }
+               args := e.nodePermissionArgs("postcss", "")
+               c.Assert(args, qt.DeepEquals, []string{
+                       "--permission",
+                       "--allow-fs-read=" + site,
+                       "--allow-fs-read=" + cacheDir,
+               })
+       })
+
+       c.Run("Global install script path", func(c *qt.C) {
+               e := &Exec{
+                       sc:         security.DefaultConfig,
+                       workingDir: site,
+               }
+               globalNM := filepath.Join(base, "nvm", "lib", "node_modules")
+               script := filepath.Join(globalNM, "postcss-cli", "bin", "postcss")
+               args := e.nodePermissionArgs("postcss", script)
+               c.Assert(args, qt.DeepEquals, []string{
+                       "--permission",
+                       "--allow-fs-read=" + site,
+                       "--allow-fs-read=" + globalNM,
+               })
+       })
+}
+
+func TestNodeScriptReadPath(t *testing.T) {
+       c := qt.New(t)
+
+       base := t.TempDir()
+       nm := filepath.Join(base, "node_modules")
+       globalNM := filepath.Join(base, "nvm", "lib", "node_modules")
+
+       c.Assert(nodeScriptReadPath(""), qt.Equals, "")
+       c.Assert(nodeScriptReadPath(filepath.Join(nm, "postcss-cli", "index.js")), qt.Equals, nm)
+       c.Assert(nodeScriptReadPath(filepath.Join(nm, "@babel", "cli", "bin", "babel.js")), qt.Equals, nm)
+       c.Assert(nodeScriptReadPath(filepath.Join(globalNM, "postcss-cli", "bin", "postcss")), qt.Equals, globalNM)
+
+       loose := filepath.Join(base, "tools", "script.js")
+       c.Assert(nodeScriptReadPath(loose), qt.Equals, filepath.Dir(loose))
+}
+
+func TestResolveNodeBin(t *testing.T) {
+       c := qt.New(t)
+
+       // Create a fake node_modules structure.
+       dir := t.TempDir()
+       nodeModules := filepath.Join(dir, "node_modules")
+       binDir := filepath.Join(nodeModules, ".bin")
+
+       // Create target JS files.
+       postcssJS := filepath.Join(nodeModules, "postcss-cli", "index.js")
+       babelJS := filepath.Join(nodeModules, "@babel", "cli", "bin", "babel.js")
+       mkdirAndWrite(t, postcssJS, "#!/usr/bin/env node\nconsole.log('postcss');\n")
+       mkdirAndWrite(t, babelJS, "#!/usr/bin/env node\nconsole.log('babel');\n")
+       os.MkdirAll(binDir, 0o755)
+
+       c.Run("Symlink to JS file", func(c *qt.C) {
+               if runtime.GOOS == "windows" {
+                       c.Skip("Symlinks may require elevated privileges on Windows")
+               }
+               link := filepath.Join(binDir, "postcss-link")
+               os.Remove(link)
+               c.Assert(os.Symlink(postcssJS, link), qt.IsNil)
+
+               resolved := resolveNodeBin(link)
+               t.Logf("Symlink: link=%q, resolved=%q", link, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, postcssJS), qt.IsTrue)
+       })
+
+       c.Run("Symlink to scoped package", func(c *qt.C) {
+               if runtime.GOOS == "windows" {
+                       c.Skip("Symlinks may require elevated privileges on Windows")
+               }
+               link := filepath.Join(binDir, "babel-link")
+               os.Remove(link)
+               c.Assert(os.Symlink(babelJS, link), qt.IsNil)
+
+               resolved := resolveNodeBin(link)
+               t.Logf("Scoped symlink: link=%q, resolved=%q", link, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, babelJS), qt.IsTrue)
+       })
+
+       c.Run("Shell wrapper", func(c *qt.C) {
+               wrapper := filepath.Join(binDir, "postcss-sh")
+               content := "#!/bin/sh\n" +
+                       `basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")` + "\n" +
+                       `exec node  "$basedir/../postcss-cli/index.js" "$@"` + "\n"
+               mkdirAndWrite(t, wrapper, content)
+
+               resolved := resolveNodeBin(wrapper)
+               t.Logf("Shell wrapper: wrapper=%q, resolved=%q", wrapper, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, postcssJS), qt.IsTrue)
+       })
+
+       c.Run("Cmd wrapper", func(c *qt.C) {
+               wrapper := filepath.Join(binDir, "postcss.cmd")
+               content := "@ECHO off\r\n" +
+                       "SETLOCAL\r\n" +
+                       `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%"  "%dp0%\..\postcss-cli\index.js" %*` + "\r\n"
+               mkdirAndWrite(t, wrapper, content)
+
+               resolved := resolveNodeBin(wrapper)
+               t.Logf("Cmd wrapper: wrapper=%q, resolved=%q", wrapper, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, postcssJS), qt.IsTrue)
+       })
+
+       c.Run("Cmd wrapper scoped package", func(c *qt.C) {
+               wrapper := filepath.Join(binDir, "babel.cmd")
+               content := "@ECHO off\r\n" +
+                       `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%"  "%dp0%\..\@babel\cli\bin\babel.js" %*` + "\r\n"
+               mkdirAndWrite(t, wrapper, content)
+
+               resolved := resolveNodeBin(wrapper)
+               t.Logf("Cmd wrapper (scoped): wrapper=%q, resolved=%q", wrapper, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, babelJS), qt.IsTrue)
+       })
+
+       c.Run("Node script with shebang", func(c *qt.C) {
+               script := filepath.Join(binDir, "node-global")
+               mkdirAndWrite(t, script, "#!/usr/bin/env node\nconsole.log('global');\n")
+
+               resolved := resolveNodeBin(script)
+               t.Logf("Node script: path=%q, resolved=%q", script, resolved)
+               c.Assert(resolved, qt.Equals, script)
+       })
+
+       c.Run("Native binary", func(c *qt.C) {
+               native := filepath.Join(binDir, "native-tool")
+               mkdirAndWrite(t, native, "\x7fELF\x00\x00\x00")
+
+               resolved := resolveNodeBin(native)
+               t.Logf("Native binary: path=%q, resolved=%q", native, resolved)
+               c.Assert(resolved, qt.Equals, "")
+       })
+
+       c.Run("Nonexistent file", func(c *qt.C) {
+               c.Assert(resolveNodeBin("/nonexistent/path"), qt.Equals, "")
+       })
+
+       c.Run("Wrapper with missing target", func(c *qt.C) {
+               wrapper := filepath.Join(binDir, "missing-target")
+               mkdirAndWrite(t, wrapper, "#!/bin/sh\nexec node \"$basedir/../no-such-pkg/index.js\" \"$@\"\n")
+
+               resolved := resolveNodeBin(wrapper)
+               t.Logf("Missing target: wrapper=%q, resolved=%q", wrapper, resolved)
+               c.Assert(resolved, qt.Equals, "")
+       })
+}
+
+func TestExtractNodeEntryPointRegex(t *testing.T) {
+       c := qt.New(t)
+
+       cases := []struct {
+               name    string
+               content string
+               want    string // expected capture group (with original separators)
+       }{
+               {"shell postcss", `"$basedir/../postcss-cli/index.js"`, "../postcss-cli/index.js"},
+               {"shell babel", `"$basedir/../@babel/cli/bin/babel.js"`, "../@babel/cli/bin/babel.js"},
+               {"shell tailwind mjs", `"$basedir/../@tailwindcss/cli/dist/index.mjs"`, "../@tailwindcss/cli/dist/index.mjs"},
+               {"cmd postcss", `"%dp0%\..\postcss-cli\index.js"`, `..\postcss-cli\index.js`},
+               {"cmd babel", `"%dp0%\..\@babel\cli\bin\babel.js"`, `..\@babel\cli\bin\babel.js`},
+               {"cmd postcss no ext", `"%dp0%\..\postcss-cli\bin\postcss"`, `..\postcss-cli\bin\postcss`},
+               {"shell postcss no ext", `"$basedir/../postcss-cli/bin/postcss"`, "../postcss-cli/bin/postcss"},
+               {"cmd postcss global", `"%dp0%\node_modules\postcss-cli\index.js"`, `node_modules\postcss-cli\index.js`},
+               {"cmd babel global", `"%dp0%\node_modules\@babel\cli\bin\babel.js"`, `node_modules\@babel\cli\bin\babel.js`},
+               {"shell postcss global", `"$basedir/node_modules/postcss-cli/index.js"`, "node_modules/postcss-cli/index.js"},
+       }
+
+       for _, tc := range cases {
+               c.Run(tc.name, func(c *qt.C) {
+                       m := nodeEntryPointRe.FindStringSubmatch(tc.content)
+                       t.Logf("regex match for %q: %v", tc.name, m)
+                       c.Assert(m, qt.Not(qt.IsNil))
+                       c.Assert(m[1], qt.Equals, tc.want)
+               })
+       }
+
+       c.Run("No match", func(c *qt.C) {
+               for _, s := range []string{"@ECHO off", "#!/bin/bash\necho hello", "\x7fELF"} {
+                       c.Assert(nodeEntryPointRe.FindStringSubmatch(s), qt.IsNil)
+               }
+       })
+}
+
+// TestResolveNodeBinWindows tests wrapper resolution on all platforms
+// by simulating Windows-style wrapper files. On Windows CI, this also
+// tests the native .cmd resolution path.
+func TestResolveNodeBinWindows(t *testing.T) {
+       c := qt.New(t)
+
+       dir := t.TempDir()
+       nodeModules := filepath.Join(dir, "node_modules")
+       binDir := filepath.Join(nodeModules, ".bin")
+
+       // Create target JS file.
+       targetJS := filepath.Join(nodeModules, "postcss-cli", "index.js")
+       mkdirAndWrite(t, targetJS, "#!/usr/bin/env node\nconsole.log('postcss');\n")
+       os.MkdirAll(binDir, 0o755)
+
+       // Simulate what npm creates on Windows: a .cmd wrapper and a shell script.
+       cmdWrapper := filepath.Join(binDir, "postcss.cmd")
+       cmdContent := "@ECHO off\r\n" +
+               "SETLOCAL\r\n" +
+               "CALL :find_dp0\r\n" +
+               `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%"  "%dp0%\..\postcss-cli\index.js" %*` + "\r\n"
+       mkdirAndWrite(t, cmdWrapper, cmdContent)
+
+       shWrapper := filepath.Join(binDir, "postcss")
+       shContent := "#!/bin/sh\n" +
+               `exec node  "$basedir/../postcss-cli/index.js" "$@"` + "\n"
+       mkdirAndWrite(t, shWrapper, shContent)
+
+       t.Logf("GOOS=%s", runtime.GOOS)
+       t.Logf("cmd wrapper: %s", cmdWrapper)
+       t.Logf("sh wrapper: %s", shWrapper)
+       t.Logf("target JS: %s", targetJS)
+
+       c.Run("cmd wrapper resolves to JS", func(c *qt.C) {
+               resolved := resolveNodeBin(cmdWrapper)
+               t.Logf("resolveNodeBin(%q) = %q", cmdWrapper, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, targetJS), qt.IsTrue)
+       })
+
+       c.Run("sh wrapper resolves to JS", func(c *qt.C) {
+               resolved := resolveNodeBin(shWrapper)
+               t.Logf("resolveNodeBin(%q) = %q", shWrapper, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, targetJS), qt.IsTrue)
+       })
+
+       // Simulate postcss-cli 7, whose wrappers point to an extensionless Node
+       // shebang script (bin/postcss) rather than a .js file.
+       targetNoExt := filepath.Join(nodeModules, "postcss-cli", "bin", "postcss")
+       mkdirAndWrite(t, targetNoExt, "#!/usr/bin/env node\nrequire('../');\n")
+
+       cmdNoExt := filepath.Join(binDir, "postcssne.cmd")
+       mkdirAndWrite(t, cmdNoExt, "@ECHO off\r\n"+
+               `"%dp0%\..\postcss-cli\bin\postcss" %*`+"\r\n")
+
+       shNoExt := filepath.Join(binDir, "postcssne")
+       mkdirAndWrite(t, shNoExt, "#!/bin/sh\n"+
+               `exec node "$basedir/../postcss-cli/bin/postcss" "$@"`+"\n")
+
+       c.Run("cmd wrapper resolves to extensionless script", func(c *qt.C) {
+               resolved := resolveNodeBin(cmdNoExt)
+               t.Logf("resolveNodeBin(%q) = %q", cmdNoExt, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, targetNoExt), qt.IsTrue)
+       })
+
+       c.Run("sh wrapper resolves to extensionless script", func(c *qt.C) {
+               resolved := resolveNodeBin(shNoExt)
+               t.Logf("resolveNodeBin(%q) = %q", shNoExt, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, targetNoExt), qt.IsTrue)
+       })
+
+       // Simulate `npm install -g` on Windows: the wrapper sits at the npm
+       // global prefix and references node_modules as a child (no `..`).
+       globalDir := filepath.Join(dir, "global")
+       globalTarget := filepath.Join(globalDir, "node_modules", "postcss-cli", "index.js")
+       mkdirAndWrite(t, globalTarget, "#!/usr/bin/env node\nconsole.log('postcss');\n")
+
+       globalCmd := filepath.Join(globalDir, "postcss.cmd")
+       mkdirAndWrite(t, globalCmd, "@ECHO off\r\n"+
+               `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%"  "%dp0%\node_modules\postcss-cli\index.js" %*`+"\r\n")
+
+       globalSh := filepath.Join(globalDir, "postcss")
+       mkdirAndWrite(t, globalSh, "#!/bin/sh\n"+
+               `exec node  "$basedir/node_modules/postcss-cli/index.js" "$@"`+"\n")
+
+       c.Run("global cmd wrapper resolves to JS", func(c *qt.C) {
+               resolved := resolveNodeBin(globalCmd)
+               t.Logf("resolveNodeBin(%q) = %q", globalCmd, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, globalTarget), qt.IsTrue)
+       })
+
+       c.Run("global sh wrapper resolves to JS", func(c *qt.C) {
+               resolved := resolveNodeBin(globalSh)
+               t.Logf("resolveNodeBin(%q) = %q", globalSh, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, globalTarget), qt.IsTrue)
+       })
+
+       globalScopedTarget := filepath.Join(globalDir, "node_modules", "@babel", "cli", "bin", "babel.js")
+       mkdirAndWrite(t, globalScopedTarget, "#!/usr/bin/env node\nconsole.log('babel');\n")
+       globalScopedCmd := filepath.Join(globalDir, "babel.cmd")
+       mkdirAndWrite(t, globalScopedCmd, "@ECHO off\r\n"+
+               `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%"  "%dp0%\node_modules\@babel\cli\bin\babel.js" %*`+"\r\n")
+
+       c.Run("global cmd wrapper scoped package", func(c *qt.C) {
+               resolved := resolveNodeBin(globalScopedCmd)
+               t.Logf("resolveNodeBin(%q) = %q", globalScopedCmd, resolved)
+               c.Assert(resolved, qt.Not(qt.Equals), "")
+               c.Assert(sameFile(t, resolved, globalScopedTarget), qt.IsTrue)
+       })
+}
+
+func sameFile(t *testing.T, a, b string) bool {
+       t.Helper()
+       infoA, errA := os.Stat(a)
+       infoB, errB := os.Stat(b)
+       if errA != nil || errB != nil {
+               t.Logf("sameFile: stat errors: a=%v, b=%v", errA, errB)
+               return false
+       }
+       return os.SameFile(infoA, infoB)
+}
+
+func mkdirAndWrite(t *testing.T, path, content string) {
+       t.Helper()
+       if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+               t.Fatal(err)
+       }
+       if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
+               t.Fatal(err)
+       }
+}
index fbb1e53a44751c5f63e0369145053d21ab42745f..9a76ee3b764cbfaf8ab15dd53afc98c87f1939ac 100644 (file)
@@ -105,7 +105,7 @@ func GetExecEnviron(workDir string, cfg config.AllProvider, fs afero.Fs) []strin
        var env []string
        nodepath := filepath.Join(workDir, "node_modules")
        if np := os.Getenv("NODE_PATH"); np != "" {
-               nodepath = workDir + string(os.PathListSeparator) + np
+               nodepath = nodepath + string(os.PathListSeparator) + np
        }
        config.SetEnvVars(&env, "NODE_PATH", nodepath)
        config.SetEnvVars(&env, "PWD", workDir)
index d47492c0e608c414407cd2598ef7526f78d70f87..8ba51a981a9b0fe8d3b2bb9f0322f6b9339b75aa 100644 (file)
@@ -19,6 +19,7 @@ import (
        "errors"
        "fmt"
        "reflect"
+       "slices"
        "strings"
 
        "github.com/gohugoio/hugo/common/herrors"
@@ -38,7 +39,7 @@ var DefaultConfig = Config{
                        "^(dart-)?sass(-embedded)?$", // sass, dart-sass, dart-sass-embedded.
                        "^go$",                       // for Go Modules
                        "^git$",                      // For Git info
-                       "^npx$",                      // used by all Node tools (Babel, PostCSS).
+                       "^node$",                     // Used as the runtime for Node tools.
                        "^postcss$",
                        "^tailwindcss$",
                ),
@@ -53,6 +54,15 @@ var DefaultConfig = Config{
                URLs:    MustNewWhitelist(".*"),
                Methods: MustNewWhitelist("(?i)GET|POST"),
        },
+       Node: Node{
+               Permissions: NodePermissions{
+                       Disable:     false,
+                       AllowRead:   []string{"."},
+                       AllowWrite:  []string{},              // No write access by default.
+                       AllowAddons: []string{"tailwindcss"}, // tailwindcss does not work without addon permissions.
+                       AllowWorker: []string{"tailwindcss"}, // tailwindcss needs worker access.
+               },
+       },
 }
 
 // Config is the top level security config.
@@ -68,6 +78,9 @@ type Config struct {
        // Restricts access to resources.GetRemote, getJSON, getCSV.
        HTTP HTTP `json:"http"`
 
+       // Node holds Node.js security settings.
+       Node Node `json:"node"`
+
        // Allow inline shortcodes
        EnableInlineShortcodes bool `json:"enableInlineShortcodes"`
 }
@@ -95,6 +108,29 @@ type HTTP struct {
        MediaTypes Whitelist `json:"mediaTypes"`
 }
 
+// Node holds Node.js security settings.
+type Node struct {
+       // Permissions configures Node's --permission flag for file system access control.
+       Permissions NodePermissions `json:"permissions"`
+}
+
+// NodePermissions configures the Node.js permission model (--permission).
+// Paths are relative to the working directory; "." means the working directory itself.
+// Use "*" to allow all paths.
+type NodePermissions struct {
+       // Disable turns off the Node.js permission model entirely.
+       Disable     bool     `json:"disable"`
+       AllowRead   []string `json:"allowRead"`
+       AllowWrite  []string `json:"allowWrite"`
+       AllowAddons []string `json:"allowAddons"`
+       AllowWorker []string `json:"allowWorker"`
+}
+
+// IsEnabled reports whether the Node.js permission model is active.
+func (p NodePermissions) IsEnabled() bool {
+       return !p.Disable
+}
+
 // ToTOML converts c to TOML with [security] as the root.
 func (c Config) ToTOML() string {
        sec := c.ToSecurityMap()
@@ -170,6 +206,11 @@ func (c Config) ToSecurityMap() map[string]any {
 // DecodeConfig creates a privacy Config from a given Hugo configuration.
 func DecodeConfig(cfg config.Provider) (Config, error) {
        sc := DefaultConfig
+       // Deep copy slices to prevent mapstructure from mutating DefaultConfig.
+       sc.Node.Permissions.AllowRead = slices.Clone(sc.Node.Permissions.AllowRead)
+       sc.Node.Permissions.AllowWrite = slices.Clone(sc.Node.Permissions.AllowWrite)
+       sc.Node.Permissions.AllowAddons = slices.Clone(sc.Node.Permissions.AllowAddons)
+       sc.Node.Permissions.AllowWorker = slices.Clone(sc.Node.Permissions.AllowWorker)
        if cfg.IsSet(securityConfigKey) {
                m := cfg.GetStringMap(securityConfigKey)
                dec, err := mapstructure.NewDecoder(
index 4ea77a50154990783e00625657dff3960e881962..c8599ee41ceb27004c8f2c75d4ecadc389dd2878 100644 (file)
@@ -135,7 +135,7 @@ func TestToTOML(t *testing.T) {
        got := DefaultConfig.ToTOML()
 
        c.Assert(got, qt.Equals,
-               "[security]\n  enableInlineShortcodes = false\n\n  [security.exec]\n    allow = ['^(dart-)?sass(-embedded)?$', '^go$', '^git$', '^npx$', '^postcss$', '^tailwindcss$']\n    osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE|PROGRAMDATA)$']\n\n  [security.funcs]\n    getenv = ['^HUGO_', '^CI$']\n\n  [security.http]\n    methods = ['(?i)GET|POST']\n    urls = ['.*']",
+               "[security]\n  enableInlineShortcodes = false\n\n  [security.exec]\n    allow = ['^(dart-)?sass(-embedded)?$', '^go$', '^git$', '^node$', '^postcss$', '^tailwindcss$']\n    osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE|PROGRAMDATA)$']\n\n  [security.funcs]\n    getenv = ['^HUGO_', '^CI$']\n\n  [security.http]\n    methods = ['(?i)GET|POST']\n    urls = ['.*']\n\n  [security.node]\n    [security.node.permissions]\n      allowAddons = ['tailwindcss']\n      allowRead = ['.']\n      allowWorker = ['tailwindcss']\n      allowWrite = []\n      disable = false",
        )
 }
 
@@ -147,8 +147,8 @@ func TestDecodeConfigDefault(t *testing.T) {
        c.Assert(err, qt.IsNil)
        c.Assert(pc, qt.Not(qt.IsNil))
        c.Assert(pc.Exec.Allow.Accept("a"), qt.IsFalse)
-       c.Assert(pc.Exec.Allow.Accept("npx"), qt.IsTrue)
-       c.Assert(pc.Exec.Allow.Accept("Npx"), qt.IsFalse)
+       c.Assert(pc.Exec.Allow.Accept("node"), qt.IsTrue)
+       c.Assert(pc.Exec.Allow.Accept("npx"), qt.IsFalse)
 
        c.Assert(pc.HTTP.URLs.Accept("https://example.org"), qt.IsTrue)
        c.Assert(pc.HTTP.Methods.Accept("POST"), qt.IsTrue)
@@ -164,4 +164,56 @@ func TestDecodeConfigDefault(t *testing.T) {
        c.Assert(pc.Exec.OsEnv.Accept("a"), qt.IsFalse)
        c.Assert(pc.Exec.OsEnv.Accept("e"), qt.IsFalse)
        c.Assert(pc.Exec.OsEnv.Accept("MYSECRET"), qt.IsFalse)
+
+       c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsTrue)
+       c.Assert(pc.Node.Permissions.AllowRead, qt.DeepEquals, []string{"."})
+       c.Assert(pc.Node.Permissions.AllowWrite, qt.DeepEquals, []string{})
+}
+
+func TestDecodeConfigNodePermissions(t *testing.T) {
+       c := qt.New(t)
+
+       c.Run("Custom paths", func(c *qt.C) {
+               c.Parallel()
+               tomlConfig := `
+[security.node.permissions]
+allowRead = ["/tmp", "."]
+allowWrite = ["."]
+`
+               cfg, err := config.FromConfigString(tomlConfig, "toml")
+               c.Assert(err, qt.IsNil)
+               pc, err := DecodeConfig(cfg)
+               c.Assert(err, qt.IsNil)
+               c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsTrue)
+               c.Assert(pc.Node.Permissions.AllowRead, qt.DeepEquals, []string{"/tmp", "."})
+               c.Assert(pc.Node.Permissions.AllowWrite, qt.DeepEquals, []string{"."})
+       })
+
+       c.Run("Disabled", func(c *qt.C) {
+               c.Parallel()
+               tomlConfig := `
+[security.node.permissions]
+disable = true
+`
+               cfg, err := config.FromConfigString(tomlConfig, "toml")
+               c.Assert(err, qt.IsNil)
+               pc, err := DecodeConfig(cfg)
+               c.Assert(err, qt.IsNil)
+               c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsFalse)
+       })
+
+       c.Run("Wildcard", func(c *qt.C) {
+               c.Parallel()
+               tomlConfig := `
+[security.node.permissions]
+allowRead = ["*"]
+allowWrite = ["*"]
+`
+               cfg, err := config.FromConfigString(tomlConfig, "toml")
+               c.Assert(err, qt.IsNil)
+               pc, err := DecodeConfig(cfg)
+               c.Assert(err, qt.IsNil)
+               c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsTrue)
+               c.Assert(pc.Node.Permissions.AllowRead, qt.DeepEquals, []string{"*"})
+       })
 }
index 81dacab2efdc07e7be7624c75845a0b5909374ad..188e530a038663a96d73062089e0b31e8aed097e 100644 (file)
@@ -243,6 +243,8 @@ func (d *Deps) Init() error {
                }
        }
 
+       d.ExecHelper.SetNodeReadPaths(d.BaseFs.Assets.RealPaths(""))
+
        if d.ContentSpec == nil {
                contentSpec, err := helpers.NewContentSpec(d.Conf, d.Log, d.Content.Fs, d.ExecHelper)
                if err != nil {
index 30c1b9e4dc1bfe31ae713fcbc20872889ffcefb5..13cc942fc2e9d786b175b6086da9ff36ac3fdc2d 100644 (file)
@@ -454,6 +454,18 @@ func (d *SourceFilesystem) RealDirs(from string) []string {
        return dirnames
 }
 
+// RealPaths returns the absolute paths of all mounts in this filesystem.
+func (d *SourceFilesystem) RealPaths(from string) []string {
+       var paths []string
+       for _, m := range d.mounts() {
+               p := filepath.Join(m.Meta().Filename, from)
+               if _, err := d.SourceFs.Stat(p); err == nil {
+                       paths = append(paths, p)
+               }
+       }
+       return paths
+}
+
 // WithBaseFs allows reuse of some potentially expensive to create parts that remain
 // the same across sites/languages.
 func WithBaseFs(b *BaseFs) func(*BaseFs) error {
index ee9919a75c4504e3c8995d909970f1f135be8c2e..e476ff6328eb96ac4e1dd62fc4613db1308705d8 100644 (file)
@@ -112,6 +112,14 @@ func TestOptWithNpmInstall() TestOpt {
        }
 }
 
+// TestOptWithNpmInstallGlobal enables global npm package installation in integration tests.
+// This mutates the host environment and is therefore restricted to real CI.
+func TestOptWithNpmInstallGlobal(packages ...string) TestOpt {
+       return func(c *IntegrationTestConfig) {
+               c.NeedsNpmGlobalInstall = packages
+       }
+}
+
 // TestOptWithNFDOnDarwin will normalize the Unicode filenames to NFD on Darwin.
 func TestOptWithNFDOnDarwin() TestOpt {
        return func(c *IntegrationTestConfig) {
@@ -967,18 +975,37 @@ func (s *IntegrationTestBuilder) initBuilder() error {
                s.H = sites
                s.fs = fs
 
-               if s.Cfg.NeedsNpmInstall {
-                       wd, _ := os.Getwd()
-                       s.Assert(os.Chdir(s.Cfg.WorkingDir), qt.IsNil)
-                       s.C.Cleanup(func() { os.Chdir(wd) })
+               if s.Cfg.NeedsNpmInstall || len(s.Cfg.NeedsNpmGlobalInstall) > 0 {
+                       if s.Cfg.NeedsNpmInstall {
+                               wd, _ := os.Getwd()
+                               s.Assert(os.Chdir(s.Cfg.WorkingDir), qt.IsNil)
+                               s.C.Cleanup(func() { os.Chdir(wd) })
+                       }
                        sc := security.DefaultConfig
                        sc.Exec.Allow, err = security.NewWhitelist("npm")
                        s.Assert(err, qt.IsNil)
                        ex := hexec.New(sc, s.Cfg.WorkingDir, loggers.NewDefault())
-                       command, err := ex.New("npm", "install")
-                       s.Assert(err, qt.IsNil)
-                       s.Assert(command.Run(), qt.IsNil)
 
+                       if len(s.Cfg.NeedsNpmGlobalInstall) > 0 {
+                               if !htesting.IsRealCI() && !htesting.IsGitHubAction() {
+                                       panic("NeedsNpmGlobalInstall is restricted to real CI because it performs global npm installs")
+                               }
+                               for _, pkg := range s.Cfg.NeedsNpmGlobalInstall {
+                                       command, err := ex.New("npm", "install", "-g", pkg)
+                                       s.Assert(err, qt.IsNil)
+                                       s.Assert(command.Run(), qt.IsNil)
+
+                               }
+                               s.C.Cleanup(func() {
+                                       for _, pkg := range s.Cfg.NeedsNpmGlobalInstall {
+                                               ex.New("npm", "uninstall", "-g", pkg)
+                                       }
+                               })
+                       } else {
+                               command, err := ex.New("npm", "install")
+                               s.Assert(err, qt.IsNil)
+                               s.Assert(command.Run(), qt.IsNil)
+                       }
                }
        })
 
@@ -1187,6 +1214,10 @@ type IntegrationTestConfig struct {
        // Whether to run npm install before Build.
        NeedsNpmInstall bool
 
+       // Global npm packages to install before Build. This is used for testing the npm integration in the Hugo Pipes.
+       // Only used on CI.
+       NeedsNpmGlobalInstall []string
+
        // Whether to normalize the Unicode filenames to NFD on Darwin.
        NFDFormOnDarwin bool
 
index 55c89859270869e0d977f619aa4f9bde8218dd85..72fa5456952a9928095b5856bf822188e9ff4b88 100644 (file)
@@ -264,7 +264,7 @@ defaultContentLanguageInSubdir = DEFAULT_CONTENT_LANGUAGE_IN_SUBDIR
 dir = 'DIAGRAM_CACHEDIR'
 
 [security.exec]
-  allow = ['^((dart-)?sass|git|go|npx|postcss|tailwindcss|asciidoctor)$']`
+  allow = ['^((dart-)?sass|git|go|node|postcss|tailwindcss|asciidoctor)$']`
 
 func validatePublishedSite_1(b *hugolib.IntegrationTestBuilder) {
        for _, file := range []publishedContentFile{
index 859bf5805ccfd9b90285c5243afe8051761c17c8..8336cd6e306112092c9f14052c03b4b96b0db661 100644 (file)
@@ -15,9 +15,9 @@ package babel
 
 import (
        "bytes"
+       "encoding/base64"
        "fmt"
        "io"
-       "os"
        "path"
        "path/filepath"
        "regexp"
@@ -57,15 +57,15 @@ func DecodeOptions(m map[string]any) (opts Options, err error) {
        return
 }
 
+func (opts Options) sourceMapEnabled() bool {
+       return opts.SourceMap != "" && opts.SourceMap != "none"
+}
+
 func (opts Options) toArgs() []any {
        var args []any
 
-       // external is not a known constant on the babel command line
-       // .sourceMaps must be a boolean, "inline", "both", or undefined
-       switch opts.SourceMap {
-       case "external":
-               args = append(args, "--source-maps")
-       case "inline":
+       // Always use inline source maps; Transform extracts to external file if needed.
+       if opts.sourceMapEnabled() {
                args = append(args, "--source-maps=inline")
        }
        if opts.Minified {
@@ -158,27 +158,13 @@ func (t *babelTransformation) Transform(ctx *resources.ResourceTransformationCtx
        }
        cmdArgs = append(cmdArgs, "--filename="+ctx.SourcePath)
 
-       // Create compile into a real temp file:
-       // 1. separate stdout/stderr messages from babel (https://github.com/gohugoio/hugo/issues/8136)
-       // 2. allow generation and retrieval of external source map.
-       compileOutput, err := os.CreateTemp("", "compileOut-*.js")
-       if err != nil {
-               return err
-       }
-
-       cmdArgs = append(cmdArgs, "--out-file="+compileOutput.Name())
+       var outBuf bytes.Buffer
        stderr := io.MultiWriter(infoW, &errBuf)
        cmdArgs = append(cmdArgs, hexec.WithStderr(stderr))
-       cmdArgs = append(cmdArgs, hexec.WithStdout(stderr))
+       cmdArgs = append(cmdArgs, hexec.WithStdout(&outBuf))
+       cmdArgs = append(cmdArgs, hexec.WithDir(t.rs.Cfg.BaseConfig().WorkingDir))
        cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.Cfg.BaseConfig().WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)))
 
-       defer func() {
-               compileOutput.Close()
-               os.Remove(compileOutput.Name())
-       }()
-
-       // ARGA [--no-install babel --config-file /private/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/hugo-test-babel812882892/babel.config.js --source-maps --filename=js/main2.js --out-file=/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/compileOut-2237820197.js]
-       //      [--no-install babel --config-file /private/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/hugo-test-babel332846848/babel.config.js --filename=js/main.js --out-file=/var/folders/_g/j3j21hts4fn7__h04w2x8gb40000gn/T/compileOut-1451390834.js 0x10304ee60 0x10304ed60 0x10304f060]
        cmd, err := ex.Npx(binaryName, cmdArgs...)
        if err != nil {
                if hexec.IsNotFound(err) {
@@ -206,24 +192,16 @@ func (t *babelTransformation) Transform(ctx *resources.ResourceTransformationCtx
                return fmt.Errorf(errBuf.String()+": %w", err)
        }
 
-       content, err := io.ReadAll(compileOutput)
-       if err != nil {
-               return err
-       }
+       content := outBuf.Bytes()
 
-       mapFile := compileOutput.Name() + ".map"
-       if _, err := os.Stat(mapFile); err == nil {
-               defer os.Remove(mapFile)
-               sourceMap, err := os.ReadFile(mapFile)
-               if err != nil {
-                       return err
-               }
-               if err = ctx.PublishSourceMap(sourceMap); err != nil {
-                       return err
+       if t.options.SourceMap == "external" {
+               if sourceMap, stripped, ok := extractInlineSourceMap(content); ok {
+                       if err = ctx.PublishSourceMap(sourceMap); err != nil {
+                               return err
+                       }
+                       targetPath := path.Base(ctx.OutPath) + ".map"
+                       content = append(stripped, ("//# sourceMappingURL=" + targetPath + "\n")...)
                }
-               targetPath := path.Base(ctx.OutPath) + ".map"
-               re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
-               content = []byte(re.ReplaceAllString(string(content), "//# sourceMappingURL="+targetPath+"\n"))
        }
 
        ctx.To.Write(content)
@@ -237,3 +215,17 @@ func (c *Client) Process(res resources.ResourceTransformer, options Options) (re
                &babelTransformation{rs: c.rs, options: options},
        )
 }
+
+var inlineSourceMapRe = regexp.MustCompile(`(?m)//# sourceMappingURL=data:application/json[^,]*,([A-Za-z0-9+/=]+)\s*$`)
+
+func extractInlineSourceMap(content []byte) (sourceMap, stripped []byte, ok bool) {
+       loc := inlineSourceMapRe.FindSubmatchIndex(content)
+       if loc == nil {
+               return nil, content, false
+       }
+       decoded, err := base64.StdEncoding.DecodeString(string(content[loc[2]:loc[3]]))
+       if err != nil {
+               return nil, content, false
+       }
+       return decoded, content[:loc[0]], true
+}
index 1153c775d8263ff4d52c07c86a663bd644777e58..41f867a51a48882f8b0412cc66c06cecfbed2a5c 100644 (file)
@@ -50,7 +50,7 @@ module.exports = {
 disablekinds = ['taxonomy', 'term', 'page']
 [security]
        [security.exec]
-       allow = ['^npx$', '^babel$']
+       allow = ['^node$', '^babel$']
 -- layouts/home.html --
 {{ $options := dict "noComments" true }}
 {{ $transpiled := resources.Get "js/main.js" | babel -}}
index 98bdc9249583e988b2ae0a0e84ae7dd465c973a2..13da2ab302d595f7ffee38635dd4c6e0fa03c4f6 100644 (file)
@@ -187,6 +187,7 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC
        stderr := io.MultiWriter(infow, &errBuf)
        cmdArgs = append(cmdArgs, hexec.WithStderr(stderr))
        cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To))
+       cmdArgs = append(cmdArgs, hexec.WithDir(t.rs.Cfg.BaseConfig().WorkingDir))
        cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.Cfg.BaseConfig().WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)))
 
        cmd, err := ex.Npx(binaryName, cmdArgs...)
index 02707fd5f4a080520d03b9596533b9f83a636823..ad0608a0c609dd93bba6cc48d84fdfec266b0327 100644 (file)
@@ -155,20 +155,6 @@ func TestTransformPostCSSError(t *testing.T) {
        b.Assert(err.Error(), qt.Contains, "a.css:4:2")
 }
 
-func TestTransformPostCSSNotInstalledError(t *testing.T) {
-       if !htesting.IsCI() {
-               t.Skip("Skip long running test when running locally")
-       }
-
-       c := qt.New(t)
-
-       _, err := hugolib.TestE(c, postCSSIntegrationTestFiles, hugolib.TestOptOsFs())
-
-       ferrs := herrors.UnwrapFileErrors(err)
-       c.Assert(len(ferrs), qt.Equals, 1)
-       c.Assert(err.Error(), qt.Contains, `binary with name "postcss" not found using npx`)
-}
-
 // #9895
 func TestTransformPostCSSImportError(t *testing.T) {
        if !htesting.IsCI() {
index a60a16222ac7918860c0010b22116bcb00e23edd..539016dc2f609e3dba9d6a1227f107916451b352 100644 (file)
@@ -104,6 +104,7 @@ func (t *tailwindcssTransformation) Transform(ctx *resources.ResourceTransformat
        stderr := io.MultiWriter(infow, &errBuf)
        cmdArgs = append(cmdArgs, hexec.WithStderr(stderr))
        cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To))
+       cmdArgs = append(cmdArgs, hexec.WithDir(workingDir))
        cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(workingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)))
 
        cmd, err := ex.Npx(binaryName, cmdArgs...)