diff options
58 files changed, 1701 insertions, 329 deletions
diff --git a/common/collections/slice.go b/common/collections/slice.go index 38ca86b08..07ad48eb3 100644 --- a/common/collections/slice.go +++ b/common/collections/slice.go @@ -64,3 +64,13 @@ func Slice(args ...interface{}) interface{} { } return slice.Interface() } + +// StringSliceToInterfaceSlice converts ss to []interface{}. +func StringSliceToInterfaceSlice(ss []string) []interface{} { + result := make([]interface{}, len(ss)) + for i, s := range ss { + result[i] = s + } + return result + +} diff --git a/common/herrors/errors.go b/common/herrors/errors.go index fded30b1a..00aed1eb6 100644 --- a/common/herrors/errors.go +++ b/common/herrors/errors.go @@ -88,3 +88,10 @@ func GetGID() uint64 { // We will, at least to begin with, make some Hugo features (SCSS with libsass) optional, // and this error is used to signal those situations. var ErrFeatureNotAvailable = errors.New("this feature is not available in your current Hugo version, see https://goo.gl/YMrWcn for more information") + +// Must panics if err != nil. +func Must(err error) { + if err != nil { + panic(err) + } +} diff --git a/common/hexec/exec.go b/common/hexec/exec.go new file mode 100644 index 000000000..a8bdd1bb7 --- /dev/null +++ b/common/hexec/exec.go @@ -0,0 +1,276 @@ +// Copyright 2020 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 ( + "bytes" + "context" + "errors" + "fmt" + "io" + "regexp" + "strings" + + "os" + "os/exec" + + "github.com/cli/safeexec" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/security" +) + +var WithDir = func(dir string) func(c *commandeer) { + return func(c *commandeer) { + c.dir = dir + } +} + +var WithContext = func(ctx context.Context) func(c *commandeer) { + return func(c *commandeer) { + c.ctx = ctx + } +} + +var WithStdout = func(w io.Writer) func(c *commandeer) { + return func(c *commandeer) { + c.stdout = w + } +} + +var WithStderr = func(w io.Writer) func(c *commandeer) { + return func(c *commandeer) { + c.stderr = w + } +} + +var WithStdin = func(r io.Reader) func(c *commandeer) { + return func(c *commandeer) { + c.stdin = r + } +} + +var WithEnviron = func(env []string) func(c *commandeer) { + return func(c *commandeer) { + setOrAppend := func(s string) { + k1, _ := config.SplitEnvVar(s) + var found bool + for i, v := range c.env { + k2, _ := config.SplitEnvVar(v) + if k1 == k2 { + found = true + c.env[i] = s + } + } + + if !found { + c.env = append(c.env, s) + } + } + + for _, s := range env { + setOrAppend(s) + } + } +} + +// New creates a new Exec using the provided security config. +func New(cfg security.Config) *Exec { + var baseEnviron []string + for _, v := range os.Environ() { + k, _ := config.SplitEnvVar(v) + if cfg.Exec.OsEnv.Accept(k) { + baseEnviron = append(baseEnviron, v) + } + } + + return &Exec{ + sc: cfg, + baseEnviron: baseEnviron, + } +} + +// IsNotFound reports whether this is an error about a binary not found. +func IsNotFound(err error) bool { + var notFoundErr *NotFoundError + return errors.As(err, ¬FoundErr) +} + +// SafeCommand is a wrapper around os/exec Command which uses a LookPath +// implementation that does not search in current directory before looking in PATH. +// See https://github.com/cli/safeexec and the linked issues. +func SafeCommand(name string, arg ...string) (*exec.Cmd, error) { + bin, err := safeexec.LookPath(name) + if err != nil { + return nil, err + } + + return exec.Command(bin, arg...), nil +} + +// Exec encorces a security policy for commands run via os/exec. +type Exec struct { + sc security.Config + + // os.Environ filtered by the Exec.OsEnviron whitelist filter. + baseEnviron []string +} + +// New will fail if name is not allowed according to the configured security policy. +// Else a configured Runner will be returned ready to be Run. +func (e *Exec) New(name string, arg ...interface{}) (Runner, error) { + if err := e.sc.CheckAllowedExec(name); err != nil { + return nil, err + } + + env := make([]string, len(e.baseEnviron)) + copy(env, e.baseEnviron) + + cm := &commandeer{ + name: name, + env: env, + } + + return cm.command(arg...) + +} + +// Npx is a convenience method to create a Runner running npx --no-install <name> <args. +func (e *Exec) Npx(name string, arg ...interface{}) (Runner, error) { + arg = append(arg[:0], append([]interface{}{"--no-install", name}, arg[0:]...)...) + return e.New("npx", arg...) +} + +// Sec returns the security policies this Exec is configured with. +func (e *Exec) Sec() security.Config { + return e.sc +} + +type NotFoundError struct { + name string +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("binary with name %q not found", e.name) +} + +// Runner wraps a *os.Cmd. +type Runner interface { + Run() error + StdinPipe() (io.WriteCloser, error) +} + +type cmdWrapper struct { + name string + c *exec.Cmd + + outerr *bytes.Buffer +} + +var notFoundRe = regexp.MustCompile(`(?s)not found:|could not determine executable`) + +func (c *cmdWrapper) Run() error { + err := c.c.Run() + if err == nil { + return nil + } + if notFoundRe.MatchString(c.outerr.String()) { + return &NotFoundError{name: c.name} + } + return fmt.Errorf("failed to execute binary %q with args %v: %s", c.name, c.c.Args[1:], c.outerr.String()) +} + +func (c *cmdWrapper) StdinPipe() (io.WriteCloser, error) { + return c.c.StdinPipe() +} + +type commandeer struct { + stdout io.Writer + stderr io.Writer + stdin io.Reader + dir string + ctx context.Context + + name string + env []string +} + +func (c *commandeer) command(arg ...interface{}) (*cmdWrapper, error) { + if c == nil { + return nil, nil + } + + var args []string + for _, a := range arg { + switch v := a.(type) { + case string: + args = append(args, v) + case func(*commandeer): + v(c) + default: + return nil, fmt.Errorf("invalid argument to command: %T", a) + } + } + + bin, err := safeexec.LookPath(c.name) + if err != nil { + return nil, &NotFoundError{ + name: c.name, + } + } + + outerr := &bytes.Buffer{} + if c.stderr == nil { + c.stderr = outerr + } else { + c.stderr = io.MultiWriter(c.stderr, outerr) + } + + var cmd *exec.Cmd + + if c.ctx != nil { + cmd = exec.CommandContext(c.ctx, bin, args...) + } else { + cmd = exec.Command(bin, args...) + } + + cmd.Stdin = c.stdin + cmd.Stderr = c.stderr + cmd.Stdout = c.stdout + cmd.Env = c.env + cmd.Dir = c.dir + + return &cmdWrapper{outerr: outerr, c: cmd, name: c.name}, nil +} + +// InPath reports whether binaryName is in $PATH. +func InPath(binaryName string) bool { + if strings.Contains(binaryName, "/") { + panic("binary name should not contain any slash") + } + _, err := safeexec.LookPath(binaryName) + return err == nil +} + +// LookPath finds the path to binaryName in $PATH. +// Returns "" if not found. +func LookPath(binaryName string) string { + if strings.Contains(binaryName, "/") { + panic("binary name should not contain any slash") + } + s, err := safeexec.LookPath(binaryName) + if err != nil { + return "" + } + return s +} diff --git a/common/hexec/safeCommand.go b/common/hexec/safeCommand.go deleted file mode 100644 index 6d5c73982..000000000 --- a/common/hexec/safeCommand.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2020 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 ( - "context" - - "os/exec" - - "github.com/cli/safeexec" -) - -// SafeCommand is a wrapper around os/exec Command which uses a LookPath -// implementation that does not search in current directory before looking in PATH. -// See https://github.com/cli/safeexec and the linked issues. -func SafeCommand(name string, arg ...string) (*exec.Cmd, error) { - bin, err := safeexec.LookPath(name) - if err != nil { - return nil, err - } - - return exec.Command(bin, arg...), nil -} - -// SafeCommandContext wraps CommandContext -// See SafeCommand for more context. -func SafeCommandContext(ctx context.Context, name string, arg ...string) (*exec.Cmd, error) { - bin, err := safeexec.LookPath(name) - if err != nil { - return nil, err - } - - return exec.CommandContext(ctx, bin, arg...), nil -} diff --git a/common/hugo/hugo.go b/common/hugo/hugo.go index 4548de93a..d8f92e298 100644 --- a/common/hugo/hugo.go +++ b/common/hugo/hugo.go @@ -89,8 +89,10 @@ func NewInfo(environment string) Info { } } +// GetExecEnviron creates and gets the common os/exec environment used in the +// external programs we interact with via os/exec, e.g. postcss. func GetExecEnviron(workDir string, cfg config.Provider, fs afero.Fs) []string { - env := os.Environ() + var env []string nodepath := filepath.Join(workDir, "node_modules") if np := os.Getenv("NODE_PATH"); np != "" { nodepath = workDir + string(os.PathListSeparator) + np @@ -98,12 +100,15 @@ func GetExecEnviron(workDir string, cfg config.Provider, fs afero.Fs) []string { config.SetEnvVars(&env, "NODE_PATH", nodepath) config.SetEnvVars(&env, "PWD", workDir) config.SetEnvVars(&env, "HUGO_ENVIRONMENT", cfg.GetString("environment")) - fis, err := afero.ReadDir(fs, files.FolderJSConfig) - if err == nil { - for _, fi := range fis { - key := fmt.Sprintf("HUGO_FILE_%s", strings.ReplaceAll(strings.ToUpper(fi.Name()), ".", "_")) - value := fi.(hugofs.FileMetaInfo).Meta().Filename - config.SetEnvVars(&env, key, value) + + if fs != nil { + fis, err := afero.ReadDir(fs, files.FolderJSConfig) + if err == nil { + for _, fi := range fis { + key := fmt.Sprintf("HUGO_FILE_%s", strings.ReplaceAll(strings.ToUpper(fi.Name()), ".", "_")) + value := fi.(hugofs.FileMetaInfo).Meta().Filename + config.SetEnvVars(&env, key, value) + } } } diff --git a/config/defaultConfigProvider.go b/config/defaultConfigProvider.go index 0a10d5cc6..7701e765a 100644 --- a/config/defaultConfigProvider.go +++ b/config/defaultConfigProvider.go @@ -44,6 +44,8 @@ var ( "permalinks": true, "related": true, "sitemap": true, + "privacy": true, + "security": true, "taxonomies": true, } diff --git a/config/security/docshelper.go b/config/security/docshelper.go new file mode 100644 index 000000000..ade03560e --- /dev/null +++ b/config/security/docshelper.go @@ -0,0 +1,26 @@ +// Copyright 2021 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 security + +import ( + "github.com/gohugoio/hugo/docshelper" +) + +func init() { + docsProvider := func() docshelper.DocProvider { + + return docshelper.DocProvider{"config": DefaultConfig.ToSecurityMap()} + } + docshelper.AddDocProviderFunc(docsProvider) +} diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go new file mode 100644 index 000000000..09c5cb625 --- /dev/null +++ b/config/security/securityConfig.go @@ -0,0 +1,227 @@ +// Copyright 2018 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 security + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/types" + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/parser" + "github.com/gohugoio/hugo/parser/metadecoders" + "github.com/mitchellh/mapstructure" +) + +const securityConfigKey = "security" + +// DefaultConfig holds the default security policy. +var DefaultConfig = Config{ + Exec: Exec{ + Allow: NewWhitelist( + "^dart-sass-embedded$", + "^go$", // for Go Modules + "^npx$", // used by all Node tools (Babel, PostCSS). + "^postcss$", + ), + // These have been tested to work with Hugo's external programs + // on Windows, Linux and MacOS. + OsEnv: NewWhitelist("(?i)^(PATH|PATHEXT|APPDATA|TMP|TEMP|TERM)$"), + }, + Funcs: Funcs{ + Getenv: NewWhitelist("^HUGO_"), + }, + HTTP: HTTP{ + URLs: NewWhitelist(".*"), + Methods: NewWhitelist("(?i)GET|POST"), + }, +} + +// Config is the top level security config. |