Skip to content

Commit 6bdf4c8

Browse files
committed
Security, scope, and share access fixes
Harden symlink, archive, and auth checks Fix copy/move, uploads, and file handling Improve path, listing, and share behavior UI cleanup and parser reliability updates
1 parent 6fbc408 commit 6bdf4c8

65 files changed

Lines changed: 3230 additions & 764 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

auth/hook.go

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store, stg *settings.Settings
6868
case "block":
6969
return nil, os.ErrPermission
7070
case "pass":
71-
u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
71+
u, err := a.Users.Get(a.Server.Root, a.Server.FollowExternalSymlinks, a.Cred.Username)
7272
if err != nil || !users.CheckPwd(a.Cred.Password, u.Password) {
7373
return nil, os.ErrPermission
7474
}
@@ -86,22 +86,6 @@ func (a *HookAuth) LoginPage() bool {
8686
// RunCommand starts the hook command and returns the action
8787
func (a *HookAuth) RunCommand() (string, error) {
8888
command := strings.Split(a.Command, " ")
89-
envMapping := func(key string) string {
90-
switch key {
91-
case "USERNAME":
92-
return a.Cred.Username
93-
case "PASSWORD":
94-
return a.Cred.Password
95-
default:
96-
return os.Getenv(key)
97-
}
98-
}
99-
for i, arg := range command {
100-
if i == 0 {
101-
continue
102-
}
103-
command[i] = os.Expand(arg, envMapping)
104-
}
10589

10690
cmd := exec.Command(command[0], command[1:]...)
10791
cmd.Env = append(os.Environ(), fmt.Sprintf("USERNAME=%s", a.Cred.Username))
@@ -145,7 +129,7 @@ func (a *HookAuth) GetValues(s string) {
145129

146130
// SaveUser updates the existing user or creates a new one when not found
147131
func (a *HookAuth) SaveUser() (*users.User, error) {
148-
u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
132+
u, err := a.Users.Get(a.Server.Root, a.Server.FollowExternalSymlinks, a.Cred.Username)
149133
if err != nil && !errors.Is(err, fberrors.ErrNotExist) {
150134
return nil, err
151135
}

auth/hook_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package auth
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
)
9+
10+
// writeHookScript writes a POSIX shell script to a temp file and returns its
11+
// path, marking it executable.
12+
func writeHookScript(t *testing.T, body string) string {
13+
t.Helper()
14+
path := filepath.Join(t.TempDir(), "hook.sh")
15+
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o700); err != nil {
16+
t.Fatalf("failed to write hook script: %v", err)
17+
}
18+
return path
19+
}
20+
21+
// TestRunCommandNoCredentialInjection ensures that attacker-controlled
22+
// credentials submitted at the unauthenticated login endpoint cannot be
23+
// injected into the hook command string. Credentials must only ever reach the
24+
// hook through the USERNAME/PASSWORD environment variables, never via string
25+
// substitution into the command itself (CWE-78/CWE-88).
26+
func TestRunCommandNoCredentialInjection(t *testing.T) {
27+
if runtime.GOOS == "windows" {
28+
t.Skip("uses POSIX shell")
29+
}
30+
31+
marker := filepath.Join(t.TempDir(), "pwned")
32+
33+
// The hook simply blocks. If the credential were ever interpolated into the
34+
// command string and evaluated by a shell, the embedded `touch` would
35+
// create the marker file.
36+
script := writeHookScript(t, "echo hook.action=block\n")
37+
38+
a := &HookAuth{
39+
Command: script,
40+
Cred: hookCred{
41+
Username: `"; touch ` + marker + `; #`,
42+
Password: `$(touch ` + marker + `)`,
43+
},
44+
}
45+
46+
action, err := a.RunCommand()
47+
if err != nil {
48+
t.Fatalf("RunCommand returned error: %v", err)
49+
}
50+
if action != "block" {
51+
t.Fatalf("expected action %q, got %q", "block", action)
52+
}
53+
if _, err := os.Stat(marker); err == nil {
54+
t.Fatalf("credential injection executed: marker file %q was created", marker)
55+
}
56+
}
57+
58+
// TestRunCommandReceivesCredentialsViaEnv verifies the supported contract: the
59+
// hook receives credentials through the USERNAME and PASSWORD environment
60+
// variables.
61+
func TestRunCommandReceivesCredentialsViaEnv(t *testing.T) {
62+
if runtime.GOOS == "windows" {
63+
t.Skip("uses POSIX shell")
64+
}
65+
66+
script := writeHookScript(t, `if [ "$USERNAME" = alice ] && [ "$PASSWORD" = secret ]; then
67+
echo hook.action=auth
68+
else
69+
echo hook.action=block
70+
fi
71+
`)
72+
73+
a := &HookAuth{
74+
Command: script,
75+
Cred: hookCred{
76+
Username: "alice",
77+
Password: "secret",
78+
},
79+
}
80+
81+
action, err := a.RunCommand()
82+
if err != nil {
83+
t.Fatalf("RunCommand returned error: %v", err)
84+
}
85+
if action != "auth" {
86+
t.Fatalf("expected action %q, got %q", "auth", action)
87+
}
88+
}

auth/json.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store, _ *settings.Settings, s
206206
cred, err := extractCredentials(authHeader)
207207
if err != nil {
208208
log.Printf("Warning: Failed to extract credentials. %s", err)
209-
return nil, err
209+
return nil, os.ErrPermission
210210
}
211211

212212
// If ReCaptcha is enabled, check the code.
@@ -222,7 +222,7 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store, _ *settings.Settings, s
222222
}
223223
}
224224

