From: Bjørn Erik Pedersen Date: Wed, 22 Apr 2026 14:25:38 +0000 (+0200) Subject: config/security: Add "! " negation to Whitelist, harden default http.urls X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=79f030be5bdb31e014c2996da7464898df750801;p=brevno-suite%2Fhugo 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 --- diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go index 8ba51a981..3ba397f6f 100644 --- a/config/security/securityConfig.go +++ b/config/security/securityConfig.go @@ -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{ diff --git a/config/security/securityConfig_test.go b/config/security/securityConfig_test.go index c8599ee41..4b5a12417 100644 --- a/config/security/securityConfig_test.go +++ b/config/security/securityConfig_test.go @@ -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) diff --git a/config/security/whitelist.go b/config/security/whitelist.go index 5ce369a1f..8a4335baa 100644 --- a/config/security/whitelist.go +++ b/config/security/whitelist.go @@ -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 } diff --git a/config/security/whitelist_test.go b/config/security/whitelist_test.go index add3345a8..c78b6fe17 100644 --- a/config/security/whitelist_test.go +++ b/config/security/whitelist_test.go @@ -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) + }) } diff --git a/hugolib/resource_chain_test.go b/hugolib/resource_chain_test.go index efc18147a..b886d0258 100644 --- a/hugolib/resource_chain_test.go +++ b/hugolib/resource_chain_test.go @@ -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) diff --git a/hugolib/securitypolicies_test.go b/hugolib/securitypolicies_test.go index 0656edda5..43b610e92 100644 --- a/hugolib/securitypolicies_test.go +++ b/hugolib/securitypolicies_test.go @@ -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 }} diff --git a/tpl/openapi/openapi3/openapi3_integration_test.go b/tpl/openapi/openapi3/openapi3_integration_test.go index 9040f965b..f8e77af99 100644 --- a/tpl/openapi/openapi3/openapi3_integration_test.go +++ b/tpl/openapi/openapi3/openapi3_integration_test.go @@ -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) {