shithub: hugo

Download patch

ref: e5f229974166402f51e4ee0695ffb4d1e09fa174
parent: 87a07282a2f01779e098cde0aaee1bae34dc32e6
author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
date: Wed Jul 24 20:12:40 EDT 2019

Block symlink dir traversal for /static

This is in line with how it behaved before, but it was lifted a little for the project mount for Hugo Modules,
but that could create hard-to-detect loops.

diff: cannot open a/hugolib/data//null: file does not exist: 'a/hugolib/data//null'
--- a/cache/filecache/filecache_test.go
+++ b/cache/filecache/filecache_test.go
@@ -292,7 +292,7 @@
 	cfg, err := config.FromConfigString(configStr, "toml")
 	assert.NoError(err)
 	initConfig(fs, cfg)
-	p, err := helpers.NewPathSpec(hugofs.NewFrom(fs, cfg), cfg)
+	p, err := helpers.NewPathSpec(hugofs.NewFrom(fs, cfg), cfg, nil)
 	assert.NoError(err)
 	return p
 
--- a/deps/deps.go
+++ b/deps/deps.go
@@ -207,7 +207,7 @@
 		cfg.OutputFormats = output.DefaultFormats
 	}
 
-	ps, err := helpers.NewPathSpec(fs, cfg.Language)
+	ps, err := helpers.NewPathSpec(fs, cfg.Language, logger)
 
 	if err != nil {
 		return nil, errors.Wrap(err, "create PathSpec")
@@ -272,7 +272,7 @@
 	l := cfg.Language
 	var err error
 
-	d.PathSpec, err = helpers.NewPathSpecWithBaseBaseFsProvided(d.Fs, l, d.BaseFs)
+	d.PathSpec, err = helpers.NewPathSpecWithBaseBaseFsProvided(d.Fs, l, d.Log, d.BaseFs)
 	if err != nil {
 		return nil, err
 	}
--- a/helpers/path_test.go
+++ b/helpers/path_test.go
@@ -60,7 +60,7 @@
 		v.Set("removePathAccents", test.removeAccents)
 
 		l := langs.NewDefaultLanguage(v)
-		p, err := NewPathSpec(hugofs.NewMem(v), l)
+		p, err := NewPathSpec(hugofs.NewMem(v), l, nil)
 		require.NoError(t, err)
 
 		output := p.MakePath(test.input)
@@ -73,7 +73,7 @@
 func TestMakePathSanitized(t *testing.T) {
 	v := newTestCfg()
 
-	p, _ := NewPathSpec(hugofs.NewMem(v), v)
+	p, _ := NewPathSpec(hugofs.NewMem(v), v, nil)
 
 	tests := []struct {
 		input    string
@@ -101,7 +101,7 @@
 	v.Set("disablePathToLower", true)
 
 	l := langs.NewDefaultLanguage(v)
-	p, _ := NewPathSpec(hugofs.NewMem(v), l)
+	p, _ := NewPathSpec(hugofs.NewMem(v), l, nil)
 
 	tests := []struct {
 		input    string
--- a/helpers/pathspec.go
+++ b/helpers/pathspec.go
@@ -16,6 +16,7 @@
 import (
 	"strings"
 
+	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/config"
 	"github.com/gohugoio/hugo/hugofs"
 	"github.com/gohugoio/hugo/hugolib/filesystems"
@@ -37,13 +38,13 @@
 }
 
 // NewPathSpec creats a new PathSpec from the given filesystems and language.
-func NewPathSpec(fs *hugofs.Fs, cfg config.Provider) (*PathSpec, error) {
-	return NewPathSpecWithBaseBaseFsProvided(fs, cfg, nil)
+func NewPathSpec(fs *hugofs.Fs, cfg config.Provider, logger *loggers.Logger) (*PathSpec, error) {
+	return NewPathSpecWithBaseBaseFsProvided(fs, cfg, logger, nil)
 }
 
 // NewPathSpecWithBaseBaseFsProvided creats a new PathSpec from the given filesystems and language.
 // If an existing BaseFs is provided, parts of that is reused.
-func NewPathSpecWithBaseBaseFsProvided(fs *hugofs.Fs, cfg config.Provider, baseBaseFs *filesystems.BaseFs) (*PathSpec, error) {
+func NewPathSpecWithBaseBaseFsProvided(fs *hugofs.Fs, cfg config.Provider, logger *loggers.Logger, baseBaseFs *filesystems.BaseFs) (*PathSpec, error) {
 
 	p, err := paths.New(fs, cfg)
 	if err != nil {
@@ -56,7 +57,7 @@
 			filesystems.WithBaseFs(baseBaseFs),
 		}
 	}
-	bfs, err := filesystems.NewBase(p, options...)
+	bfs, err := filesystems.NewBase(p, logger, options...)
 	if err != nil {
 		return nil, err
 	}
--- a/helpers/pathspec_test.go
+++ b/helpers/pathspec_test.go
@@ -42,7 +42,7 @@
 	fs := hugofs.NewMem(v)
 	fs.Source.MkdirAll(filepath.FromSlash("thework/thethemes/thetheme"), 0777)
 
-	p, err := NewPathSpec(fs, l)
+	p, err := NewPathSpec(fs, l, nil)
 
 	require.NoError(t, err)
 	require.True(t, p.CanonifyURLs)
--- a/helpers/testhelpers_test.go
+++ b/helpers/testhelpers_test.go
@@ -10,7 +10,7 @@
 
 func newTestPathSpec(fs *hugofs.Fs, v *viper.Viper) *PathSpec {
 	l := langs.NewDefaultLanguage(v)
-	ps, _ := NewPathSpec(fs, l)
+	ps, _ := NewPathSpec(fs, l, nil)
 	return ps
 }
 
--- a/helpers/url_test.go
+++ b/helpers/url_test.go
@@ -28,7 +28,7 @@
 
 	v := newTestCfg()
 	l := langs.NewDefaultLanguage(v)
-	p, _ := NewPathSpec(hugofs.NewMem(v), l)
+	p, _ := NewPathSpec(hugofs.NewMem(v), l, nil)
 
 	tests := []struct {
 		input    string
@@ -90,7 +90,7 @@
 		v.Set("baseURL", test.baseURL)
 		v.Set("contentDir", "content")
 		l := langs.NewLanguage(lang, v)
-		p, _ := NewPathSpec(hugofs.NewMem(v), l)
+		p, _ := NewPathSpec(hugofs.NewMem(v), l, nil)
 
 		output := p.AbsURL(test.input, addLanguage)
 		expected := test.expected
@@ -168,7 +168,7 @@
 		v.Set("baseURL", test.baseURL)
 		v.Set("canonifyURLs", test.canonify)
 		l := langs.NewLanguage(lang, v)
-		p, _ := NewPathSpec(hugofs.NewMem(v), l)
+		p, _ := NewPathSpec(hugofs.NewMem(v), l, nil)
 
 		output := p.RelURL(test.input, addLanguage)
 
@@ -256,7 +256,7 @@
 		v := newTestCfg()
 		v.Set("uglyURLs", d.ugly)
 		l := langs.NewDefaultLanguage(v)
-		p, _ := NewPathSpec(hugofs.NewMem(v), l)
+		p, _ := NewPathSpec(hugofs.NewMem(v), l, nil)
 
 		output := p.URLPrep(d.input)
 		if d.output != output {
--- a/hugofs/decorators.go
+++ b/hugofs/decorators.go
@@ -90,19 +90,14 @@
 		isSymlink := isSymlink(fi)
 		if isSymlink {
 			meta[metaKeyOriginalFilename] = filename
-			link, err := filepath.EvalSymlinks(filename)
+			var link string
+			var err error
+			link, fi, err = evalSymlinks(fs, filename)
 			if err != nil {
 				return nil, err
 			}
-
-			fi, err = fs.Stat(link)
-			if err != nil {
-				return nil, err
-			}
-
 			filename = link
 			meta[metaKeyIsSymlink] = true
-
 		}
 
 		opener := func() (afero.File, error) {
@@ -115,6 +110,20 @@
 
 	ffs.decorate = decorator
 	return ffs
+}
+
+func evalSymlinks(fs afero.Fs, filename string) (string, os.FileInfo, error) {
+	link, err := filepath.EvalSymlinks(filename)
+	if err != nil {
+		return "", nil, err
+	}
+
+	fi, err := fs.Stat(link)
+	if err != nil {
+		return "", nil, err
+	}
+
+	return link, fi, nil
 }
 
 type baseFileDecoratorFs struct {
--- a/hugofs/fileinfo.go
+++ b/hugofs/fileinfo.go
@@ -180,9 +180,20 @@
 
 type fileInfoMeta struct {
 	os.FileInfo
+
 	m FileMeta
 }
 
+// Name returns the file's name. Note that we follow symlinks,
+// if supported by the file system, and the Name given here will be the
+// name of the symlink, which is what Hugo needs in all situations.
+func (fi *fileInfoMeta) Name() string {
+	if name := fi.m.Name(); name != "" {
+		return name
+	}
+	return fi.FileInfo.Name()
+}
+
 func (fi *fileInfoMeta) Meta() FileMeta {
 	return fi.m
 }
@@ -294,4 +305,12 @@
 		return norm.NFC.String(filename)
 	}
 	return filename
+}
+
+func fileInfosToNames(fis []os.FileInfo) []string {
+	names := make([]string, len(fis))
+	for i, d := range fis {
+		names[i] = d.Name()
+	}
+	return names
 }
--- a/hugofs/nosymlink_fs.go
+++ b/hugofs/nosymlink_fs.go
@@ -16,7 +16,10 @@
 import (
 	"errors"
 	"os"
+	"path/filepath"
 
+	"github.com/gohugoio/hugo/common/loggers"
+
 	"github.com/spf13/afero"
 )
 
@@ -24,15 +27,48 @@
 	ErrPermissionSymlink = errors.New("symlinks not allowed in this filesystem")
 )
 
-func NewNoSymlinkFs(fs afero.Fs) afero.Fs {
-	return &noSymlinkFs{Fs: fs}
+// NewNoSymlinkFs creates a new filesystem that prevents symlinks.
+func NewNoSymlinkFs(fs afero.Fs, logger *loggers.Logger, allowFiles bool) afero.Fs {
+	return &noSymlinkFs{Fs: fs, logger: logger, allowFiles: allowFiles}
 }
 
 // noSymlinkFs is a filesystem that prevents symlinking.
 type noSymlinkFs struct {
+	allowFiles bool // block dirs only
+	logger     *loggers.Logger
 	afero.Fs
 }
 
+type noSymlinkFile struct {
+	fs *noSymlinkFs
+	afero.File
+}
+
+func (f *noSymlinkFile) Readdir(count int) ([]os.FileInfo, error) {
+	fis, err := f.File.Readdir(count)
+
+	filtered := fis[:0]
+	for _, x := range fis {
+		filename := filepath.Join(f.Name(), x.Name())
+		if _, err := f.fs.checkSymlinkStatus(filename, x); err != nil {
+			// Log a warning and drop the file from the list
+			logUnsupportedSymlink(filename, f.fs.logger)
+		} else {
+			filtered = append(filtered, x)
+		}
+	}
+
+	return filtered, err
+}
+
+func (f *noSymlinkFile) Readdirnames(count int) ([]string, error) {
+	dirs, err := f.Readdir(count)
+	if err != nil {
+		return nil, err
+	}
+	return fileInfosToNames(dirs), nil
+}
+
 func (fs *noSymlinkFs) LstatIfPossible(name string) (os.FileInfo, bool, error) {
 	return fs.stat(name)
 }
@@ -53,21 +89,48 @@
 	if lstater, ok := fs.Fs.(afero.Lstater); ok {
 		fi, wasLstat, err = lstater.LstatIfPossible(name)
 	} else {
-
 		fi, err = fs.Fs.Stat(name)
 	}
 
+	if err != nil {
+		return nil, false, err
+	}
+
+	fi, err = fs.checkSymlinkStatus(name, fi)
+
+	return fi, wasLstat, err
+}
+
+func (fs *noSymlinkFs) checkSymlinkStatus(name string, fi os.FileInfo) (os.FileInfo, error) {
 	var metaIsSymlink bool
 
 	if fim, ok := fi.(FileMetaInfo); ok {
-		metaIsSymlink = fim.Meta().IsSymlink()
+		meta := fim.Meta()
+		metaIsSymlink = meta.IsSymlink()
 	}
 
-	if metaIsSymlink || isSymlink(fi) {
-		return nil, wasLstat, ErrPermissionSymlink
+	if metaIsSymlink {
+		if fs.allowFiles && !fi.IsDir() {
+			return fi, nil
+		}
+		return nil, ErrPermissionSymlink
 	}
 
-	return fi, wasLstat, err
+	// Also support non-decorated filesystems, e.g. the Os fs.
+	if isSymlink(fi) {
+		// Need to determine if this is a directory or not.
+		_, sfi, err := evalSymlinks(fs.Fs, name)
+		if err != nil {
+			return nil, err
+		}
+		if fs.allowFiles && !sfi.IsDir() {
+			// Return the original FileInfo to get the expected Name.
+			return fi, nil
+		}
+		return nil, ErrPermissionSymlink
+	}
+
+	return fi, nil
 }
 
 func (fs *noSymlinkFs) Open(name string) (afero.File, error) {
@@ -74,7 +137,7 @@
 	if _, _, err := fs.stat(name); err != nil {
 		return nil, err
 	}
-	return fs.Fs.Open(name)
+	return fs.wrapFile(fs.Fs.Open(name))
 }
 
 func (fs *noSymlinkFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
@@ -81,5 +144,13 @@
 	if _, _, err := fs.stat(name); err != nil {
 		return nil, err
 	}
-	return fs.Fs.OpenFile(name, flag, perm)
+	return fs.wrapFile(fs.Fs.OpenFile(name, flag, perm))
+}
+
+func (fs *noSymlinkFs) wrapFile(f afero.File, err error) (afero.File, error) {
+	if err != nil {
+		return nil, err
+	}
+
+	return &noSymlinkFile{File: f, fs: fs}, nil
 }
--- a/hugofs/nosymlink_test.go
+++ b/hugofs/nosymlink_test.go
@@ -18,6 +18,8 @@
 	"path/filepath"
 	"testing"
 
+	"github.com/gohugoio/hugo/common/loggers"
+
 	"github.com/gohugoio/hugo/htesting"
 
 	"github.com/spf13/afero"
@@ -25,73 +27,120 @@
 	"github.com/stretchr/testify/require"
 )
 
-func TestNoSymlinkFs(t *testing.T) {
-	if skipSymlink() {
-		t.Skip("Skip; os.Symlink needs administrator rights on Windows")
-	}
+func prepareSymlinks(t *testing.T) (string, func()) {
 	assert := require.New(t)
-	workDir, clean, err := htesting.CreateTempDir(Os, "hugo-nosymlink")
+
+	workDir, clean, err := htesting.CreateTempDir(Os, "hugo-symlink-test")
 	assert.NoError(err)
-	defer clean()
 	wd, _ := os.Getwd()
-	defer func() {
-		os.Chdir(wd)
-	}()
 
 	blogDir := filepath.Join(workDir, "blog")
-	blogFile := filepath.Join(blogDir, "a.txt")
-	assert.NoError(os.MkdirAll(blogDir, 0777))
-	afero.WriteFile(Os, filepath.Join(blogFile), []byte("content"), 0777)
+	blogSubDir := filepath.Join(blogDir, "sub")
+	assert.NoError(os.MkdirAll(blogSubDir, 0777))
+	blogFile1 := filepath.Join(blogDir, "a.txt")
+	blogFile2 := filepath.Join(blogSubDir, "b.txt")
+	afero.WriteFile(Os, filepath.Join(blogFile1), []byte("content1"), 0777)
+	afero.WriteFile(Os, filepath.Join(blogFile2), []byte("content2"), 0777)
 	os.Chdir(workDir)
 	assert.NoError(os.Symlink("blog", "symlinkdedir"))
 	os.Chdir(blogDir)
+	assert.NoError(os.Symlink("sub", "symsub"))
 	assert.NoError(os.Symlink("a.txt", "symlinkdedfile.txt"))
 
-	fs := NewNoSymlinkFs(Os)
-	ls := fs.(afero.Lstater)
-	symlinkedDir := filepath.Join(workDir, "symlinkdedir")
-	symlinkedFile := filepath.Join(blogDir, "symlinkdedfile.txt")
+	return workDir, func() {
+		clean()
+		os.Chdir(wd)
+	}
+}
 
-	// Check Stat and Lstat
-	for _, stat := range []func(name string) (os.FileInfo, error){
-		func(name string) (os.FileInfo, error) {
-			return fs.Stat(name)
-		},
-		func(name string) (os.FileInfo, error) {
-			fi, _, err := ls.LstatIfPossible(name)
-			return fi, err
-		},
-	} {
-		_, err = stat(symlinkedDir)
-		assert.Equal(ErrPermissionSymlink, err)
-		_, err = stat(symlinkedFile)
-		assert.Equal(ErrPermissionSymlink, err)
+func TestNoSymlinkFs(t *testing.T) {
+	if skipSymlink() {
+		t.Skip("Skip; os.Symlink needs administrator rights on Windows")
+	}
+	assert := require.New(t)
+	workDir, clean := prepareSymlinks(t)
+	defer clean()
 
-		fi, err := stat(filepath.Join(workDir, "blog"))
-		assert.NoError(err)
-		assert.NotNil(fi)
+	blogDir := filepath.Join(workDir, "blog")
+	blogFile1 := filepath.Join(blogDir, "a.txt")
 
-		fi, err = stat(blogFile)
-		assert.NoError(err)
-		assert.NotNil(fi)
-	}
+	logger := loggers.NewWarningLogger()
 
-	// Check Open
-	_, err = fs.Open(symlinkedDir)
-	assert.Equal(ErrPermissionSymlink, err)
-	_, err = fs.OpenFile(symlinkedDir, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666)
-	assert.Equal(ErrPermissionSymlink, err)
-	_, err = fs.OpenFile(symlinkedFile, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666)
-	assert.Equal(ErrPermissionSymlink, err)
-	_, err = fs.Open(symlinkedFile)
-	assert.Equal(ErrPermissionSymlink, err)
-	f, err := fs.Open(blogDir)
-	assert.NoError(err)
-	f.Close()
-	f, err = fs.Open(blogFile)
-	assert.NoError(err)
-	f.Close()
+	for _, bfs := range []afero.Fs{NewBaseFileDecorator(Os), Os} {
+		for _, allowFiles := range []bool{false, true} {
+			logger.WarnCounter.Reset()
+			fs := NewNoSymlinkFs(bfs, logger, allowFiles)
+			ls := fs.(afero.Lstater)
+			symlinkedDir := filepath.Join(workDir, "symlinkdedir")
+			symlinkedFilename := "symlinkdedfile.txt"
+			symlinkedFile := filepath.Join(blogDir, symlinkedFilename)
 
-	// os.OpenFile(logFile, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666)
+			assertFileErr := func(err error) {
+				if allowFiles {
+					assert.NoError(err)
+				} else {
+					assert.Equal(ErrPermissionSymlink, err)
+				}
+			}
+
+			assertFileStat := func(name string, fi os.FileInfo, err error) {
+				t.Helper()
+				assertFileErr(err)
+				if err == nil {
+					assert.NotNil(fi)
+					assert.Equal(name, fi.Name())
+				}
+			}
+
+			// Check Stat and Lstat
+			for _, stat := range []func(name string) (os.FileInfo, error){
+				func(name string) (os.FileInfo, error) {
+					return fs.Stat(name)
+				},
+				func(name string) (os.FileInfo, error) {
+					fi, _, err := ls.LstatIfPossible(name)
+					return fi, err
+				},
+			} {
+				fi, err := stat(symlinkedDir)
+				assert.Equal(ErrPermissionSymlink, err)
+				fi, err = stat(symlinkedFile)
+				assertFileStat(symlinkedFilename, fi, err)
+
+				fi, err = stat(filepath.Join(workDir, "blog"))
+				assert.NoError(err)
+				assert.NotNil(fi)
+
+				fi, err = stat(blogFile1)
+				assert.NoError(err)
+				assert.NotNil(fi)
+			}
+
+			// Check Open
+			_, err := fs.Open(symlinkedDir)
+			assert.Equal(ErrPermissionSymlink, err)
+			_, err = fs.OpenFile(symlinkedDir, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666)
+			assert.Equal(ErrPermissionSymlink, err)
+			_, err = fs.OpenFile(symlinkedFile, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666)
+			assertFileErr(err)
+			_, err = fs.Open(symlinkedFile)
+			assertFileErr(err)
+			f, err := fs.Open(blogDir)
+			assert.NoError(err)
+			f.Close()
+			f, err = fs.Open(blogFile1)
+			assert.NoError(err)
+			f.Close()
+
+			// Check readdir
+			f, err = fs.Open(workDir)
+			assert.NoError(err)
+			// There is at least one unsported symlink inside workDir
+			_, err = f.Readdir(-1)
+			f.Close()
+			assert.Equal(uint64(1), logger.WarnCounter.Count())
+
+		}
+	}
 
 }
--- a/hugofs/rootmapping_fs.go
+++ b/hugofs/rootmapping_fs.go
@@ -459,9 +459,5 @@
 	if err != nil {
 		return nil, err
 	}
-	dirss := make([]string, len(dirs))
-	for i, d := range dirs {
-		dirss[i] = d.Name()
-	}
-	return dirss, nil
+	return fileInfosToNames(dirs), nil
 }
--- a/hugofs/walk.go
+++ b/hugofs/walk.go
@@ -121,8 +121,7 @@
 				return nil
 			}
 
-			if err == ErrPermissionSymlink {
-				w.logger.WARN.Printf("Unsupported symlink found in %q, skipping.", w.root)
+			if w.checkErr(w.root, err) {
 				return nil
 			}
 
@@ -149,6 +148,19 @@
 	return fi, false, err
 }
 
+// checkErr returns true if the error is handled.
+func (w *Walkway) checkErr(filename string, err error) bool {
+	if err == ErrPermissionSymlink {
+		logUnsupportedSymlink(filename, w.logger)
+		return true
+	}
+	return false
+}
+
+func logUnsupportedSymlink(filename string, logger *loggers.Logger) {
+	logger.WARN.Printf("Unsupported symlink found in %q, skipping.", filename)
+}
+
 // walk recursively descends path, calling walkFn.
 // It follow symlinks if supported by the filesystem, but only the same path once.
 func (w *Walkway) walk(path string, info FileMetaInfo, dirEntries []FileMetaInfo, walkFn WalkFunc) error {
@@ -168,8 +180,10 @@
 
 	if dirEntries == nil {
 		f, err := w.fs.Open(path)
-
 		if err != nil {
+			if w.checkErr(path, err) {
+				return nil
+			}
 			return walkFn(path, info, errors.Wrapf(err, "walk: open %q (%q)", path, w.root))
 		}
 
@@ -176,8 +190,7 @@
 		fis, err := f.Readdir(-1)
 		f.Close()
 		if err != nil {
-			if err == ErrPermissionSymlink {
-				w.logger.WARN.Printf("Unsupported symlink found in %q, skipping.", filename)
+			if w.checkErr(filename, err) {
 				return nil
 			}
 			return walkFn(path, info, errors.Wrap(err, "walk: Readdir"))
--- a/hugolib/data/hugo.toml
+++ /dev/null
@@ -1,1 +1,0 @@
-slogan = "Hugo Rocks!"
\ No newline at end of file
--- a/hugolib/filesystems/basefs.go
+++ b/hugolib/filesystems/basefs.go
@@ -23,6 +23,8 @@
 	"strings"
 	"sync"
 
+	"github.com/gohugoio/hugo/common/loggers"
+
 	"github.com/gohugoio/hugo/hugofs/files"
 
 	"github.com/pkg/errors"
@@ -295,8 +297,11 @@
 }
 
 // NewBase builds the filesystems used by Hugo given the paths and options provided.NewBase
-func NewBase(p *paths.Paths, options ...func(*BaseFs) error) (*BaseFs, error) {
+func NewBase(p *paths.Paths, logger *loggers.Logger, options ...func(*BaseFs) error) (*BaseFs, error) {
 	fs := p.Fs
+	if logger == nil {
+		logger = loggers.NewWarningLogger()
+	}
 
 	publishFs := afero.NewBasePathFs(fs.Destination, p.AbsPublishDir)
 
@@ -314,7 +319,7 @@
 		return b, nil
 	}
 
-	builder := newSourceFilesystemsBuilder(p, b)
+	builder := newSourceFilesystemsBuilder(p, logger, b)
 	sourceFilesystems, err := builder.Build()
 	if err != nil {
 		return nil, errors.Wrap(err, "build filesystems")
@@ -327,6 +332,7 @@
 }
 
 type sourceFilesystemsBuilder struct {
+	logger   *loggers.Logger
 	p        *paths.Paths
 	sourceFs afero.Fs
 	result   *SourceFilesystems
@@ -333,9 +339,9 @@
 	theBigFs *filesystemsCollector
 }
 
-func newSourceFilesystemsBuilder(p *paths.Paths, b *BaseFs) *sourceFilesystemsBuilder {
+func newSourceFilesystemsBuilder(p *paths.Paths, logger *loggers.Logger, b *BaseFs) *sourceFilesystemsBuilder {
 	sourceFs := hugofs.NewBaseFileDecorator(p.Fs.Source)
-	return &sourceFilesystemsBuilder{p: p, sourceFs: sourceFs, theBigFs: b.theBigFs, result: &SourceFilesystems{}}
+	return &sourceFilesystemsBuilder{p: p, logger: logger, sourceFs: sourceFs, theBigFs: b.theBigFs, result: &SourceFilesystems{}}
 }
 
 func (b *sourceFilesystemsBuilder) newSourceFilesystem(fs afero.Fs, dirs []hugofs.FileMetaInfo) *SourceFilesystem {
@@ -415,7 +421,7 @@
 			ms[k] = sfs
 		}
 	} else {
-		bfs := afero.NewBasePathFs(b.theBigFs.overlayMounts, files.ComponentFolderStatic)
+		bfs := afero.NewBasePathFs(b.theBigFs.overlayMountsStatic, files.ComponentFolderStatic)
 		ms[""] = b.newSourceFilesystem(bfs, b.result.StaticDirs)
 	}
 
@@ -432,7 +438,7 @@
 
 	collector := &filesystemsCollector{
 		sourceProject:     b.sourceFs,
-		sourceModules:     hugofs.NewNoSymlinkFs(b.sourceFs),
+		sourceModules:     hugofs.NewNoSymlinkFs(b.sourceFs, b.logger, false),
 		overlayDirs:       make(map[string][]hugofs.FileMetaInfo),
 		staticPerLanguage: staticFsMap,
 	}
@@ -475,6 +481,10 @@
 	return strings.HasPrefix(mnt.Target, files.ComponentFolderContent)
 }
 
+func (b *sourceFilesystemsBuilder) isStaticMount(mnt modules.Mount) bool {
+	return strings.HasPrefix(mnt.Target, files.ComponentFolderStatic)
+}
+
 func (b *sourceFilesystemsBuilder) createModFs(
 	collector *filesystemsCollector,
 	md mountsDescriptor) error {
@@ -482,6 +492,7 @@
 	var (
 		fromTo        []hugofs.RootMapping
 		fromToContent []hugofs.RootMapping
+		fromToStatic  []hugofs.RootMapping
 	)
 
 	absPathify := func(path string) string {
@@ -544,6 +555,8 @@
 
 		if isContentMount {
 			fromToContent = append(fromToContent, rm)
+		} else if b.isStaticMount(mount) {
+			fromToStatic = append(fromToStatic, rm)
 		} else {
 			fromTo = append(fromTo, rm)
 		}
@@ -553,6 +566,7 @@
 	if !md.isMainProject {
 		modBase = collector.sourceModules
 	}
+	sourceStatic := hugofs.NewNoSymlinkFs(modBase, b.logger, true)
 
 	rmfs, err := hugofs.NewRootMappingFs(modBase, fromTo...)
 	if err != nil {
@@ -562,17 +576,22 @@
 	if err != nil {
 		return err
 	}
+	rmfsStatic, err := hugofs.NewRootMappingFs(sourceStatic, fromToStatic...)
+	if err != nil {
+		return err
+	}
 
 	// We need to keep the ordered list of directories for watching and
 	// some special merge operations (data, i18n).
 	collector.addDirs(rmfs)
 	collector.addDirs(rmfsContent)
+	collector.addDirs(rmfsStatic)
 
 	if collector.staticPerLanguage != nil {
 		for _, l := range b.p.Languages {
 			lang := l.Lang
 
-			lfs := rmfs.Filter(func(rm hugofs.RootMapping) bool {
+			lfs := rmfsStatic.Filter(func(rm hugofs.RootMapping) bool {
 				rlang := rm.Meta.Lang()
 				return rlang == "" || rlang == lang
 			})
@@ -599,6 +618,7 @@
 	if collector.overlayMounts == nil {
 		collector.overlayMounts = rmfs
 		collector.overlayMountsContent = rmfsContent
+		collector.overlayMountsStatic = rmfsStatic
 		collector.overlayFull = afero.NewBasePathFs(modBase, md.dir)
 		collector.overlayResources = afero.NewBasePathFs(modBase, getResourcesDir())
 	} else {
@@ -605,6 +625,7 @@
 
 		collector.overlayMounts = afero.NewCopyOnWriteFs(collector.overlayMounts, rmfs)
 		collector.overlayMountsContent = hugofs.NewLanguageCompositeFs(collector.overlayMountsContent, rmfsContent)
+		collector.overlayMountsStatic = hugofs.NewLanguageCompositeFs(collector.overlayMountsStatic, rmfsStatic)
 		collector.overlayFull = afero.NewCopyOnWriteFs(collector.overlayFull, afero.NewBasePathFs(modBase, md.dir))
 		collector.overlayResources = afero.NewCopyOnWriteFs(collector.overlayResources, afero.NewBasePathFs(modBase, getResourcesDir()))
 	}
@@ -639,6 +660,7 @@
 
 	overlayMounts        afero.Fs
 	overlayMountsContent afero.Fs
+	overlayMountsStatic  afero.Fs
 	overlayFull          afero.Fs
 	overlayResources     afero.Fs
 
--- a/hugolib/filesystems/basefs_test.go
+++ b/hugolib/filesystems/basefs_test.go
@@ -124,7 +124,7 @@
 	p, err := paths.New(fs, v)
 	assert.NoError(err)
 
-	bfs, err := NewBase(p)
+	bfs, err := NewBase(p, nil)
 	assert.NoError(err)
 	assert.NotNil(bfs)
 
@@ -206,7 +206,7 @@
 
 	p, err := paths.New(fs, v)
 	assert.NoError(err)
-	bfs, err := NewBase(p)
+	bfs, err := NewBase(p, nil)
 	assert.NoError(err)
 	assert.NotNil(bfs)
 	assert.NotNil(bfs.Archetypes.Fs)
@@ -263,7 +263,7 @@
 
 	p, err := paths.New(fs, v)
 	assert.NoError(err)
-	bfs, err := NewBase(p)
+	bfs, err := NewBase(p, nil)
 	assert.NoError(err)
 	assert.NotNil(bfs)
 
@@ -300,7 +300,7 @@
 
 	p, err := paths.New(fs, v)
 	assert.NoError(err)
-	bfs, err := NewBase(p)
+	bfs, err := NewBase(p, nil)
 	assert.NoError(err)
 
 	sfs := bfs.StaticFs("en")
@@ -344,7 +344,7 @@
 
 	p, err := paths.New(fs, v)
 	assert.NoError(err)
-	bfs, err := NewBase(p)
+	bfs, err := NewBase(p, nil)
 	assert.NoError(err)
 	enFs := bfs.StaticFs("en")
 	checkFileContent(enFs, "f1.txt", assert, "Hugo Rocks!")
--- a/hugolib/hugo_modules_test.go
+++ b/hugolib/hugo_modules_test.go
@@ -443,6 +443,7 @@
 `
 
 	b := newTestSitesBuilder(t).WithNothingAdded().WithWorkingDir(workDir)
+	b.WithLogger(loggers.NewErrorLogger())
 	b.Fs = fs
 
 	b.WithConfigFile("toml", config)
@@ -457,35 +458,46 @@
 
 	bfs := b.H.BaseFs
 
-	for _, componentFs := range []afero.Fs{
+	for i, componentFs := range []afero.Fs{
+		bfs.Static[""].Fs,
 		bfs.Archetypes.Fs,
 		bfs.Content.Fs,
 		bfs.Data.Fs,
 		bfs.Assets.Fs,
-		bfs.Static[""].Fs,
 		bfs.I18n.Fs} {
 
-		for i, id := range []string{"mod", "project"} {
+		if i != 0 {
+			continue
+		}
 
-			statCheck := func(fs afero.Fs, filename string) {
-				shouldFail := i == 0
+		for j, id := range []string{"mod", "project"} {
+
+			statCheck := func(fs afero.Fs, filename string, isDir bool) {
+				shouldFail := j == 0
+				if !shouldFail && i == 0 {
+					// Static dirs only supports symlinks for files
+					shouldFail = isDir
+				}
+
 				_, err := fs.Stat(filepath.FromSlash(filename))
+
 				if err != nil {
-					if strings.HasSuffix(filename, "toml") && strings.Contains(err.Error(), "files not supported") {
+					if i > 0 && strings.HasSuffix(filename, "toml") && strings.Contains(err.Error(), "files not supported") {
 						// OK
 						return
 					}
 				}
+
 				if shouldFail {
 					assert.Error(err)
-					assert.Equal(hugofs.ErrPermissionSymlink, err)
+					assert.Equal(hugofs.ErrPermissionSymlink, err, filename)
 				} else {
-					assert.NoError(err)
+					assert.NoError(err, filename)
 				}
 			}
 
-			statCheck(componentFs, fmt.Sprintf("realsym%s", id))
-			statCheck(componentFs, fmt.Sprintf("real/datasym%s.toml", id))
+			statCheck(componentFs, fmt.Sprintf("realsym%s", id), true)
+			statCheck(componentFs, fmt.Sprintf("real/datasym%s.toml", id), false)
 
 		}
 	}
--- a/hugolib/hugo_sites_build_test.go
+++ b/hugolib/hugo_sites_build_test.go
@@ -2,7 +2,6 @@
 
 import (
 	"fmt"
-	"os"
 	"strings"
 	"testing"
 
@@ -1282,7 +1281,7 @@
 			root = helpers.FilePathSeparator + root
 		}
 
-		helpers.PrintFs(fs, root, os.Stdout)
+		//helpers.PrintFs(fs, root, os.Stdout)
 		t.Fatalf("Failed to read file: %s", err)
 	}
 	return string(b)
--- a/hugolib/pages_capture_test.go
+++ b/hugolib/pages_capture_test.go
@@ -52,7 +52,7 @@
 	writeFile("pages/page2.md")
 	writeFile("pages/page.png")
 
-	ps, err := helpers.NewPathSpec(hugofs.NewFrom(fs, cfg), cfg)
+	ps, err := helpers.NewPathSpec(hugofs.NewFrom(fs, cfg), cfg, loggers.NewErrorLogger())
 	assert.NoError(err)
 	sourceSpec := source.NewSourceSpec(ps, fs)
 
--- a/resources/page/testhelpers_test.go
+++ b/resources/page/testhelpers_test.go
@@ -73,7 +73,7 @@
 	}
 	cfg.Set("allModules", modules.Modules{mod})
 	fs := hugofs.NewMem(cfg)
-	s, err := helpers.NewPathSpec(fs, cfg)
+	s, err := helpers.NewPathSpec(fs, cfg, nil)
 	if err != nil {
 		panic(err)
 	}
--- a/resources/testhelpers_test.go
+++ b/resources/testhelpers_test.go
@@ -66,7 +66,7 @@
 
 	fs := hugofs.NewMem(cfg)
 
-	s, err := helpers.NewPathSpec(fs, cfg)
+	s, err := helpers.NewPathSpec(fs, cfg, nil)
 	assert.NoError(err)
 
 	filecaches, err := filecache.NewCaches(s)
@@ -104,7 +104,7 @@
 	fs.Destination = &afero.MemMapFs{}
 	fs.Source = afero.NewBasePathFs(hugofs.Os, workDir)
 
-	s, err := helpers.NewPathSpec(fs, cfg)
+	s, err := helpers.NewPathSpec(fs, cfg, nil)
 	assert.NoError(err)
 
 	filecaches, err := filecache.NewCaches(s)
--- a/source/content_directory_test.go
+++ b/source/content_directory_test.go
@@ -54,7 +54,7 @@
 		v := newTestConfig()
 		v.Set("ignoreFiles", test.ignoreFilesRegexpes)
 		fs := hugofs.NewMem(v)
-		ps, err := helpers.NewPathSpec(fs, v)
+		ps, err := helpers.NewPathSpec(fs, v, nil)
 		assert.NoError(err)
 
 		s := NewSourceSpec(ps, fs.Source)
--- a/source/filesystem_test.go
+++ b/source/filesystem_test.go
@@ -103,7 +103,7 @@
 func newTestSourceSpec() *SourceSpec {
 	v := newTestConfig()
 	fs := hugofs.NewFrom(hugofs.NewBaseFileDecorator(afero.NewMemMapFs()), v)
-	ps, err := helpers.NewPathSpec(fs, v)
+	ps, err := helpers.NewPathSpec(fs, v, nil)
 	if err != nil {
 		panic(err)
 	}
--- a/tpl/data/resources_test.go
+++ b/tpl/data/resources_test.go
@@ -203,7 +203,7 @@
 	fs := hugofs.NewMem(cfg)
 	logger := loggers.NewErrorLogger()
 
-	p, err := helpers.NewPathSpec(fs, cfg)
+	p, err := helpers.NewPathSpec(fs, cfg, nil)
 	if err != nil {
 		panic(err)
 	}