mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 07:18:54 +00:00
Disallow HTML content by default
For security reasons. Enable in security config, e.g.: ```toml [security] allowContent = ['.*'] ```
This commit is contained in:
@@ -74,6 +74,11 @@ var DefaultConfig = Config{
|
||||
AllowChildProcess: []string{"tailwindcss"}, // detect-libc spawns getconf on some Linux setups.
|
||||
},
|
||||
},
|
||||
// Content under /content is treated as untrusted. text/html bodies are
|
||||
// emitted verbatim and are an XSS sink, so they are denied by default.
|
||||
// Everything else is allowed because Whitelist treats a deny-only list as
|
||||
// "allow anything not denied".
|
||||
AllowContent: MustNewWhitelist("! ^text/html$"),
|
||||
}
|
||||
|
||||
// Config is the top level security config.
|
||||
@@ -92,6 +97,12 @@ type Config struct {
|
||||
// Node holds Node.js security settings.
|
||||
Node Node `json:"node"`
|
||||
|
||||
// AllowContent restricts which content media types may be used for
|
||||
// pages under /content. Matched against the full MIME type (e.g.
|
||||
// "text/html"). text/html is denied by default because Hugo emits the
|
||||
// body verbatim.
|
||||
AllowContent Whitelist `json:"allowContent"`
|
||||
|
||||
// Allow inline shortcodes
|
||||
EnableInlineShortcodes bool `json:"enableInlineShortcodes"`
|
||||
}
|
||||
@@ -200,6 +211,17 @@ func (c Config) CheckAllowedHTTPMethod(method string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) CheckAllowedContent(mediaType string) error {
|
||||
if !c.AllowContent.Accept(mediaType) {
|
||||
return &AccessDeniedError{
|
||||
name: mediaType,
|
||||
path: "security.allowContent",
|
||||
policies: c.ToTOML(),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToSecurityMap converts c to a map with 'security' as the root key.
|
||||
func (c Config) ToSecurityMap() map[string]any {
|
||||
// Take it to JSON and back to get proper casing etc.
|
||||
|
||||
@@ -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 = ['(?i)^https?://[a-z0-9]', '! ^https?://\\d+\\.', '! (?i)localhost', '! (?i)^https?://[^/?#]*@']\n\n [security.node]\n [security.node.permissions]\n allowAddons = ['tailwindcss']\n allowChildProcess = ['tailwindcss']\n allowRead = ['.']\n allowWorker = ['tailwindcss']\n allowWrite = []\n disable = false",
|
||||
"[security]\n allowContent = ['! ^text/html$']\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-z0-9]', '! ^https?://\\d+\\.', '! (?i)localhost', '! (?i)^https?://[^/?#]*@']\n\n [security.node]\n [security.node.permissions]\n allowAddons = ['tailwindcss']\n allowChildProcess = ['tailwindcss']\n allowRead = ['.']\n allowWorker = ['tailwindcss']\n allowWrite = []\n disable = false",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -298,6 +298,48 @@ func TestCheckAllowedHTTPURLDigitHostnameIssue14837(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAllowedContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := qt.New(t)
|
||||
|
||||
c.Run("text/html denied by default", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
pc, err := DecodeConfig(config.New())
|
||||
c.Assert(err, qt.IsNil)
|
||||
err = pc.CheckAllowedContent("text/html")
|
||||
c.Assert(err, qt.IsNotNil)
|
||||
c.Assert(err, qt.ErrorMatches, `(?s).*"text/html" is not whitelisted in policy "security\.allowContent".*`)
|
||||
})
|
||||
|
||||
c.Run("Other content types allowed by default", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
pc, err := DecodeConfig(config.New())
|
||||
c.Assert(err, qt.IsNil)
|
||||
for _, mt := range []string{
|
||||
"text/markdown",
|
||||
"text/asciidoc",
|
||||
"text/x-org",
|
||||
"text/rst",
|
||||
"text/pandoc",
|
||||
} {
|
||||
c.Assert(pc.CheckAllowedContent(mt), qt.IsNil, qt.Commentf(mt))
|
||||
}
|
||||
})
|
||||
|
||||
c.Run("User can opt in to HTML", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
tomlConfig := `
|
||||
[security]
|
||||
allowContent = ['.*']
|
||||
`
|
||||
cfg, err := config.FromConfigString(tomlConfig, "toml")
|
||||
c.Assert(err, qt.IsNil)
|
||||
pc, err := DecodeConfig(cfg)
|
||||
c.Assert(err, qt.IsNil)
|
||||
c.Assert(pc.CheckAllowedContent("text/html"), qt.IsNil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecodeConfigNodePermissions(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
|
||||
|
||||
+2
-1
@@ -807,7 +807,8 @@ func (ps *pageState) getContentConverter() converter.Converter {
|
||||
markup := ps.m.pageConfigSource.ContentMediaType.SubType
|
||||
|
||||
if markup == "html" {
|
||||
// Only used for shortcode inner content.
|
||||
// Only reachable for shortcode inner content rendering; file-based
|
||||
// HTML pages are gated at initFrontMatter via security.allowContent.
|
||||
markup = "markdown"
|
||||
}
|
||||
ps.contentConverter, err = ps.m.newContentConverter(ps, markup)
|
||||
|
||||
@@ -238,6 +238,17 @@ func (m *pageMetaSource) initFrontMatter(h *HugoSites) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Gate the content format against the security policy. The body of a
|
||||
// content file is treated as untrusted; text/html is denied by default
|
||||
// because Hugo emits it verbatim and that is an XSS sink. This applies
|
||||
// to pages emitted by content adapters too -- the adapter is trusted
|
||||
// but the data it pulls in may not be.
|
||||
if m.f != nil && !m.pageConfigSource.ContentMediaType.IsZero() {
|
||||
if err := h.Deps.ExecHelper.Sec().CheckAllowedContent(m.pageConfigSource.ContentMediaType.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -741,6 +741,8 @@ func TestSummaryManualSplitHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
Test(t, `
|
||||
-- hugo.toml --
|
||||
[security]
|
||||
allowContent = ['.*']
|
||||
-- content/simple.html --
|
||||
---
|
||||
title: Simple
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
func TestPageBundlerBasic(t *testing.T) {
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
[security]
|
||||
allowContent = ['.*']
|
||||
-- content/mybundle/index.md --
|
||||
---
|
||||
title: "My Bundle"
|
||||
@@ -630,6 +632,8 @@ func TestHTMLFilesIsue11999(t *testing.T) {
|
||||
disableKinds = ["taxonomy", "term", "rss", "sitemap", "robotsTXT", "404"]
|
||||
[permalinks]
|
||||
posts = "/myposts/:slugorcontentbasename"
|
||||
[security]
|
||||
allowContent = ['.*']
|
||||
-- content/posts/markdown-without-frontmatter.md --
|
||||
-- content/posts/html-without-frontmatter.html --
|
||||
<html>hello</html>
|
||||
@@ -705,6 +709,8 @@ func TestBundleDuplicatePagesAndResources(t *testing.T) {
|
||||
-- hugo.toml --
|
||||
baseURL = "https://example.com"
|
||||
disableKinds = ["taxonomy", "term"]
|
||||
[security]
|
||||
allowContent = ['.*']
|
||||
-- content/mysection/mybundle/index.md --
|
||||
-- content/mysection/mybundle/index.html --
|
||||
-- content/mysection/mybundle/p1.md --
|
||||
|
||||
@@ -31,6 +31,8 @@ const filesPagesFromDataTempleBasic = `
|
||||
disableKinds = ["taxonomy", "term", "rss", "sitemap"]
|
||||
baseURL = "https://example.com"
|
||||
disableLiveReload = true
|
||||
[security]
|
||||
allowContent = ['.*']
|
||||
-- assets/a/pixel.png --
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||
-- assets/mydata.yaml --
|
||||
@@ -589,6 +591,8 @@ func TestPagesFromGoTmplShortcodeNoPreceddingCharacterIssue12544(t *testing.T) {
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
disableKinds = ['home','rss','section','sitemap','taxonomy','term']
|
||||
[security]
|
||||
allowContent = ['.*']
|
||||
-- content/_content.gotmpl --
|
||||
{{ $content := dict "mediaType" "text/html" "value" "x{{< sc >}}" }}
|
||||
{{ .AddPage (dict "content" $content "path" "a") }}
|
||||
|
||||
@@ -247,6 +247,8 @@ func TestRenderShortcodesNestedPageContextIssue12356(t *testing.T) {
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
disableKinds = ["taxonomy", "term", "rss", "sitemap", "robotsTXT", "404"]
|
||||
[security]
|
||||
allowContent = ['.*']
|
||||
-- layouts/_markup/render-image.html --
|
||||
{{- with .PageInner.Resources.Get .Destination -}}Image: {{ .RelPermalink }}|{{- end -}}
|
||||
-- layouts/_markup/render-link.html --
|
||||
|
||||
@@ -30,6 +30,74 @@ import (
|
||||
func TestSecurityPolicies(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
|
||||
c.Run("HTML content, denied by default", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
baseURL = "https://example.org"
|
||||
-- content/page.html --
|
||||
---
|
||||
title: "Untrusted"
|
||||
---
|
||||
<script>alert(1)</script>
|
||||
-- layouts/single.html --
|
||||
{{ .Content }}
|
||||
`
|
||||
_, err := TestE(c, files)
|
||||
c.Assert(err, qt.IsNotNil)
|
||||
c.Assert(err, qt.ErrorMatches, `(?s).*"text/html" is not whitelisted in policy "security\.allowContent".*`)
|
||||
})
|
||||
|
||||
c.Run("HTML content, allowed via override", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
baseURL = "https://example.org"
|
||||
[security]
|
||||
allowContent = ['.*']
|
||||
-- content/page.html --
|
||||
---
|
||||
title: "Trusted"
|
||||
---
|
||||
<p>hello</p>
|
||||
-- layouts/single.html --
|
||||
{{ .Content }}
|
||||
`
|
||||
b := Test(c, files)
|
||||
b.AssertFileContent("public/page/index.html", "<p>hello</p>")
|
||||
})
|
||||
|
||||
c.Run("HTML content from content adapter, denied by default", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
baseURL = "https://example.org"
|
||||
-- content/_content.gotmpl --
|
||||
{{ .AddPage (dict "path" "p1" "title" "Untrusted" "content" (dict "value" "<script>alert(1)</script>" "mediaType" "text/html")) }}
|
||||
-- layouts/single.html --
|
||||
{{ .Content }}
|
||||
`
|
||||
_, err := TestE(c, files)
|
||||
c.Assert(err, qt.IsNotNil)
|
||||
c.Assert(err, qt.ErrorMatches, `(?s).*"text/html" is not whitelisted in policy "security\.allowContent".*`)
|
||||
})
|
||||
|
||||
c.Run("HTML content from content adapter, allowed via override", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
baseURL = "https://example.org"
|
||||
[security]
|
||||
allowContent = ['.*']
|
||||
-- content/_content.gotmpl --
|
||||
{{ .AddPage (dict "path" "p1" "title" "Trusted" "content" (dict "value" "<p>hello</p>" "mediaType" "text/html")) }}
|
||||
-- layouts/single.html --
|
||||
{{ .Content }}
|
||||
`
|
||||
b := Test(c, files)
|
||||
b.AssertFileContent("public/p1/index.html", "<p>hello</p>")
|
||||
})
|
||||
|
||||
c.Run("os.GetEnv, denied", func(c *qt.C) {
|
||||
c.Parallel()
|
||||
files := `
|
||||
|
||||
Reference in New Issue
Block a user