diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go index 3ecc67fcf..9818fd130 100644 --- a/config/security/securityConfig.go +++ b/config/security/securityConfig.go @@ -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. diff --git a/config/security/securityConfig_test.go b/config/security/securityConfig_test.go index da82b0c49..37ecaab61 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 = ['(?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) diff --git a/hugolib/page.go b/hugolib/page.go index 04be4428e..761274f35 100644 --- a/hugolib/page.go +++ b/hugolib/page.go @@ -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) diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go index 8ca377b1c..755a56d8c 100644 --- a/hugolib/page__meta.go +++ b/hugolib/page__meta.go @@ -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 } diff --git a/hugolib/page_test.go b/hugolib/page_test.go index b634b76ef..08f7729a7 100644 --- a/hugolib/page_test.go +++ b/hugolib/page_test.go @@ -741,6 +741,8 @@ func TestSummaryManualSplitHTML(t *testing.T) { t.Parallel() Test(t, ` -- hugo.toml -- +[security] +allowContent = ['.*'] -- content/simple.html -- --- title: Simple diff --git a/hugolib/pagebundler_test.go b/hugolib/pagebundler_test.go index 10207205e..f005a608e 100644 --- a/hugolib/pagebundler_test.go +++ b/hugolib/pagebundler_test.go @@ -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 -- hello @@ -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 -- diff --git a/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go b/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go index 2b3aa6e29..f0bdb1548 100644 --- a/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go +++ b/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go @@ -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") }} diff --git a/hugolib/rendershortcodes_test.go b/hugolib/rendershortcodes_test.go index 8fa0c24eb..4d4599836 100644 --- a/hugolib/rendershortcodes_test.go +++ b/hugolib/rendershortcodes_test.go @@ -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 -- diff --git a/hugolib/securitypolicies_test.go b/hugolib/securitypolicies_test.go index 43b610e92..23801bcf9 100644 --- a/hugolib/securitypolicies_test.go +++ b/hugolib/securitypolicies_test.go @@ -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" +--- + +-- 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" +--- +

hello

+-- layouts/single.html -- +{{ .Content }} +` + b := Test(c, files) + b.AssertFileContent("public/page/index.html", "

hello

") + }) + + 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" "" "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" "

hello

" "mediaType" "text/html")) }} +-- layouts/single.html -- +{{ .Content }} +` + b := Test(c, files) + b.AssertFileContent("public/p1/index.html", "

hello

") + }) + c.Run("os.GetEnv, denied", func(c *qt.C) { c.Parallel() files := `