225-
u, err := usr.Get(srv.Root, cred.Username)
225+
u, err := usr.Get(srv.Root, srv.FollowExternalSymlinks, cred.Username)
226226
if err != nil {
227227
log.Printf("Warning: Login error for %s - lookup failed: %v", cred.Username, err)
228228
// Even if the user is not found, we check the password against a dummy hash

auth/none.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type NoAuth struct{}
1515

1616
// Auth uses authenticates user 1.
1717
func (a NoAuth) Auth(_ *http.Request, usr users.Store, _ *settings.Settings, srv *settings.Server) (*users.User, error) {
18-
return usr.Get(srv.Root, uint(1))
18+
return usr.Get(srv.Root, srv.FollowExternalSymlinks, uint(1))
1919
}
2020

2121
// LoginPage tells that no auth doesn't require a login page.

auth/proxy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type ProxyAuth struct {
2020
// Auth authenticates the user via an HTTP header.
2121
func (a ProxyAuth) Auth(r *http.Request, usr users.Store, setting *settings.Settings, srv *settings.Server) (*users.User, error) {
2222
username := r.Header.Get(a.Header)
23-
user, err := usr.Get(srv.Root, username)
23+
user, err := usr.Get(srv.Root, srv.FollowExternalSymlinks, username)
2424
if errors.Is(err, fberrors.ErrNotExist) {
2525
return a.createUser(usr, setting, srv, username)
2626
}

auth/proxy_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ type mockUserStore struct {
1313
users map[string]*users.User
1414
}
1515

16-
func (m *mockUserStore) Get(_ string, id interface{}) (*users.User, error) {
16+
func (m *mockUserStore) Get(_ string, _ bool, id interface{}) (*users.User, error) {
1717
if v, ok := id.(string); ok {
1818
if u, ok := m.users[v]; ok {
1919
return u, nil
@@ -22,14 +22,15 @@ func (m *mockUserStore) Get(_ string, id interface{}) (*users.User, error) {
2222
return nil, fberrors.ErrNotExist
2323
}
2424

25-
func (m *mockUserStore) Gets(_ string) ([]*users.User, error) { return nil, nil }
26-
func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil }
25+
func (m *mockUserStore) GetByScope(_ string) (*users.User, error) { return nil, fberrors.ErrNotExist }
26+
func (m *mockUserStore) Gets(_ string, _ bool) ([]*users.User, error) { return nil, nil }
27+
func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil }
2728
func (m *mockUserStore) Save(user *users.User) error {
2829
m.users[user.Username] = user
2930
return nil
3031
}
3132
func (m *mockUserStore) Delete(_ interface{}) error { return nil }
32-
func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 }
33+
func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 }
3334

3435
func TestProxyAuthCreateUserRestrictsDefaults(t *testing.T) {
3536
t.Parallel()

cmd/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
244244
fmt.Fprintf(w, "\tThumbnails Enabled:\t%t\n", ser.EnableThumbnails)
245245
fmt.Fprintf(w, "\tResize Preview:\t%t\n", ser.ResizePreview)
246246
fmt.Fprintf(w, "\tType Detection by Header:\t%t\n", ser.TypeDetectionByHeader)
247+
fmt.Fprintf(w, "\tFollow External Symlinks:\t%t\n", ser.FollowExternalSymlinks)
247248

248249
fmt.Fprintln(w, "\nTUS:")
249250
fmt.Fprintf(w, "\tChunk size:\t%d\n", set.Tus.ChunkSize)

cmd/root.go

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"os"
1414
"os/signal"
1515
"path/filepath"
16+
"strings"
1617
"syscall"
1718
"time"
1819

@@ -111,6 +112,7 @@ func addServerFlags(flags *pflag.FlagSet) {
111112
flags.Bool("disableExec", true, "disables Command Runner feature")
112113
flags.Bool("disableTypeDetectionByHeader", false, "disables type detection by reading file headers")
113114
flags.Bool("disableImageResolutionCalc", false, "disables image resolution calculation by reading image files")
115+
flags.Bool("followExternalSymlinks", false, "follow symlinks whose target is outside the user scope (unsafe)")
114116
}
115117

116118
var rootCmd = &cobra.Command{
@@ -362,6 +364,10 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
362364
server.EnableExec = !v.GetBool("disableExec")
363365
}
364366

367+
if v.IsSet("followExternalSymlinks") {
368+
server.FollowExternalSymlinks = v.GetBool("followExternalSymlinks")
369+
}
370+
365371
if isAddrSet && isSocketSet {
366372
return nil, errors.New("--socket flag cannot be used with --address, --port, --key nor --cert")
367373
}
@@ -378,6 +384,25 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
378384
log.Println("WARNING: read https://github.com/thevickypedia/filebrowser/issues/5199")
379385
}
380386

387+
if server.FollowExternalSymlinks {
388+
log.Println("WARNING: Following external symlinks enabled!")
389+
log.Println("WARNING: Symlinks pointing outside a user's scope will be followed,")
390+
log.Println("WARNING: which can expose files outside that scope. Only enable this if")
391+
log.Println("WARNING: you fully understand and trust the contents of every user scope.")
392+
}
393+
394+
if set, err := st.Settings.Get(); err == nil && set.Signup {
395+
scope := strings.TrimSpace(set.Defaults.Scope)
396+
scopeIsRoot := scope == "" || scope == "." || scope == "/"
397+
398+
if !set.CreateUserDir && scopeIsRoot {
399+
log.Println("WARNING: Signup is enabled without createUserDir and the default scope is")
400+
log.Println("WARNING: the server root, so every self-registered user can read, modify and")
401+
log.Println("WARNING: delete all files File Browser serves, including other users' files.")
402+
log.Println("WARNING: Enable createUserDir, or set a default scope other than the root.")
403+
}
404+
}
405+
381406
return server, nil
382407
}
383408

@@ -455,19 +480,20 @@ func quickSetup(v *viper.Viper, s *storage.Storage) error {
455480
}
456481

457482
ser := &settings.Server{
458-
BaseURL: v.GetString("baseURL"),
459-
Port: v.GetString("port"),
460-
Log: v.GetString("log"),
461-
TLSKey: v.GetString("key"),
462-
TLSCert: v.GetString("cert"),
463-
Address: v.GetString("address"),
464-
Root: v.GetString("root"),
465-
TokenExpirationTime: v.GetString("tokenExpirationTime"),
466-
EnableThumbnails: !v.GetBool("disableThumbnails"),
467-
ResizePreview: !v.GetBool("disablePreviewResize"),
468-
EnableExec: !v.GetBool("disableExec"),
469-
TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"),
470-
ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"),
483+
BaseURL: v.GetString("baseURL"),
484+
Port: v.GetString("port"),
485+
Log: v.GetString("log"),
486+
TLSKey: v.GetString("key"),
487+
TLSCert: v.GetString("cert"),
488+
Address: v.GetString("address"),
489+
Root: v.GetString("root"),
490+
TokenExpirationTime: v.GetString("tokenExpirationTime"),
491+
EnableThumbnails: !v.GetBool("disableThumbnails"),
492+
ResizePreview: !v.GetBool("disablePreviewResize"),
493+
EnableExec: !v.GetBool("disableExec"),
494+
TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"),
495+
ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"),
496+
FollowExternalSymlinks: v.GetBool("followExternalSymlinks"),
471497
}
472498

473499
err = s.Settings.SaveServer(ser)

cmd/rules.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User)
3636
}
3737
if id != nil {
3838
var user *users.User
39-
user, err = st.Users.Get("", id)
39+
user, err = st.Users.Get("", false, id)
4040
if err != nil {
4141
return err
4242
}

cmd/users.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func addUserFlags(flags *pflag.FlagSet) {
8080
flags.Bool("singleClick", false, "use single clicks only")
8181
flags.Bool("redirectAfterCopyMove", false, "redirect to destination after copy/move")
8282
flags.Bool("dateFormat", false, "use date format (true for absolute time, false for relative)")
83-
flags.Bool("hideDotfiles", false, "hide dotfiles")
83+
flags.Bool("hideDotfiles", false, "hide dotfiles in file listings")
8484
flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users")
8585
}
8686

0 commit comments

Comments
 (0)