Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 79f030b

Browse files
committed
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://[email protected]/" 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
1 parent 896bc89 commit 79f030b

7 files changed

Lines changed: 187 additions & 24 deletions

File tree

config/security/securityConfig.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,16 @@ var DefaultConfig = Config{
5151
Getenv: MustNewWhitelist("^HUGO_", "^CI$"),
5252
},
5353
HTTP: HTTP{
54-
URLs: MustNewWhitelist(".*"),
54+
// Allow URLs whose host starts with a letter (the typical
55+
// "https://example.com" shape), deny anything that looks like
56+
// localhost, and deny URLs with userinfo ("http://user@...") to
57+
// foil the obvious SSRF bypass. Public IP literals are collateral
58+
// blocks; users who need them can override security.http.urls.
59+
URLs: MustNewWhitelist(
60+
`(?i)^https?://[a-z]`,
61+
`! (?i)localhost`,
62+
`! @`,
63+
),
5564
Methods: MustNewWhitelist("(?i)GET|POST"),
5665
},
5766
Node: Node{

config/security/securityConfig_test.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ func TestToTOML(t *testing.T) {
135135
got := DefaultConfig.ToTOML()
136136

137137
c.Assert(got, qt.Equals,
138-
"[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",
138+
"[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",
139139
)
140140
}
141141

@@ -170,6 +170,83 @@ func TestDecodeConfigDefault(t *testing.T) {
170170
c.Assert(pc.Node.Permissions.AllowWrite, qt.DeepEquals, []string{})
171171
}
172172

173+
func TestCheckAllowedHTTPURLHardenedDefaultsIssue14792(t *testing.T) {
174+
t.Parallel()
175+
c := qt.New(t)
176+
177+
c.Run("Public URLs allowed by default", func(c *qt.C) {
178+
c.Parallel()
179+
pc, err := DecodeConfig(config.New())
180+
c.Assert(err, qt.IsNil)
181+
for _, u := range []string{
182+
"https://example.org/",
183+
"https://example.org:8443/foo",
184+
"https://sub.example.org/path",
185+
} {
186+
c.Assert(pc.CheckAllowedHTTPURL(u), qt.IsNil, qt.Commentf(u))
187+
}
188+
})
189+
190+
c.Run("Private/loopback URLs denied by default", func(c *qt.C) {
191+
c.Parallel()
192+
pc, err := DecodeConfig(config.New())
193+
c.Assert(err, qt.IsNil)
194+
for _, u := range []string{
195+
"http://localhost/",
196+
"http://LOCALHOST:8080/",
197+
"http://foo.localhost/",
198+
"http://127.0.0.1/",
199+
"http://127.1.2.3:8080/x",
200+
"http://user:[email protected]/", // userinfo must not sneak past the deny.
201+
"http://10.0.0.1/",
202+
"http://172.16.0.1/",
203+
"http://192.168.1.1/",
204+
"http://169.254.169.254/latest/meta-data/", // AWS/GCP metadata.
205+
"http://0.0.0.0/",
206+
"http://[::1]/",
207+
"http://[fe80::1]/",
208+
"http://[fc00::1]/",
209+
// Public IP literals are blocked as collateral; users can override.
210+
"http://93.184.216.34/",
211+
"https://[2001:db8::1]/",
212+
} {
213+
err := pc.CheckAllowedHTTPURL(u)
214+
c.Assert(err, qt.IsNotNil, qt.Commentf(u))
215+
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`, qt.Commentf(u))
216+
}
217+
})
218+
219+
c.Run("Explicit user config bypasses hardening", func(c *qt.C) {
220+
c.Parallel()
221+
tomlConfig := `
222+
[security.http]
223+
urls = ['http://127\.0\.0\.1.*', 'http://localhost.*']
224+
`
225+
cfg, err := config.FromConfigString(tomlConfig, "toml")
226+
c.Assert(err, qt.IsNil)
227+
pc, err := DecodeConfig(cfg)
228+
c.Assert(err, qt.IsNil)
229+
c.Assert(pc.CheckAllowedHTTPURL("http://127.0.0.1:8080/foo"), qt.IsNil)
230+
c.Assert(pc.CheckAllowedHTTPURL("http://localhost:1313/"), qt.IsNil)
231+
})
232+
233+
c.Run("User can deny with the ! prefix", func(c *qt.C) {
234+
c.Parallel()
235+
tomlConfig := `
236+
[security.http]
237+
urls = ['.*', '! ^https?://evil\.example\.com']
238+
`
239+
cfg, err := config.FromConfigString(tomlConfig, "toml")
240+
c.Assert(err, qt.IsNil)
241+
pc, err := DecodeConfig(cfg)
242+
c.Assert(err, qt.IsNil)
243+
c.Assert(pc.CheckAllowedHTTPURL("https://good.example.com/"), qt.IsNil)
244+
err = pc.CheckAllowedHTTPURL("https://evil.example.com/x")
245+
c.Assert(err, qt.IsNotNil)
246+
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`)
247+
})
248+
}
249+
173250
func TestDecodeConfigNodePermissions(t *testing.T) {
174251
c := qt.New(t)
175252

config/security/whitelist.go

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,25 @@ import (
1818
"fmt"
1919
"regexp"
2020
"strings"
21-
)
2221

23-
const (
24-
acceptNoneKeyword = "none"
22+
"github.com/gohugoio/hugo/hugofs/hglob"
2523
)
2624

25+
const acceptNoneKeyword = "none"
26+
2727
// Whitelist holds a whitelist.
28+
//
29+
// Patterns are regular expressions. A pattern prefixed with "! "
30+
// (see hglob.NegationPrefix) is a deny rule: a name that matches any
31+
// deny rule is rejected even if it matches an allow rule.
32+
// A whitelist made up exclusively of deny rules implicitly allows
33+
// names that do not match any of them.
2834
type Whitelist struct {
2935
acceptNone bool
30-
patterns []*regexp.Regexp
36+
allow []*regexp.Regexp
37+
deny []*regexp.Regexp
3138

32-
// Store this for debugging/error reporting
39+
// Store this for debugging/error reporting.
3340
patternsStrings []string
3441
}
3542

@@ -44,14 +51,17 @@ func (w Whitelist) MarshalJSON() ([]byte, error) {
4451

4552
// NewWhitelist creates a new Whitelist from zero or more patterns.
4653
// An empty patterns list or a pattern with the value 'none' will create
47-
// a whitelist that will Accept none.
54+
// a whitelist that will Accept none. Patterns prefixed with "! " act as
55+
// deny rules; see Whitelist.
4856
func NewWhitelist(patterns ...string) (Whitelist, error) {
4957
if len(patterns) == 0 {
5058
return Whitelist{acceptNone: true}, nil
5159
}
5260

53-
var acceptSome bool
54-
var patternsStrings []string
61+
var (
62+
acceptSome bool
63+
patternsStrings []string
64+
)
5565

5666
for _, p := range patterns {
5767
if p == acceptNoneKeyword {
@@ -66,26 +76,28 @@ func NewWhitelist(patterns ...string) (Whitelist, error) {
6676
}
6777

6878
if !acceptSome {
69-
return Whitelist{
70-
acceptNone: true,
71-
}, nil
79+
return Whitelist{acceptNone: true}, nil
7280
}
7381

74-
var patternsr []*regexp.Regexp
75-
76-
for i := range patterns {
77-
p := strings.TrimSpace(patterns[i])
78-
if p == "" {
79-
continue
82+
var allow, deny []*regexp.Regexp
83+
for _, p := range patternsStrings {
84+
raw := p
85+
negate := strings.HasPrefix(p, hglob.NegationPrefix)
86+
if negate {
87+
raw = p[len(hglob.NegationPrefix):]
8088
}
81-
re, err := regexp.Compile(p)
89+
re, err := regexp.Compile(raw)
8290
if err != nil {
8391
return Whitelist{}, fmt.Errorf("failed to compile whitelist pattern %q: %w", p, err)
8492
}
85-
patternsr = append(patternsr, re)
93+
if negate {
94+
deny = append(deny, re)
95+
} else {
96+
allow = append(allow, re)
97+
}
8698
}
8799

88-
return Whitelist{patterns: patternsr, patternsStrings: patternsStrings}, nil
100+
return Whitelist{allow: allow, deny: deny, patternsStrings: patternsStrings}, nil
89101
}
90102

91103
// 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 {
103115
return false
104116
}
105117

106-
for _, p := range w.patterns {
118+
for _, p := range w.deny {
119+
if p.MatchString(name) {
120+
return false
121+
}
122+
}
123+
124+
if len(w.allow) == 0 {
125+
// A whitelist with only deny rules implicitly allows everything
126+
// that is not denied. An empty (zero-value) whitelist rejects.
127+
return len(w.deny) > 0
128+
}
129+
130+
for _, p := range w.allow {
107131
if p.MatchString(name) {
108132
return true
109133
}

config/security/whitelist_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,25 @@ func TestWhitelist(t *testing.T) {
4343
c.Assert(w.Accept("bar"), qt.IsTrue)
4444
c.Assert(w.Accept("mbar"), qt.IsFalse)
4545
})
46+
47+
c.Run("Negation takes precedence", func(c *qt.C) {
48+
w := MustNewWhitelist(".*", "! ^foo")
49+
c.Assert(w.Accept("bar"), qt.IsTrue)
50+
c.Assert(w.Accept("foo"), qt.IsFalse)
51+
c.Assert(w.Accept("foobar"), qt.IsFalse)
52+
})
53+
54+
c.Run("Negation only", func(c *qt.C) {
55+
// A whitelist with only deny rules accepts everything else.
56+
w := MustNewWhitelist("! ^foo")
57+
c.Assert(w.Accept("bar"), qt.IsTrue)
58+
c.Assert(w.Accept("foo"), qt.IsFalse)
59+
})
60+
61+
c.Run("Bad pattern", func(c *qt.C) {
62+
_, err := NewWhitelist("[invalid")
63+
c.Assert(err, qt.IsNotNil)
64+
_, err = NewWhitelist("! [invalid")
65+
c.Assert(err, qt.IsNotNil)
66+
})
4667
}

hugolib/resource_chain_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ func TestResourceChainBasic(t *testing.T) {
4545
files := `
4646
-- hugo.toml --
4747
baseURL = "http://example.com/"
48+
[security.http]
49+
urls = ['.*']
4850
-- assets/images/sunset.jpg --
4951
` + getTestSunset(t) + `
5052
-- layouts/home.html --
@@ -1329,6 +1331,8 @@ Template test.
13291331

13301332
files := test.files
13311333
files = strings.ReplaceAll(files, "HTTPTEST_SERVER_URL", ts.URL)
1334+
files = strings.Replace(files, `baseURL = "http://example.com/"`,
1335+
"baseURL = \"http://example.com/\"\n[security.http]\nurls = ['.*']", 1)
13321336

13331337
b := Test(t, files)
13341338

hugolib/securitypolicies_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ allow="none"
151151
files := fmt.Sprintf(`
152152
-- hugo.toml --
153153
baseURL = "https://example.org"
154+
[security]
155+
[security.http]
156+
urls = ['.*']
154157
-- layouts/home.html --
155158
{{ $json := resources.GetRemote "%s/fruits.json" }}{{ $json.Content }}
156159
`, ts.URL)
@@ -166,6 +169,9 @@ baseURL = "https://example.org"
166169
files := fmt.Sprintf(`
167170
-- hugo.toml --
168171
baseURL = "https://example.org"
172+
[security]
173+
[security.http]
174+
urls = ['.*']
169175
-- layouts/home.html --
170176
{{ $json := resources.GetRemote "%s/fruits.json" (dict "method" "DELETE" ) }}{{ $json.Content }}
171177
`, ts.URL)
@@ -194,6 +200,23 @@ urls="none"
194200
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`)
195201
})
196202

203+
c.Run("resources.GetRemote, denied loopback URL by default", func(c *qt.C) {
204+
c.Parallel()
205+
ts := httptest.NewServer(http.FileServer(http.Dir("testdata/")))
206+
c.Cleanup(func() {
207+
ts.Close()
208+
})
209+
files := fmt.Sprintf(`
210+
-- hugo.toml --
211+
baseURL = "https://example.org"
212+
-- layouts/home.html --
213+
{{ $json := resources.GetRemote "%s/fruits.json" }}{{ $json.Content }}
214+
`, ts.URL)
215+
_, err := TestE(c, files)
216+
c.Assert(err, qt.IsNotNil)
217+
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`)
218+
})
219+
197220
c.Run("resources.GetRemote, fake JSON", func(c *qt.C) {
198221
c.Parallel()
199222
ts := httptest.NewServer(http.FileServer(http.Dir("testdata/")))
@@ -204,6 +227,8 @@ urls="none"
204227
-- hugo.toml --
205228
baseURL = "https://example.org"
206229
[security]
230+
[security.http]
231+
urls = ['.*']
207232
-- layouts/home.html --
208233
{{ $json := resources.GetRemote "%s/fakejson.json" }}{{ $json.Content }}
209234
`, ts.URL)
@@ -223,6 +248,7 @@ baseURL = "https://example.org"
223248
baseURL = "https://example.org"
224249
[security]
225250
[security.http]
251+
urls = ['.*']
226252
mediaTypes=["application/json"]
227253
-- layouts/home.html --
228254
{{ $json := resources.GetRemote "%s/fakejson.json" }}{{ $json.Content }}

tpl/openapi/openapi3/openapi3_integration_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,9 @@ func TestUnmarshalRefRemote(t *testing.T) {
273273
})
274274

275275
dataPartRef := ts.URL + "/api/messages/dataPart.json"
276-
return strings.ReplaceAll(refLocalTemplate, "DATAPART_REF", dataPartRef)
276+
files := strings.ReplaceAll(refLocalTemplate, "DATAPART_REF", dataPartRef)
277+
return strings.Replace(files, "baseURL = 'http://example.com/'",
278+
"baseURL = 'http://example.com/'\n[security.http]\nurls = ['.*']", 1)
277279
}
278280

279281
t.Run("Build", func(t *testing.T) {

0 commit comments

Comments
 (0)