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:
Bjørn Erik Pedersen
2026-04-22 16:25:38 +02:00
parent 896bc89ab8
commit 79f030be5b
7 changed files with 187 additions and 24 deletions
+10 -1
View File
@@ -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{
+78 -1
View File
@@ -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)
+45 -21
View File
@@ -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
}
+21
View File
@@ -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)
})
}
+4
View File
@@ -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)
+26
View File
@@ -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) {