Hugo: XSS via text/html content files
Description
A stored XSS vulnerability in Hugo allows untrusted HTML content (.html files or adapter output) to be emitted verbatim into rendered pages, fixed in v0.162.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A stored XSS vulnerability in Hugo allows untrusted HTML content (`.html` files or adapter output) to be emitted verbatim into rendered pages, fixed in v0.162.0.
Vulnerability
In Hugo versions prior to v0.162.0, content files mapped to the text/html media type (typically .html files under /content, or pages produced by a content adapter that sets content.mediaType = "text/html") had their body emitted verbatim into the rendered page without sanitization [1][3][4]. This allowed arbitrary HTML, including JavaScript, to be injected into the final output. The affected versions include all Hugo releases before v0.162.0. Markdown, AsciiDoc, Org, Pandoc, and reStructuredText content are unaffected because they are processed through markdown renderers that escape HTML by default [1][3][4].
Exploitation
An attacker needs to be able to write or control the content of a file that Hugo treats as text/html — for example, through a CMS-backed editor, a content adapter pulling from an external API, or an automated import pipeline [1][3][4]. The attacker places malicious HTML (e.g., `) inside a .html file or configures a content adapter to emit text/html` content with embedded scripts. When Hugo builds the site, the malicious HTML is included verbatim in the generated page. Any visitor who loads that page will execute the injected script in the context of the site [1][3][4]. No other user interaction is required beyond visiting the affected page.
Impact
Successful exploitation results in stored Cross-Site Scripting (XSS). The attacker can execute arbitrary JavaScript in the browser of any user visiting the compromised page. This can lead to session hijacking, credential theft, defacement, or redirection to malicious sites [1][3][4]. The severity is rated Low to Medium, depending on whether the site ingests untrusted content (e.g., a public CMS or third-party API).
Mitigation
The vulnerability is fixed in Hugo v0.162.0, released on 2026-06-16 [1][3]. The fix introduces a security.allowContent whitelist with text/html denied by default [1][2][3]. Sites that require HTML content can opt back in by setting [security] allowContent = ['.*'] in the configuration, though this is discouraged unless all HTML sources are fully trusted [1][3][4]. No workarounds exist for earlier versions; upgrading is the only effective mitigation.
AI Insight generated on Jun 16, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
1e41a06447daaDisallow HTML content by default
9 files changed · +160 −2
config/security/securityConfig.go+22 −0 modified@@ -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.
config/security/securityConfig_test.go+43 −1 modified@@ -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)
hugolib/pagebundler_test.go+6 −0 modified@@ -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 --
hugolib/page.go+2 −1 modified@@ -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)
hugolib/page__meta.go+11 −0 modified@@ -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 }
hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go+4 −0 modified@@ -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") }}
hugolib/page_test.go+2 −0 modified@@ -741,6 +741,8 @@ func TestSummaryManualSplitHTML(t *testing.T) { t.Parallel() Test(t, ` -- hugo.toml -- +[security] +allowContent = ['.*'] -- content/simple.html -- --- title: Simple
hugolib/rendershortcodes_test.go+2 −0 modified@@ -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 --
hugolib/securitypolicies_test.go+68 −0 modified@@ -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 := `
Vulnerability mechanics
Root cause
"Hugo emitted the body of HTML content files verbatim into rendered pages without sanitization, creating a stored XSS sink."
Attack vector
An attacker who can place a `.html` file under `/content` (or who can control the output of a content adapter that sets `mediaType` to `text/html`) can inject arbitrary HTML/JavaScript. Hugo previously emitted the body of such pages verbatim into the rendered output without sanitization, creating a stored XSS sink. The attack requires the site to ingest content from an untrusted source — for example, a CMS-backed editor, an external API-driven content adapter, or an automated import pipeline [ref_id=1]. No authentication bypass or special network path is needed beyond write access to the content source.
Affected code
The vulnerability exists in how Hugo processes content files mapped to the `text/html` media type. The core logic is in `hugolib/page__meta.go` where `initFrontMatter` now gates content via `CheckAllowedContent` [patch_id=6192779]. The `config/security/securityConfig.go` file defines the new `AllowContent` whitelist and the `CheckAllowedContent` method, with the default set to deny `text/html` via `MustNewWhitelist("! ^text/html$")` [patch_id=6192779].
What the fix does
The patch introduces a new `security.allowContent` whitelist in the security configuration, defaulting to `['! ^text/html$']` — a deny rule that rejects the `text/html` media type while allowing all others [patch_id=6192779]. In `hugolib/page__meta.go`, the `initFrontMatter` method now calls `CheckAllowedContent` before processing a page; if the media type is denied, Hugo returns an error and refuses to build the page [patch_id=6192779]. The `hugolib/page.go` change clarifies that the HTML-to-markdown fallback is only reachable for shortcode inner content, not for file-based HTML pages [patch_id=6192779]. Sites that intentionally author HTML content can opt back in by setting `allowContent = ['.*']` in their Hugo config.
Preconditions
- inputAttacker must be able to place a .html file under /content or control the output of a content adapter that sets mediaType to 'text/html'
- configSite must ingest content from an untrusted source (e.g., CMS editor, external API, automated import pipeline)
Reproduction
Create a Hugo site with a file `content/page.html` containing front matter and `<script>alert(1)</script>` in the body, and a layout that renders `{{ .Content }}`. Running `hugo` will fail with an error matching `"text/html" is not whitelisted in policy "security.allowContent"` [patch_id=6192779]. For content adapters, create `content/_content.gotmpl` that calls `{{ .AddPage (dict "path" "p1" "title" "Untrusted" "content" (dict "value" "<script>alert(1)</script>" "mediaType" "text/html")) }}` — the build will also fail with the same error [patch_id=6192779].
Generated on Jun 16, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.