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.
This commit is contained in:
Bjørn Erik Pedersen
2026-07-25 12:40:04 +02:00
parent 1b701b72ca
commit f228c87d41
4 changed files with 60 additions and 5 deletions
+2 -1
View File
@@ -5,4 +5,5 @@ dist/
public/ public/
.DS_Store .DS_Store
cache/filecache/_gen/ cache/filecache/_gen/
.claude/ .claude/
dump.txt
+34
View File
@@ -15,10 +15,37 @@ package hugofs
import ( import (
"os" "os"
"path/filepath"
"github.com/gohugoio/hugo/common/herrors"
"github.com/spf13/afero" "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. // NewDropSymlinksFs returns an afero.Fs wrapper that treats symlinks as non-existing files.
func NewDropSymlinksFs(base afero.Fs) *DropSymlinksFs { func NewDropSymlinksFs(base afero.Fs) *DropSymlinksFs {
return &DropSymlinksFs{base} return &DropSymlinksFs{base}
@@ -48,5 +75,12 @@ func (fs *DropSymlinksFs) Stat(name string) (os.FileInfo, error) {
if fi.Mode()&os.ModeSymlink != 0 { if fi.Mode()&os.ModeSymlink != 0 {
return nil, os.ErrNotExist 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 return fi, nil
} }
+9 -1
View File
@@ -729,10 +729,18 @@ func (fs *RootMappingFs) statRoot(root *RootMapping, filename string) (FileMetaI
return nil, err 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 { if fi.Mode()&os.ModeSymlink != 0 {
return nil, os.ErrNotExist 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) var opener func() (afero.File, error)
if !fi.IsDir() { if !fi.IsDir() {
+15 -3
View File
@@ -18,6 +18,7 @@ stdout 'modstatictok'
! stdout 'modstaticsymlink' ! stdout 'modstaticsymlink'
stdout pageok stdout pageok
! stdout pagesymlink ! stdout pagesymlink
! stdout rootdirfile
-- hugo.toml -- -- hugo.toml --
disableKinds = ["taxonomy", "term", "rss"] disableKinds = ["taxonomy", "term", "rss"]
@@ -30,15 +31,24 @@ Read me.
{{ with resources.Get "modassetsymlink.txt"}}FAIL {{ .Publish }}{{ else }}OK{{ end }} {{ with resources.Get "modassetsymlink.txt"}}FAIL {{ .Publish }}{{ else }}OK{{ end }}
{{ with resources.GetMatch "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.GetMatch "myassets/symlinkdir/**"}}FAIL {{ .Publish }}{{ else }}OK{{ end }}
{{ with resources.Get "myassets/symlinkdir/rootdirfile1.txt"}}FAIL {{ .Publish }}{{ else }}OK{{ end }}
Page: {{ .RelPermalink }}|{{ .Content }}| Page: {{ .RelPermalink }}|{{ .Content }}|
{{/* os template package. */}} {{/* os template package. */}}
{{ $symFilePath := "content/pagesymlink.md" }} {{ $symFilePath := "content/pagesymlink.md" }}
{{ with os.ReadDir "assets/myassets/symlinkdir" }}FAIL {{ len . }}{{ else }}OK{{ end }} {{ with os.ReadDir "assets/myassets/symlinkdir" }}FAIL {{ len . }}{{ else }}OK{{ end }}
{{ with os.Stat $symFilePath }}FAIL{{ else }}OK{{ end }} {{ template "check-os-path" $symFilePath }}
{{ with os.ReadFile $symFilePath }}FAIL{{ else }}OK{{ end }} {{ $symFilePath = "assets/myassets/symlinkdir/rootdirfile1.txt" }}
{{ with os.FileExists $symFilePath }}FAIL{{ else }}OK{{ end }} {{ 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 -- -- content/pageok.md --
-- themes/mytheme/assets/modassetok.txt -- -- themes/mytheme/assets/modassetok.txt --
@@ -53,5 +63,7 @@ My file.
Rootdirfile1 content. Rootdirfile1 content.
-- rootdir/rootdirfile2.txt -- -- rootdir/rootdirfile2.txt --
Rootdirfile2 content. Rootdirfile2 content.
-- rootdir/subdir/rootdirfile3.txt --
Rootdirfile3 content.