]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
config/security: Add "! " negation to Whitelist, harden default http.urls
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 22 Apr 2026 14:25:38 +0000 (16:25 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 22 Apr 2026 18:15:19 +0000 (20:15 +0200)
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

config/security/securityConfig.go
config/security/securityConfig_test.go
config/security/whitelist.go
config/security/whitelist_test.go
hugolib/resource_chain_test.go
hugolib/securitypolicies_test.go
tpl/openapi/openapi3/openapi3_integration_test.go

index 8ba51a981a9b0fe8d3b2bb9f0322f6b9339b75aa..3ba397f6fc8b0d0941d7fac44268b5253eaed2b7 100644 (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{
index c8599ee41ceb27004c8f2c75d4ecadc389dd2878..4b5a12417e248d2dac81f949190804dfe166214c 100644 (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)
 
index 5ce369a1f3839195f6cc904da048c4d63ea75b4d..8a4335baa8657d48f746205f0e8a2a8ed5687123 100644 (file)
@@ -18,18 +18,25 @@ import (
        "fmt"
        "regexp"
        "strings"
-)
 
-const (
-       acceptNoneKeyword = "none"
+       "github.com/gohugoio/hugo/hugofs/hglob"
 )
 
+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
                }
index add3345a8da221b734cee2f520179d80de6e67af..c78b6fe176ad25c0e96c8724df120b075e4971dc 100644 (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)
+       })
 }
index efc18147a482feca52a6c49fda1f8e3e56296d9b..b886d0258a5ea21e832496f8c9da30363c04aef5 100644 (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)
 
index 0656edda50cba3dbfc40fb330c63a5592debc126..43b610e92501e123195e76c59262ce74adf8e8e7 100644 (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 }}
index 9040f965b940bc9c8f2be3aeecc9132ec9fc1e1f..f8e77af99ebd94dbf28939d293e86747e7d5dea9 100644 (file)
@@ -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) {