mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 15:28:54 +00:00
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:
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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|",
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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{"*"})
|
||||
})
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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...)
|
||||
|
||||
Reference in New Issue
Block a user