Hugo: Symlink confinement bypass in resources.Get
Description
Hugo's resources.Get followed symlinks instead of Lstat, allowing a planted symlink inside a theme to read arbitrary files outside the mount tree.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Hugo's resources.Get followed symlinks instead of Lstat, allowing a planted symlink inside a theme to read arbitrary files outside the mount tree.
Vulnerability
In Hugo versions v0.123.0 through v0.161.1, a regression in RootMappingFs.statRoot caused resources.Get to use Stat (which follows symlinks) instead of Lstat. This allowed a symlink placed inside a mounted directory—such as a locally-vendored theme under themes/—to be followed, potentially reading files outside the intended mount tree. Themes installed as Go modules are not affected because symlinks are stripped during download. Multi-directory walks (e.g., content/ or asset walking) were not affected; only direct calls to resources.Get were vulnerable [1][2][3].
Exploitation
An attacker must be able to place (or convince a site author to place) a symbolic link inside a mount point, for example inside a locally-vendored theme. The attacker then invokes resources.Get "<symlink_name>" where the symlink points to a target file outside the mount. No authentication or user interaction beyond running hugo is required; the attacker can achieve arbitrary file read if they control any part of a mounted directory [3].
Impact
Successful exploitation allows an attacker to read arbitrary files on the filesystem that the user running hugo can access. This could disclose sensitive information such as configuration files, secrets, or source code. The attack does not enable file write or remote code execution; the scope is limited to information disclosure at the privilege level of the Hugo process [3].
Mitigation
The vulnerability is fixed in Hugo v0.162.0, released on June 16, 2026 [1]. The fix uses LstatIfPossible to detect symlinks and rejects them with os.ErrNotExist, restoring the pre-v0.123.0 behavior [2]. Users should upgrade to v0.162.0 or later. No workarounds are available; updating is the only mitigation. This CVE is not listed in the CISA Known Exploited Vulnerabilities (KEV) catalog as of publication.
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
2Patches
1f8b5fa09a649Fix prevention of direct symlink reads in resources.Get
5 files changed · +90 −1
hugofs/decorators.go+16 −0 modified@@ -106,6 +106,22 @@ func (fs *baseFileDecoratorFs) Stat(name string) (os.FileInfo, error) { return fim.(os.FileInfo), nil } +func (fs *baseFileDecoratorFs) LstatIfPossible(name string) (os.FileInfo, bool, error) { + if lstater, ok := fs.Fs.(afero.Lstater); ok { + fi, ok, err := lstater.LstatIfPossible(name) + if err != nil { + return nil, false, err + } + fim, err := fs.decorate(fi, name) + if err != nil { + return nil, false, err + } + return fim.(os.FileInfo), ok, nil + } + fi, err := fs.Stat(name) + return fi, false, err +} + func (fs *baseFileDecoratorFs) Open(name string) (afero.File, error) { return fs.open(name) }
hugofs/fs.go+10 −0 modified@@ -267,3 +267,13 @@ func ReadDirWithContext(ctx context.Context, f DirOnlyOps, count int) ([]iofs.Di } return v, ctx, nil } + +// LstatIfPossible tries to use LstatIfPossible if the filesystem supports it, otherwise it falls back to Stat. +func LstatIfPossible(fs afero.Fs, name string) (os.FileInfo, error) { + if lstater, ok := fs.(afero.Lstater); ok { + fi, _, err := lstater.LstatIfPossible(name) + return fi, err + } + fi, err := fs.Stat(name) + return fi, err +}
hugofs/rootmapping_fs.go+6 −1 modified@@ -724,11 +724,16 @@ func (fs *RootMappingFs) statRoot(root *RootMapping, filename string) (FileMetaI } filename = root.filename(filename) - fi, err := fs.Fs.Stat(filename) + fi, err := LstatIfPossible(fs.Fs, filename) if err != nil { return nil, err } + // Don't allow symlinks to escape the mount. + if fi.Mode()&os.ModeSymlink != 0 { + return nil, os.ErrNotExist + } + var opener func() (afero.File, error) if !fi.IsDir() { // Open the file directly.
main_test.go+19 −0 modified@@ -237,6 +237,21 @@ var commonTestScriptsParam = testscript.Params{ ts.Fatalf("failed to write file: %v", err) } }, + // ln creates a symlink, but throws an error on Windows. + "ln": func(ts *testscript.TestScript, neg bool, args []string) { + if runtime.GOOS == "windows" { + ts.Fatalf("ln is not supported on Windows") + } + if len(args) != 2 { + ts.Fatalf("usage: ln TARGET LINKNAME") + } + target := ts.MkAbs(args[0]) + linkname := ts.MkAbs(args[1]) + err := os.Symlink(target, linkname) + if err != nil { + ts.Fatalf("failed to create symlink: %v", err) + } + }, // httpget checks that a HTTP resource's body matches (if it compiles as a regexp) or contains all of the strings given as arguments. "httpget": func(ts *testscript.TestScript, neg bool, args []string) { @@ -314,6 +329,10 @@ var commonTestScriptsParam = testscript.Params{ if !ok { ts.Fatalf("stat %s: %v", filename, err) } + if ok && neg { + // OK. + continue + } if fi.Size() == 0 { ts.Fatalf("%s is empty", filename) }
testscripts/commands/no_symlinks.txt+39 −0 added@@ -0,0 +1,39 @@ +[windows] skip + +ln ./rootfile.txt ./themes/mytheme/assets/modassetsymlink.txt +ln ./rootfile.txt ./themes/mytheme/static/modstaticsymlink.txt +ln ./README.md ./content/pagesymlink.md + +hugo + +grep 'OK' public/index.html +! grep 'FAIL' public/index.html + +tree public + +stdout modassetok +! stdout modassetsymlink +stdout 'modstatictok' +! stdout 'modstaticsymlink' +stdout pageok +! stdout pagesymlink + +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss"] +[[module.imports]] +path = 'mytheme' +-- README.md -- +Read me. +-- layouts/all.html -- +{{ with resources.Get "modassetok.txt"}}OK {{ .Publish }}{{ else }}FAIL{{ end }} +{{ with resources.Get "modassetsymlink.txt"}}FAIL {{ .Publish }}{{ else }}OK{{ end }} +Page: {{ .RelPermalink }}|{{ .Content }}| +-- content/pageok.md -- +-- themes/mytheme/assets/modassetok.txt -- +Content. +-- themes/mytheme/static/modstatictok.txt -- +Content. +-- rootfile.txt -- +Roo Content. + +
Vulnerability mechanics
Root cause
"A regression in v0.123.0 caused `RootMappingFs.statRoot` to call `Stat` (which follows symlinks) instead of `Lstat`, allowing a symlink planted inside a mount to escape the mount boundary."
Attack vector
An attacker who can place a symlink inside a mounted directory (e.g., inside a locally-vendored theme under `themes/`) can make `resources.Get` follow that symlink and read arbitrary files reachable by the Hugo process. The symlink must point to a file outside the mount tree. Themes downloaded as Go modules from GitHub are not affected because symlinks are stripped on download [patch_id=6193158].
Affected code
The vulnerability is in `hugofs/rootmapping_fs.go` in the `statRoot` method, which called `Stat` (which follows symlinks) instead of `LstatIfPossible`. The fix also adds `LstatIfPossible` to `hugofs/decorators.go` and a helper in `hugofs/fs.go`. The regression was introduced in v0.123.0 and affects direct lookups via `resources.Get` on symlinked files inside mounted directories [patch_id=6193158].
What the fix does
The patch replaces `fs.Fs.Stat(filename)` with `LstatIfPossible(fs.Fs, filename)` in `statRoot`, which does not follow symlinks. It then adds a check: if the returned file info has the `ModeSymlink` bit set, the function returns `os.ErrNotExist`, effectively hiding the symlink from `resources.Get`. This matches the pre-v0.123.0 behavior and the behavior of directory-walking code paths [patch_id=6193158].
Preconditions
- inputAttacker must be able to place a symlink inside a mounted directory (e.g., a locally-vendored theme under themes/).
- inputThe symlink must point to a file outside the mount tree that the Hugo process can read.
- configThe site must use resources.Get to directly reference the symlinked file.
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.