mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 15:28:54 +00:00
8a55df7af2
Fixes #15178 Closes #15171
394 lines
11 KiB
Go
394 lines
11 KiB
Go
// 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"
|
||
"net/netip"
|
||
"net/url"
|
||
"reflect"
|
||
"slices"
|
||
"strconv"
|
||
"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: MustNewWhitelist(
|
||
"^(dart-)?sass$", // sass, dart-sass
|
||
"^go$", // for Go Modules
|
||
"^git$", // For Git info
|
||
"^node$", // Used as the runtime for Node tools.
|
||
"^postcss$",
|
||
),
|
||
// These have been tested to work with Hugo's external programs
|
||
// on Windows, Linux and MacOS.
|
||
OsEnv: MustNewWhitelist(`(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE|PROGRAMDATA)$`),
|
||
},
|
||
Funcs: Funcs{
|
||
Getenv: MustNewWhitelist("^HUGO_", "^CI$"),
|
||
},
|
||
HTTP: HTTP{
|
||
// Allow URLs whose host starts with a letter (the typical
|
||
// "https://example.com" shape), deny anything that looks like
|
||
// localhost, and deny URLs with userinfo ("http://user@...") to
|
||
// foil the obvious SSRF bypass. Public IP literals are collateral
|
||
// blocks; users who need them can override security.http.urls.
|
||
URLs: MustNewWhitelist(
|
||
`(?i)^https?://[a-z0-9]`,
|
||
`! ^https?://\d+\.`,
|
||
`! (?i)localhost`,
|
||
`! (?i)^https?://[^/?#]*@`,
|
||
),
|
||
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.
|
||
AllowChildProcess: []string{"tailwindcss"}, // detect-libc spawns getconf on some Linux setups.
|
||
},
|
||
},
|
||
// Content under /content is treated as untrusted. text/html bodies are
|
||
// emitted verbatim and are an XSS sink, so they are denied by default.
|
||
// Everything else is allowed because Whitelist treats a deny-only list as
|
||
// "allow anything not denied".
|
||
AllowContent: MustNewWhitelist("! ^text/html$"),
|
||
}
|
||
|
||
// Config is the top level security config.
|
||
// <docsmeta>{"name": "security", "description": "This section holds the top level security config.", "newIn": "0.91.0" }</docsmeta>
|
||
type Config struct {
|
||
// Restricts access to os.Exec....
|
||
// <docsmeta>{ "newIn": "0.91.0" }</docsmeta>
|
||
Exec Exec `json:"exec"`
|
||
|
||
// Restricts access to certain template funcs.
|
||
Funcs Funcs `json:"funcs"`
|
||
|
||
// Restricts access to resources.GetRemote, getJSON, getCSV.
|
||
HTTP HTTP `json:"http"`
|
||
|
||
// Node holds Node.js security settings.
|
||
Node Node `json:"node"`
|
||
|
||
// AllowContent restricts which content media types may be used for
|
||
// pages under /content. Matched against the full MIME type (e.g.
|
||
// "text/html"). text/html is denied by default because Hugo emits the
|
||
// body verbatim.
|
||
AllowContent Whitelist `json:"allowContent"`
|
||
|
||
// Allow inline shortcodes
|
||
EnableInlineShortcodes bool `json:"enableInlineShortcodes"`
|
||
}
|
||
|
||
// Exec holds os/exec policies.
|
||
type Exec struct {
|
||
Allow Whitelist `json:"allow"`
|
||
OsEnv Whitelist `json:"osEnv"`
|
||
}
|
||
|
||
// Funcs holds template funcs policies.
|
||
type Funcs struct {
|
||
// OS env keys allowed to query in os.Getenv.
|
||
Getenv Whitelist `json:"getenv"`
|
||
}
|
||
|
||
type HTTP struct {
|
||
// URLs to allow in remote HTTP (resources.Get, getJSON, getCSV).
|
||
URLs Whitelist `json:"urls"`
|
||
|
||
// HTTP methods to allow.
|
||
Methods Whitelist `json:"methods"`
|
||
|
||
// Media types where the Content-Type in the response is used instead of resolving from the file content.
|
||
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"`
|
||
AllowChildProcess []string `json:"allowChildProcess"`
|
||
}
|
||
|
||
// 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()
|
||
|
||
var b bytes.Buffer
|
||
|
||
if err := parser.InterfaceToConfig(sec, metadecoders.TOML, &b); err != nil {
|
||
panic(err)
|
||
}
|
||
|
||
return strings.TrimSpace(b.String())
|
||
}
|
||
|
||
func (c Config) CheckAllowedExec(name string) error {
|
||
if !c.Exec.Allow.Accept(name) {
|
||
return &AccessDeniedError{
|
||
name: name,
|
||
path: "security.exec.allow",
|
||
policies: c.ToTOML(),
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c Config) CheckAllowedGetEnv(name string) error {
|
||
if !c.Funcs.Getenv.Accept(name) {
|
||
return &AccessDeniedError{
|
||
name: name,
|
||
path: "security.funcs.getenv",
|
||
policies: c.ToTOML(),
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c Config) CheckAllowedHTTPURL(u string) error {
|
||
deny := func(name string) error {
|
||
return &AccessDeniedError{
|
||
name: name,
|
||
path: "security.http.urls",
|
||
policies: c.ToTOML(),
|
||
}
|
||
}
|
||
if !c.HTTP.URLs.Accept(u) {
|
||
return deny(u)
|
||
}
|
||
// A host can be written as an integer/hex/octal IPv4 literal
|
||
// (e.g. http://2130706433/ == http://127.0.0.1/) that has no dot and
|
||
// thus slips past IP-literal deny rules. Re-check the canonical form so
|
||
// the policy treats every encoding of the same address alike.
|
||
if canon, ok := canonicalIPv4URL(u); ok && !c.HTTP.URLs.Accept(canon) {
|
||
return deny(u)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// canonicalIPv4URL rewrites an integer/hex/octal IPv4 host in rawURL to its
|
||
// canonical dotted-decimal form (inet_aton semantics), returning ok=false when
|
||
// the host is a normal name or already dotted-decimal.
|
||
func canonicalIPv4URL(rawURL string) (string, bool) {
|
||
u, err := url.Parse(rawURL)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
host := u.Hostname()
|
||
ip, ok := parseInetAtonIPv4(host)
|
||
if !ok || ip.String() == host {
|
||
return "", false
|
||
}
|
||
if port := u.Port(); port != "" {
|
||
u.Host = ip.String() + ":" + port
|
||
} else {
|
||
u.Host = ip.String()
|
||
}
|
||
return u.String(), true
|
||
}
|
||
|
||
// parseInetAtonIPv4 parses the inet_aton IPv4 forms (1–4 dot-separated parts,
|
||
// each decimal, octal "0..." or hex "0x..."), e.g. "2130706433", "0x7f.0.0.1".
|
||
func parseInetAtonIPv4(host string) (netip.Addr, bool) {
|
||
if host == "" {
|
||
return netip.Addr{}, false
|
||
}
|
||
parts := strings.Split(host, ".")
|
||
if len(parts) > 4 {
|
||
return netip.Addr{}, false
|
||
}
|
||
vals := make([]uint64, len(parts))
|
||
for i, p := range parts {
|
||
v, ok := parseCInt(p)
|
||
if !ok {
|
||
return netip.Addr{}, false
|
||
}
|
||
vals[i] = v
|
||
}
|
||
maxLast := []uint64{0xffffffff, 0xffffff, 0xffff, 0xff}[len(parts)-1]
|
||
var n uint64
|
||
for i, v := range vals {
|
||
if i == len(parts)-1 {
|
||
if v > maxLast {
|
||
return netip.Addr{}, false
|
||
}
|
||
n |= v
|
||
} else {
|
||
if v > 0xff {
|
||
return netip.Addr{}, false
|
||
}
|
||
n |= v << (8 * (3 - i))
|
||
}
|
||
}
|
||
return netip.AddrFrom4([4]byte{byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)}), true
|
||
}
|
||
|
||
func parseCInt(s string) (uint64, bool) {
|
||
base := 10
|
||
switch {
|
||
case len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X'):
|
||
base, s = 16, s[2:]
|
||
case len(s) >= 2 && s[0] == '0':
|
||
base, s = 8, s[1:]
|
||
}
|
||
if s == "" {
|
||
return 0, false
|
||
}
|
||
v, err := strconv.ParseUint(s, base, 64)
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
return v, true
|
||
}
|
||
|
||
func (c Config) CheckAllowedHTTPMethod(method string) error {
|
||
if !c.HTTP.Methods.Accept(method) {
|
||
return &AccessDeniedError{
|
||
name: method,
|
||
path: "security.http.method",
|
||
policies: c.ToTOML(),
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c Config) CheckAllowedContent(mediaType string) error {
|
||
if !c.AllowContent.Accept(mediaType) {
|
||
return &AccessDeniedError{
|
||
name: mediaType,
|
||
path: "security.allowContent",
|
||
policies: c.ToTOML(),
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ToSecurityMap converts c to a map with 'security' as the root key.
|
||
func (c Config) ToSecurityMap() map[string]any {
|
||
// Take it to JSON and back to get proper casing etc.
|
||
asJson, err := json.Marshal(c)
|
||
herrors.Must(err)
|
||
m := make(map[string]any)
|
||
herrors.Must(json.Unmarshal(asJson, &m))
|
||
|
||
// Add the root
|
||
sec := map[string]any{
|
||
"security": m,
|
||
}
|
||
return sec
|
||
}
|
||
|
||
// 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)
|
||
sc.Node.Permissions.AllowChildProcess = slices.Clone(sc.Node.Permissions.AllowChildProcess)
|
||
if cfg.IsSet(securityConfigKey) {
|
||
m := cfg.GetStringMap(securityConfigKey)
|
||
dec, err := mapstructure.NewDecoder(
|
||
&mapstructure.DecoderConfig{
|
||
WeaklyTypedInput: true,
|
||
Result: &sc,
|
||
DecodeHook: stringSliceToWhitelistHook(),
|
||
},
|
||
)
|
||
if err != nil {
|
||
return sc, err
|
||
}
|
||
|
||
if err = dec.Decode(m); err != nil {
|
||
return sc, err
|
||
}
|
||
}
|
||
|
||
if !sc.EnableInlineShortcodes {
|
||
// Legacy
|
||
sc.EnableInlineShortcodes = cfg.GetBool("enableInlineShortcodes")
|
||
}
|
||
|
||
return sc, nil
|
||
}
|
||
|
||
func stringSliceToWhitelistHook() mapstructure.DecodeHookFuncType {
|
||
return func(
|
||
f reflect.Type,
|
||
t reflect.Type,
|
||
data any,
|
||
) (any, error) {
|
||
if t != reflect.TypeFor[Whitelist]() {
|
||
return data, nil
|
||
}
|
||
|
||
wl := types.ToStringSlicePreserveString(data)
|
||
|
||
return NewWhitelist(wl...)
|
||
}
|
||
}
|
||
|
||
// AccessDeniedError represents a security policy conflict.
|
||
type AccessDeniedError struct {
|
||
path string
|
||
name string
|
||
policies string
|
||
}
|
||
|
||
func (e *AccessDeniedError) Error() string {
|
||
return fmt.Sprintf("access denied: %q is not whitelisted in policy %q; the current security configuration is:\n\n%s\n\n", e.name, e.path, e.policies)
|
||
}
|
||
|
||
// IsAccessDenied reports whether err is an AccessDeniedError
|
||
func IsAccessDenied(err error) bool {
|
||
var notFoundErr *AccessDeniedError
|
||
return errors.As(err, ¬FoundErr)
|
||
}
|