Harden Node tool execution with --permission flag

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
This commit is contained in:
Bjørn Erik Pedersen
2026-04-11 12:41:48 +02:00
parent f5fce935e7
commit a54c398b93
16 changed files with 972 additions and 147 deletions
+4
View 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:
+234 -75
View 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"
@@ -102,7 +103,7 @@ func New(cfg security.Config, workingDir string, log loggers.Logger) *Exec {
workingDir: workingDir,
infol: log.InfoCommand("exec"),
baseEnviron: baseEnviron,
newNPXRunnerCache: hmaps.NewCache[string, func(arg ...any) (Runner, error)](),
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
}
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
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 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"}
}
locations := []binaryLocation{binaryLocationNodeModules, binaryLocationNpx, binaryLocationPath}
if name == "tailwindcss" {
// See https://github.com/gohugoio/hugo/issues/13221#issuecomment-2574801253
locations = []binaryLocation{binaryLocationNodeModules, binaryLocationPath, binaryLocationNpx}
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)
}
for _, loc := range locations {
if f := tryFuncs[loc](); f != nil {
e.infol.Logf("resolve %q using %s", name, loc)
return f, nil
}
}
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"
)
func (e *Exec) checkNpx() {
e.npxInit.Do(func() {
e.npxAvailable = InPath(npxBinary)
})
// 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}))
}
// 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...)
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
}
// 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())
}
+74
View File
@@ -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|",
)
}
+470
View File
@@ -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)
}
}
+1 -1
View 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)
+42 -1
View 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(
+55 -3
View 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{"*"})
})
}
+2
View 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 {
+12
View 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 {
+32 -1
View 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,19 +975,38 @@ func (s *IntegrationTestBuilder) initBuilder() error {
s.H = sites
s.fs = fs
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")
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)
}
}
})
return initErr
@@ -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
@@ -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{
+29 -37
View 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 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"
re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
content = []byte(re.ReplaceAllString(string(content), "//# sourceMappingURL="+targetPath+"\n"))
content = append(stripped, ("//# 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
}
@@ -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 -}}
@@ -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...)
@@ -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() {
@@ -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...)