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

Skip to content

Commit e41a064

Browse files
committed
Disallow HTML content by default
For security reasons. Enable in security config, e.g.: ```toml [security] allowContent = ['.*'] ```
1 parent 90d9f81 commit e41a064

9 files changed

Lines changed: 160 additions & 2 deletions

config/security/securityConfig.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ var DefaultConfig = Config{
7474
AllowChildProcess: []string{"tailwindcss"}, // detect-libc spawns getconf on some Linux setups.
7575
},
7676
},
77+
// Content under /content is treated as untrusted. text/html bodies are
78+
// emitted verbatim and are an XSS sink, so they are denied by default.
79+
// Everything else is allowed because Whitelist treats a deny-only list as
80+
// "allow anything not denied".
81+
AllowContent: MustNewWhitelist("! ^text/html$"),
7782
}
7883

7984
// Config is the top level security config.
@@ -92,6 +97,12 @@ type Config struct {
9297
// Node holds Node.js security settings.
9398
Node Node `json:"node"`
9499

100+
// AllowContent restricts which content media types may be used for
101+
// pages under /content. Matched against the full MIME type (e.g.
102+
// "text/html"). text/html is denied by default because Hugo emits the
103+
// body verbatim.
104+
AllowContent Whitelist `json:"allowContent"`
105+
95106
// Allow inline shortcodes
96107
EnableInlineShortcodes bool `json:"enableInlineShortcodes"`
97108
}
@@ -200,6 +211,17 @@ func (c Config) CheckAllowedHTTPMethod(method string) error {
200211
return nil
201212
}
202213

214+
func (c Config) CheckAllowedContent(mediaType string) error {
215+
if !c.AllowContent.Accept(mediaType) {
216+
return &AccessDeniedError{
217+
name: mediaType,
218+
path: "security.allowContent",
219+
policies: c.ToTOML(),
220+
}
221+
}
222+
return nil
223+
}
224+
203225
// ToSecurityMap converts c to a map with 'security' as the root key.
204226
func (c Config) ToSecurityMap() map[string]any {
205227
// Take it to JSON and back to get proper casing etc.

config/security/securityConfig_test.go

Lines changed: 43 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 = ['(?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",
138+
"[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",
139139
)
140140
}
141141

@@ -298,6 +298,48 @@ func TestCheckAllowedHTTPURLDigitHostnameIssue14837(t *testing.T) {
298298
}
299299
}
300300

