fix: dangling symlink, write, delete scope bugs

This commit is contained in:
Henrique Dias 2026-06-23 12:35:55 +02:00
parent be23ab3a15
commit 64511ce45e
No known key found for this signature in database
3 changed files with 277 additions and 12 deletions

View file

@ -5,6 +5,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -14,6 +15,7 @@ import (
"github.com/filebrowser/filebrowser/v2/diskcache"
"github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/storage"
"github.com/filebrowser/filebrowser/v2/storage/bolt"
"github.com/filebrowser/filebrowser/v2/users"
)
@ -115,3 +117,108 @@ func signToken(t *testing.T, perm users.Permissions, key []byte) string {
}
return signed
}
// scopedUserStorage returns a storage whose single user (ID 1) is scoped to
// userScope through a symlink-confining ScopedFs (via customFSUser), mirroring
// production. Used by the symlink scope-escape regression tests below.
func scopedUserStorage(t *testing.T, userScope string, perm users.Permissions, key []byte) *storage.Storage {
t.Helper()
db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
st, err := bolt.NewStorage(db)
if err != nil {
t.Fatalf("failed to get storage: %v", err)
}
if err := st.Users.Save(&users.User{Username: "u", Password: "pw", Perm: perm}); err != nil {
t.Fatalf("failed to save user: %v", err)
}
if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil {
t.Fatalf("failed to save settings: %v", err)
}
st.Users = &customFSUser{
Store: st.Users,
fs: afero.NewBasePathFs(afero.NewOsFs(), userScope),
}
return st
}
// Regression for the dangling-symlink write escape (GHSA-8wc8-hf36-mjh9 /
// GHSA-fh54-6rfh-r8f3): POSTing to an in-scope dangling symlink whose target is
// outside the scope must not dereference the link to create the out-of-scope
// file.
func TestResourcePostRejectsDanglingSymlinkWriteEscape(t *testing.T) {
root := t.TempDir()
userScope := filepath.Join(root, "user")
outside := filepath.Join(root, "outside")
for _, d := range []string{userScope, outside} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
// A dangling symlink inside the scope pointing at a not-yet-existing file
// outside it (planted out-of-band, per the advisory preconditions).
outsideTarget := filepath.Join(outside, "created.txt")
if err := os.Symlink(outsideTarget, filepath.Join(userScope, "evil")); err != nil {
t.Skipf("cannot create symlink: %v", err)
}
key := []byte("test-signing-key")
perm := users.Permissions{Create: true, Modify: true}
st := scopedUserStorage(t, userScope, perm, key)
signed := signToken(t, perm, key)
req, _ := http.NewRequest(http.MethodPost, "/evil?override=true", strings.NewReader("http-outside"))
req.Header.Set("X-Auth", signed)
rec := httptest.NewRecorder()
handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)
if _, statErr := os.Stat(outsideTarget); statErr == nil {
data, _ := os.ReadFile(outsideTarget)
t.Fatalf("VULNERABLE: out-of-scope file created via dangling symlink (status=%d, content=%q)", rec.Code, string(data))
}
if rec.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d body=%q", rec.Code, rec.Body.String())
}
}
// Regression for the symlink-following delete escape (GHSA-hq4g-mpch-f9vp /
// GHSA-fmm7-x4gx-8jhr): a Create-only user POSTing to a child of an escaping
// symlinked directory must not delete the out-of-scope target through the
// failed-upload cleanup RemoveAll.
func TestResourcePostCleanupDoesNotDeleteThroughSymlink(t *testing.T) {
root := t.TempDir()
userScope := filepath.Join(root, "user")
outside := filepath.Join(root, "outside")
for _, d := range []string{userScope, outside} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
victim := filepath.Join(outside, "victim.txt")
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
// An escaping directory symlink inside the scope (planted out-of-band).
if err := os.Symlink(outside, filepath.Join(userScope, "link")); err != nil {
t.Skipf("cannot create symlink: %v", err)
}
key := []byte("test-signing-key")
// Create-only: Perm.Delete is deliberately false — the bug must not need it.
perm := users.Permissions{Create: true}
st := scopedUserStorage(t, userScope, perm, key)
signed := signToken(t, perm, key)
req, _ := http.NewRequest(http.MethodPost, "/link/victim.txt", strings.NewReader("x"))
req.Header.Set("X-Auth", signed)
rec := httptest.NewRecorder()
handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)
if _, statErr := os.Stat(victim); statErr != nil {
t.Fatalf("VULNERABLE: out-of-scope victim.txt deleted by cleanup RemoveAll (status=%d): %v", rec.Code, statErr)
}
}