diff options
author | Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com> | 2021-12-12 12:11:11 +0100 |
---|---|---|
committer | Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com> | 2021-12-16 09:40:22 +0100 |
commit | f4389e48ce0a70807362772d66c12ab5cd9e15f8 (patch) | |
tree | 1334516a199dcdf4133758e3664348287e73e88b | |
parent | 803f572e66c5e22213ddcc994c41b3e80e9c1f35 (diff) |
Add some basic security policies with sensible defaults
This ommmit contains some security hardening measures for the Hugo build runtime.
There are some rarely used features in Hugo that would be good to have disabled by default. One example would be the "external helpers".
For `asciidoctor` and some others we use Go's `os/exec` package to start a new process.
These are a predefined set of binary names, all loaded from `PATH` and with a predefined set of arguments. Still, if you don't use `asciidoctor` in your project, you might as well have it turned off.
You can configure your own in the new `security` configuration section, but the defaults are configured to create a minimal amount of site breakage. And if that do happen, you will get clear instructions in the loa about what to do.
The default configuration is listed below. Note that almost all of these options are regular expression _whitelists_ (a string or a slice); the value `none` will block all.
```toml
[security]
enableInlineShortcodes = false
[security.exec]
allow = ['^dart-sass-embedded$', '^go$', '^npx$', '^postcss$']
osEnv = ['(?i)^(PATH|PATHEXT|APPDATA|TMP|TEMP|TERM)$']
[security.funcs]
getenv = ['^HUGO_']
[security.http]
methods = ['(?i)GET|POST']
urls = ['.*']
```
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/l |