Skip to content

Commit

Permalink
Add some basic security policies with sensible defaults
Browse files Browse the repository at this point in the history
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 = ['.*']
```
  • Loading branch information
bep committed Dec 16, 2021
1 parent 803f572 commit f4389e4
Show file tree
Hide file tree
Showing 58 changed files with 1,701 additions and 329 deletions.
10 changes: 10 additions & 0 deletions common/collections/slice.go
Expand Up @@ -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

}
7 changes: 7 additions & 0 deletions common/herrors/errors.go
Expand Up @@ -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)
}
}
276 changes: 276 additions & 0 deletions 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, &notFoundErr)
}

// 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
}
45 changes: 0 additions & 45 deletions common/hexec/safeCommand.go

This file was deleted.

19 changes: 12 additions & 7 deletions common/hugo/hugo.go
Expand Up @@ -89,21 +89,26 @@ 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
}
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)
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions config/defaultConfigProvider.go
Expand Up @@ -44,6 +44,8 @@ var (
"permalinks": true,
"related": true,
"sitemap": true,
"privacy": true,
"security": true,
"taxonomies": true,
}

Expand Down

0 comments on commit f4389e4

Please sign in to comment.