301+
func TestCheckAllowedContent(t *testing.T) {
302+
t.Parallel()
303+
c := qt.New(t)
304+
305+
c.Run("text/html denied by default", func(c *qt.C) {
306+
c.Parallel()
307+
pc, err := DecodeConfig(config.New())
308+
c.Assert(err, qt.IsNil)
309+
err = pc.CheckAllowedContent("text/html")
310+
c.Assert(err, qt.IsNotNil)
311+
c.Assert(err, qt.ErrorMatches, `(?s).*"text/html" is not whitelisted in policy "security\.allowContent".*`)
312+
})
313+
314+
c.Run("Other content types allowed by default", func(c *qt.C) {
315+
c.Parallel()
316+
pc, err := DecodeConfig(config.New())
317+
c.Assert(err, qt.IsNil)
318+
for _, mt := range []string{
319+
"text/markdown",
320+
"text/asciidoc",
321+
"text/x-org",
322+
"text/rst",
323+
"text/pandoc",
324+
} {
325+
c.Assert(pc.CheckAllowedContent(mt), qt.IsNil, qt.Commentf(mt))
326+
}
327+
})
328+
329+
c.Run("User can opt in to HTML", func(c *qt.C) {
330+
c.Parallel()
331+
tomlConfig := `
332+
[security]
333+
allowContent = ['.*']
334+
`
335+
cfg, err := config.FromConfigString(tomlConfig, "toml")
336+
c.Assert(err, qt.IsNil)
337+
pc, err := DecodeConfig(cfg)
338+
c.Assert(err, qt.IsNil)
339+
c.Assert(pc.CheckAllowedContent("text/html"), qt.IsNil)
340+
})
341+
}
342+
301343
func TestDecodeConfigNodePermissions(t *testing.T) {
302344
c := qt.New(t)
303345

hugolib/page.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,8 @@ func (ps *pageState) getContentConverter() converter.Converter {
807807
markup := ps.m.pageConfigSource.ContentMediaType.SubType
808808

809809
if markup == "html" {
810-
// Only used for shortcode inner content.
810+
// Only reachable for shortcode inner content rendering; file-based
811+
// HTML pages are gated at initFrontMatter via security.allowContent.
811812
markup = "markdown"
812813
}
813814
ps.contentConverter, err = ps.m.newContentConverter(ps, markup)

hugolib/page__meta.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,17 @@ func (m *pageMetaSource) initFrontMatter(h *HugoSites) error {
238238
return err
239239
}
240240

241+
// Gate the content format against the security policy. The body of a
242+
// content file is treated as untrusted; text/html is denied by default
243+
// because Hugo emits it verbatim and that is an XSS sink. This applies
244+
// to pages emitted by content adapters too -- the adapter is trusted
245+
// but the data it pulls in may not be.
246+
if m.f != nil && !m.pageConfigSource.ContentMediaType.IsZero() {
247+
if err := h.Deps.ExecHelper.Sec().CheckAllowedContent(m.pageConfigSource.ContentMediaType.Type); err != nil {
248+
return err
249+
}
250+
}
251+
241252
return nil
242253
}
243254

hugolib/page_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,8 @@ func TestSummaryManualSplitHTML(t *testing.T) {
741741
t.Parallel()
742742
Test(t, `
743743
-- hugo.toml --
744+
[security]
745+
allowContent = ['.*']
744746
-- content/simple.html --
745747
---
746748
title: Simple

hugolib/pagebundler_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
func TestPageBundlerBasic(t *testing.T) {
2323
files := `
2424
-- hugo.toml --
25+
[security]
26+
allowContent = ['.*']
2527
-- content/mybundle/index.md --
2628
---
2729
title: "My Bundle"
@@ -630,6 +632,8 @@ func TestHTMLFilesIsue11999(t *testing.T) {
630632
disableKinds = ["taxonomy", "term", "rss", "sitemap", "robotsTXT", "404"]
631633
[permalinks]
632634
posts = "/myposts/:slugorcontentbasename"
635+
[security]
636+
allowContent = ['.*']
633637
-- content/posts/markdown-without-frontmatter.md --
634638
-- content/posts/html-without-frontmatter.html --
635639
<html>hello</html>
@@ -705,6 +709,8 @@ func TestBundleDuplicatePagesAndResources(t *testing.T) {
705709
-- hugo.toml --
706710
baseURL = "https://example.com"
707711
disableKinds = ["taxonomy", "term"]
712+
[security]
713+
allowContent = ['.*']
708714
-- content/mysection/mybundle/index.md --
709715
-- content/mysection/mybundle/index.html --
710716
-- content/mysection/mybundle/p1.md --

hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const filesPagesFromDataTempleBasic = `
3131
disableKinds = ["taxonomy", "term", "rss", "sitemap"]
3232
baseURL = "https://example.com"
3333
disableLiveReload = true
34+
[security]
35+
allowContent = ['.*']
3436
-- assets/a/pixel.png --
3537
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
3638
-- assets/mydata.yaml --
@@ -589,6 +591,8 @@ func TestPagesFromGoTmplShortcodeNoPreceddingCharacterIssue12544(t *testing.T) {
589591
files := `
590592
-- hugo.toml --
591593
disableKinds = ['home','rss','section','sitemap','taxonomy','term']
594+
[security]
595+
allowContent = ['.*']
592596
-- content/_content.gotmpl --
593597
{{ $content := dict "mediaType" "text/html" "value" "x{{< sc >}}" }}
594598
{{ .AddPage (dict "content" $content "path" "a") }}

hugolib/rendershortcodes_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ func TestRenderShortcodesNestedPageContextIssue12356(t *testing.T) {
247247
files := `
248248
-- hugo.toml --
249249
disableKinds = ["taxonomy", "term", "rss", "sitemap", "robotsTXT", "404"]
250+
[security]
251+
allowContent = ['.*']
250252
-- layouts/_markup/render-image.html --
251253
{{- with .PageInner.Resources.Get .Destination -}}Image: {{ .RelPermalink }}|{{- end -}}
252254
-- layouts/_markup/render-link.html --

hugolib/securitypolicies_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,74 @@ import (
3030
func TestSecurityPolicies(t *testing.T) {
3131
c := qt.New(t)
3232

33+
c.Run("HTML content, denied by default", func(c *qt.C) {
34+
c.Parallel()
35+
files := `
36+
-- hugo.toml --
37+
baseURL = "https://example.org"
38+
-- content/page.html --
39+
---
40+
title: "Untrusted"
41+
---
42+
<script>alert(1)</script>
43+
-- layouts/single.html --
44+
{{ .Content }}
45+
`
46+
_, err := TestE(c, files)
47+
c.Assert(err, qt.IsNotNil)
48+
c.Assert(err, qt.ErrorMatches, `(?s).*"text/html" is not whitelisted in policy "security\.allowContent".*`)
49+
})
50+
51+
c.Run("HTML content, allowed via override", func(c *qt.C) {
52+
c.Parallel()
53+
files := `
54+
-- hugo.toml --
55+
baseURL = "https://example.org"
56+
[security]
57+
allowContent = ['.*']
58+
-- content/page.html --
59+
---
60+
title: "Trusted"
61+
---
62+
<p>hello</p>
63+
-- layouts/single.html --
64+
{{ .Content }}
65+
`
66+
b := Test(c, files)
67+
b.AssertFileContent("public/page/index.html", "<p>hello</p>")
68+
})
69+
70+
c.Run("HTML content from content adapter, denied by default", func(c *qt.C) {
71+
c.Parallel()
72+
files := `
73+
-- hugo.toml --
74+
baseURL = "https://example.org"
75+
-- content/_content.gotmpl --
76+
{{ .AddPage (dict "path" "p1" "title" "Untrusted" "content" (dict "value" "<script>alert(1)</script>" "mediaType" "text/html")) }}
77+
-- layouts/single.html --
78+
{{ .Content }}
79+
`
80+
_, err := TestE(c, files)
81+
c.Assert(err, qt.IsNotNil)
82+
c.Assert(err, qt.ErrorMatches, `(?s).*"text/html" is not whitelisted in policy "security\.allowContent".*`)
83+
})
84+
85+
c.Run("HTML content from content adapter, allowed via override", func(c *qt.C) {
86+
c.Parallel()
87+
files := `
88+
-- hugo.toml --
89+
baseURL = "https://example.org"
90+
[security]
91+
allowContent = ['.*']
92+
-- content/_content.gotmpl --
93+
{{ .AddPage (dict "path" "p1" "title" "Trusted" "content" (dict "value" "<p>hello</p>" "mediaType" "text/html")) }}
94+
-- layouts/single.html --
95+
{{ .Content }}
96+
`
97+
b := Test(c, files)
98+
b.AssertFileContent("public/p1/index.html", "<p>hello</p>")
99+
})
100+
33101
c.Run("os.GetEnv, denied", func(c *qt.C) {
34102
c.Parallel()
35103
files := `

0 commit comments

Comments
 (0)