From f228c87d417ae5b4461edf4ff80de717efceb661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Sat, 25 Jul 2026 12:40:04 +0200 Subject: [PATCH] Drop symlinks in parent directories Lstat only refrains from following the last element of a path, so a symlink in an intermediate directory was still resolved, and reads could escape the mount via e.g. resources.Get "symlinkdir/secret.txt". Walk the directories up to the mount root and reject any that is a symlink. Follow-up to cf9c8f93c and f8b5fa09a. --- .gitignore | 3 ++- hugofs/nosymlinks_fs.go | 34 ++++++++++++++++++++++++++++ hugofs/rootmapping_fs.go | 10 +++++++- testscripts/commands/no_symlinks.txt | 18 ++++++++++++--- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 0e812bb3e..7f3f745a9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ dist/ public/ .DS_Store cache/filecache/_gen/ -.claude/ \ No newline at end of file +.claude/ +dump.txt \ No newline at end of file diff --git a/hugofs/nosymlinks_fs.go b/hugofs/nosymlinks_fs.go index 0d710a7b4..89321e0c5 100644 --- a/hugofs/nosymlinks_fs.go +++ b/hugofs/nosymlinks_fs.go @@ -15,10 +15,37 @@ package hugofs import ( "os" + "path/filepath" + "github.com/gohugoio/hugo/common/herrors" "github.com/spf13/afero" ) +// hasSymlinkParent reports whether any directory between name and base is a symlink. +// Lstat only refrains from following the last element of a path, so callers checking +// name itself need this to keep a symlinked parent from escaping base. +// base itself is not checked; an empty base walks to the root of fs. +func hasSymlinkParent(fs afero.Fs, base, name string) (bool, error) { + for name != base { + parent := filepath.Dir(name) + if parent == name || parent == base || parent == "." { + return false, nil + } + fi, err := LstatIfPossible(fs, parent) + if err != nil { + if herrors.IsNotExist(err) { + return false, nil + } + return false, err + } + if fi.Mode()&os.ModeSymlink != 0 { + return true, nil + } + name = parent + } + return false, nil +} + // NewDropSymlinksFs returns an afero.Fs wrapper that treats symlinks as non-existing files. func NewDropSymlinksFs(base afero.Fs) *DropSymlinksFs { return &DropSymlinksFs{base} @@ -48,5 +75,12 @@ func (fs *DropSymlinksFs) Stat(name string) (os.FileInfo, error) { if fi.Mode()&os.ModeSymlink != 0 { return nil, os.ErrNotExist } + symlinkParent, err := hasSymlinkParent(fs.Fs, "", name) + if err != nil { + return nil, err + } + if symlinkParent { + return nil, os.ErrNotExist + } return fi, nil } diff --git a/hugofs/rootmapping_fs.go b/hugofs/rootmapping_fs.go index f9c9c133e..e91ee334c 100644 --- a/hugofs/rootmapping_fs.go +++ b/hugofs/rootmapping_fs.go @@ -729,10 +729,18 @@ func (fs *RootMappingFs) statRoot(root *RootMapping, filename string) (FileMetaI return nil, err } - // Don't allow symlinks to escape the mount. + // Don't allow symlinks to escape the mount, neither as the file itself + // nor as any directory between it and the mount root. if fi.Mode()&os.ModeSymlink != 0 { return nil, os.ErrNotExist } + symlinkParent, err := hasSymlinkParent(fs.Fs, root.To, filename) + if err != nil { + return nil, err + } + if symlinkParent { + return nil, os.ErrNotExist + } var opener func() (afero.File, error) if !fi.IsDir() { diff --git a/testscripts/commands/no_symlinks.txt b/testscripts/commands/no_symlinks.txt index 427927547..de79d08e6 100644 --- a/testscripts/commands/no_symlinks.txt +++ b/testscripts/commands/no_symlinks.txt @@ -18,6 +18,7 @@ stdout 'modstatictok' ! stdout 'modstaticsymlink' stdout pageok ! stdout pagesymlink +! stdout rootdirfile -- hugo.toml -- disableKinds = ["taxonomy", "term", "rss"] @@ -30,15 +31,24 @@ Read me. {{ with resources.Get "modassetsymlink.txt"}}FAIL {{ .Publish }}{{ else }}OK{{ end }} {{ with resources.GetMatch "modassetsymlink.txt"}}FAIL {{ .Publish }}{{ else }}OK{{ end }} {{ with resources.GetMatch "myassets/symlinkdir/**"}}FAIL {{ .Publish }}{{ else }}OK{{ end }} +{{ with resources.Get "myassets/symlinkdir/rootdirfile1.txt"}}FAIL {{ .Publish }}{{ else }}OK{{ end }} Page: {{ .RelPermalink }}|{{ .Content }}| {{/* os template package. */}} {{ $symFilePath := "content/pagesymlink.md" }} {{ with os.ReadDir "assets/myassets/symlinkdir" }}FAIL {{ len . }}{{ else }}OK{{ end }} -{{ with os.Stat $symFilePath }}FAIL{{ else }}OK{{ end }} -{{ with os.ReadFile $symFilePath }}FAIL{{ else }}OK{{ end }} -{{ with os.FileExists $symFilePath }}FAIL{{ else }}OK{{ end }} +{{ template "check-os-path" $symFilePath }} +{{ $symFilePath = "assets/myassets/symlinkdir/rootdirfile1.txt" }} +{{ template "check-os-path" $symFilePath }} +{{ $symFilePath = "assets/myassets/symlinkdir/subdir/rootdirfile3.txt" }} +{{ template "check-os-path" $symFilePath }} + +{{ define "check-os-path" }} +{{ with os.Stat . }}FAIL{{ else }}OK{{ end }} +{{ with os.ReadFile . }}FAIL{{ else }}OK{{ end }} +{{ with os.FileExists . }}FAIL{{ else }}OK{{ end }} +{{ end }} -- content/pageok.md -- -- themes/mytheme/assets/modassetok.txt -- @@ -53,5 +63,7 @@ My file. Rootdirfile1 content. -- rootdir/rootdirfile2.txt -- Rootdirfile2 content. +-- rootdir/subdir/rootdirfile3.txt -- +Rootdirfile3 content.