mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 15:28:54 +00:00
config/security: Add "! " negation to Whitelist, harden default http.urls
Whitelist now treats any pattern prefixed with "! " (the same negation
prefix used by hglob/predicate) as a deny rule. Deny matches take
precedence over allow, and a whitelist made up exclusively of deny
rules implicitly allows everything it does not deny.
The default security.http.urls now reads:
urls = ['(?i)^https?://[a-z]', '! (?i)localhost', '! @']
i.e. allow URLs whose host starts with a letter (the common
"https://example.com" shape), deny anything that looks like localhost,
and deny URLs with userinfo to foil "http://user@127.0.0.1/" bypasses.
Public IP literals are collateral blocks; users who need them (or their
own private hosts) override security.http.urls as before, mixing allow
and deny rules with the same "! " prefix, e.g.
[security.http]
urls = ['.*', '! ^https?://evil\.example\.com']
Fixes #14792
This commit is contained in:
@@ -51,7 +51,16 @@ var DefaultConfig = Config{
|
||||
Getenv: MustNewWhitelist("^HUGO_", "^CI$"),
|
||||
},
|
||||
HTTP: HTTP{
|
||||
URLs: MustNewWhitelist(".*"),
|
||||
// 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-z]`,
|
||||
`! (?i)localhost`,
|
||||
`! @`,
|
||||
),
|
||||
Methods: MustNewWhitelist("(?i)GET|POST"),
|
||||
},
|
||||
Node: Node{
|
||||
|
||||
@@ -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$', '^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",
|
||||
"[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 = ['(?i)^https?://[a-z]', '! (?i)localhost', '! @']\n\n [security.node]\n [security.node.permissions]\n allowAddons = ['tailwindcss']\n allowRead = ['.']\n allowWorker = ['tailwindcss']\n allowWrite = []\n disable = false",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -170,6 +170,83 @@ func TestDecodeConfigDefault(t *testing.T) {
|
||||
c.Assert(pc.Node.Permissions.AllowWrite, qt.DeepEquals, []string{})
|
||||
}
|
||||
|
||||
func TestCheckAllowedHTTPURLHardenedDefaultsIssue14792(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := qt.New(t)
|
||||
|
||||
c.Run("Public URLs allowed by default", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
pc, err := DecodeConfig(config.New())
|
||||
c.Assert(err, qt.IsNil)
|
||||
for _, u := range []string{
|
||||
"https://example.org/",
|
||||
"https://example.org:8443/foo",
|
||||
"https://sub.example.org/path",
|
||||
} {
|
||||
c.Assert(pc.CheckAllowedHTTPURL(u), qt.IsNil, qt.Commentf(u))
|
||||
}
|
||||
})
|
||||
|
||||
c.Run("Private/loopback URLs denied by default", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
pc, err := DecodeConfig(config.New())
|
||||
c.Assert(err, qt.IsNil)
|
||||
for _, u := range []string{
|
||||
"http://localhost/",
|
||||
"http://LOCALHOST:8080/",
|
||||
"http://foo.localhost/",
|
||||
"http://127.0.0.1/",
|
||||
"http://127.1.2.3:8080/x",
|
||||
"http://user:pass@127.0.0.1/", // userinfo must not sneak past the deny.
|
||||
"http://10.0.0.1/",
|
||||
"http://172.16.0.1/",
|
||||
"http://192.168.1.1/",
|
||||
"http://169.254.169.254/latest/meta-data/", // AWS/GCP metadata.
|
||||
"http://0.0.0.0/",
|
||||
"http://[::1]/",
|
||||
"http://[fe80::1]/",
|
||||
"http://[fc00::1]/",
|
||||
// Public IP literals are blocked as collateral; users can override.
|
||||
"http://93.184.216.34/",
|
||||
"https://[2001:db8::1]/",
|
||||
} {
|
||||
err := pc.CheckAllowedHTTPURL(u)
|
||||
c.Assert(err, qt.IsNotNil, qt.Commentf(u))
|
||||
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`, qt.Commentf(u))
|
||||
}
|
||||
})
|
||||
|
||||
c.Run("Explicit user config bypasses hardening", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
tomlConfig := `
|
||||
[security.http]
|
||||
urls = ['http://127\.0\.0\.1.*', 'http://localhost.*']
|
||||
`
|
||||
cfg, err := config.FromConfigString(tomlConfig, "toml")
|
||||
c.Assert(err, qt.IsNil)
|
||||
pc, err := DecodeConfig(cfg)
|
||||
c.Assert(err, qt.IsNil)
|
||||
c.Assert(pc.CheckAllowedHTTPURL("http://127.0.0.1:8080/foo"), qt.IsNil)
|
||||
c.Assert(pc.CheckAllowedHTTPURL("http://localhost:1313/"), qt.IsNil)
|
||||
})
|
||||
|
||||
c.Run("User can deny with the ! prefix", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
tomlConfig := `
|
||||
[security.http]
|
||||
urls = ['.*', '! ^https?://evil\.example\.com']
|
||||
`
|
||||
cfg, err := config.FromConfigString(tomlConfig, "toml")
|
||||
c.Assert(err, qt.IsNil)
|
||||
pc, err := DecodeConfig(cfg)
|
||||
c.Assert(err, qt.IsNil)
|
||||
c.Assert(pc.CheckAllowedHTTPURL("https://good.example.com/"), qt.IsNil)
|
||||
err = pc.CheckAllowedHTTPURL("https://evil.example.com/x")
|
||||
c.Assert(err, qt.IsNotNil)
|
||||
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecodeConfigNodePermissions(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
|
||||
|
||||
@@ -18,18 +18,25 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gohugoio/hugo/hugofs/hglob"
|
||||
)
|
||||
|
||||
const (
|
||||
acceptNoneKeyword = "none"
|
||||
)
|
||||
const acceptNoneKeyword = "none"
|
||||
|
||||
// Whitelist holds a whitelist.
|
||||
//
|
||||
// Patterns are regular expressions. A pattern prefixed with "! "
|
||||
// (see hglob.NegationPrefix) is a deny rule: a name that matches any
|
||||
// deny rule is rejected even if it matches an allow rule.
|
||||
// A whitelist made up exclusively of deny rules implicitly allows
|
||||
// names that do not match any of them.
|
||||
type Whitelist struct {
|
||||
acceptNone bool
|
||||
patterns []*regexp.Regexp
|
||||
allow []*regexp.Regexp
|
||||
deny []*regexp.Regexp
|
||||
|
||||
// Store this for debugging/error reporting
|
||||
// Store this for debugging/error reporting.
|
||||
patternsStrings []string
|
||||
}
|
||||
|
||||
@@ -44,14 +51,17 @@ func (w Whitelist) MarshalJSON() ([]byte, error) {
|
||||
|
||||
// NewWhitelist creates a new Whitelist from zero or more patterns.
|
||||
// An empty patterns list or a pattern with the value 'none' will create
|
||||
// a whitelist that will Accept none.
|
||||
// a whitelist that will Accept none. Patterns prefixed with "! " act as
|
||||
// deny rules; see Whitelist.
|
||||
func NewWhitelist(patterns ...string) (Whitelist, error) {
|
||||
if len(patterns) == 0 {
|
||||
return Whitelist{acceptNone: true}, nil
|
||||
}
|
||||
|
||||
var acceptSome bool
|
||||
var patternsStrings []string
|
||||
var (
|
||||
acceptSome bool
|
||||
patternsStrings []string
|
||||
)
|
||||
|
||||
for _, p := range patterns {
|
||||
if p == acceptNoneKeyword {
|
||||
@@ -66,26 +76,28 @@ func NewWhitelist(patterns ...string) (Whitelist, error) {
|
||||
}
|
||||
|
||||
if !acceptSome {
|
||||
return Whitelist{
|
||||
acceptNone: true,
|
||||
}, nil
|
||||
return Whitelist{acceptNone: true}, nil
|
||||
}
|
||||
|
||||
var patternsr []*regexp.Regexp
|
||||
|
||||
for i := range patterns {
|
||||
p := strings.TrimSpace(patterns[i])
|
||||
if p == "" {
|
||||
continue
|
||||
var allow, deny []*regexp.Regexp
|
||||
for _, p := range patternsStrings {
|
||||
raw := p
|
||||
negate := strings.HasPrefix(p, hglob.NegationPrefix)
|
||||
if negate {
|
||||
raw = p[len(hglob.NegationPrefix):]
|
||||
}
|
||||
re, err := regexp.Compile(p)
|
||||
re, err := regexp.Compile(raw)
|
||||
if err != nil {
|
||||
return Whitelist{}, fmt.Errorf("failed to compile whitelist pattern %q: %w", p, err)
|
||||
}
|
||||
patternsr = append(patternsr, re)
|
||||
if negate {
|
||||
deny = append(deny, re)
|
||||
} else {
|
||||
allow = append(allow, re)
|
||||
}
|
||||
}
|
||||
|
||||
return Whitelist{patterns: patternsr, patternsStrings: patternsStrings}, nil
|
||||
return Whitelist{allow: allow, deny: deny, patternsStrings: patternsStrings}, nil
|
||||
}
|
||||
|
||||
// MustNewWhitelist creates a new Whitelist from zero or more patterns and panics on error.
|
||||
@@ -103,7 +115,19 @@ func (w Whitelist) Accept(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, p := range w.patterns {
|
||||
for _, p := range w.deny {
|
||||
if p.MatchString(name) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if len(w.allow) == 0 {
|
||||
// A whitelist with only deny rules implicitly allows everything
|
||||
// that is not denied. An empty (zero-value) whitelist rejects.
|
||||
return len(w.deny) > 0
|
||||
}
|
||||
|
||||
for _, p := range w.allow {
|
||||
if p.MatchString(name) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -43,4 +43,25 @@ func TestWhitelist(t *testing.T) {
|
||||
c.Assert(w.Accept("bar"), qt.IsTrue)
|
||||
c.Assert(w.Accept("mbar"), qt.IsFalse)
|
||||
})
|
||||
|
||||
c.Run("Negation takes precedence", func(c *qt.C) {
|
||||
w := MustNewWhitelist(".*", "! ^foo")
|
||||
c.Assert(w.Accept("bar"), qt.IsTrue)
|
||||
c.Assert(w.Accept("foo"), qt.IsFalse)
|
||||
c.Assert(w.Accept("foobar"), qt.IsFalse)
|
||||
})
|
||||
|
||||
c.Run("Negation only", func(c *qt.C) {
|
||||
// A whitelist with only deny rules accepts everything else.
|
||||
w := MustNewWhitelist("! ^foo")
|
||||
c.Assert(w.Accept("bar"), qt.IsTrue)
|
||||
c.Assert(w.Accept("foo"), qt.IsFalse)
|
||||
})
|
||||
|
||||
c.Run("Bad pattern", func(c *qt.C) {
|
||||
_, err := NewWhitelist("[invalid")
|
||||
c.Assert(err, qt.IsNotNil)
|
||||
_, err = NewWhitelist("! [invalid")
|
||||
c.Assert(err, qt.IsNotNil)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ func TestResourceChainBasic(t *testing.T) {
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
baseURL = "http://example.com/"
|
||||
[security.http]
|
||||
urls = ['.*']
|
||||
-- assets/images/sunset.jpg --
|
||||
` + getTestSunset(t) + `
|
||||
-- layouts/home.html --
|
||||
@@ -1329,6 +1331,8 @@ Template test.
|
||||
|
||||
files := test.files
|
||||
files = strings.ReplaceAll(files, "HTTPTEST_SERVER_URL", ts.URL)
|
||||
files = strings.Replace(files, `baseURL = "http://example.com/"`,
|
||||
"baseURL = \"http://example.com/\"\n[security.http]\nurls = ['.*']", 1)
|
||||
|
||||
b := Test(t, files)
|
||||
|
||||
|
||||
@@ -151,6 +151,9 @@ allow="none"
|
||||
files := fmt.Sprintf(`
|
||||
-- hugo.toml --
|
||||
baseURL = "https://example.org"
|
||||
[security]
|
||||
[security.http]
|
||||
urls = ['.*']
|
||||
-- layouts/home.html --
|
||||
{{ $json := resources.GetRemote "%s/fruits.json" }}{{ $json.Content }}
|
||||
`, ts.URL)
|
||||
@@ -166,6 +169,9 @@ baseURL = "https://example.org"
|
||||
files := fmt.Sprintf(`
|
||||
-- hugo.toml --
|
||||
baseURL = "https://example.org"
|
||||
[security]
|
||||
[security.http]
|
||||
urls = ['.*']
|
||||
-- layouts/home.html --
|
||||
{{ $json := resources.GetRemote "%s/fruits.json" (dict "method" "DELETE" ) }}{{ $json.Content }}
|
||||
`, ts.URL)
|
||||
@@ -194,6 +200,23 @@ urls="none"
|
||||
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`)
|
||||
})
|
||||
|
||||
c.Run("resources.GetRemote, denied loopback URL by default", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
ts := httptest.NewServer(http.FileServer(http.Dir("testdata/")))
|
||||
c.Cleanup(func() {
|
||||
ts.Close()
|
||||
})
|
||||
files := fmt.Sprintf(`
|
||||
-- hugo.toml --
|
||||
baseURL = "https://example.org"
|
||||
-- layouts/home.html --
|
||||
{{ $json := resources.GetRemote "%s/fruits.json" }}{{ $json.Content }}
|
||||
`, ts.URL)
|
||||
_, err := TestE(c, files)
|
||||
c.Assert(err, qt.IsNotNil)
|
||||
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`)
|
||||
})
|
||||
|
||||
c.Run("resources.GetRemote, fake JSON", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
ts := httptest.NewServer(http.FileServer(http.Dir("testdata/")))
|
||||
@@ -204,6 +227,8 @@ urls="none"
|
||||
-- hugo.toml --
|
||||
baseURL = "https://example.org"
|
||||
[security]
|
||||
[security.http]
|
||||
urls = ['.*']
|
||||
-- layouts/home.html --
|
||||
{{ $json := resources.GetRemote "%s/fakejson.json" }}{{ $json.Content }}
|
||||
`, ts.URL)
|
||||
@@ -223,6 +248,7 @@ baseURL = "https://example.org"
|
||||
baseURL = "https://example.org"
|
||||
[security]
|
||||
[security.http]
|
||||
urls = ['.*']
|
||||
mediaTypes=["application/json"]
|
||||
-- layouts/home.html --
|
||||
{{ $json := resources.GetRemote "%s/fakejson.json" }}{{ $json.Content }}
|
||||
|
||||
@@ -273,7 +273,9 @@ func TestUnmarshalRefRemote(t *testing.T) {
|
||||
})
|
||||
|
||||
dataPartRef := ts.URL + "/api/messages/dataPart.json"
|
||||
return strings.ReplaceAll(refLocalTemplate, "DATAPART_REF", dataPartRef)
|
||||
files := strings.ReplaceAll(refLocalTemplate, "DATAPART_REF", dataPartRef)
|
||||
return strings.Replace(files, "baseURL = 'http://example.com/'",
|
||||
"baseURL = 'http://example.com/'\n[security.http]\nurls = ['.*']", 1)
|
||||
}
|
||||
|
||||
t.Run("Build", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user