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{
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",
)
}
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)
"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
}
// 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 {
}
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.
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
}
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)
+ })
}
files := `
-- hugo.toml --
baseURL = "http://example.com/"
+[security.http]
+urls = ['.*']
-- assets/images/sunset.jpg --
` + getTestSunset(t) + `
-- layouts/home.html --
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)
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)
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)
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/")))
-- hugo.toml --
baseURL = "https://example.org"
[security]
+[security.http]
+urls = ['.*']
-- layouts/home.html --
{{ $json := resources.GetRemote "%s/fakejson.json" }}{{ $json.Content }}
`, ts.URL)
baseURL = "https://example.org"
[security]
[security.http]
+urls = ['.*']
mediaTypes=["application/json"]
-- layouts/home.html --
{{ $json := resources.GetRemote "%s/fakejson.json" }}{{ $json.Content }}
})
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) {