Skip to content

Commit 5ae0813

Browse files
committed
- Make provisioned scope assignment atomic
- Move the provisioned scope check into `users.Storage.SaveProvisioned` so the lookup and save occur under a single lock - Prevent concurrent provisioning of usernames that normalize to the same home directory - Simplify `CreateUserHome` to only derive and create the home directory - Add concurrent provisioning tests covering signup, proxy auth, and hook auth - Cover the case where explicitly assigned (non-derived) scopes are intentionally shared - Use `aria-selected` where appropriate
1 parent 2866414 commit 5ae0813

10 files changed

Lines changed: 198 additions & 70 deletions

File tree

auth/hook.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,13 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
160160
// A scope explicitly returned by the hook takes precedence over the
161161
// automatic per-user home directory derivation.
162162
_, explicitScope := a.Fields.Values["user.scope"]
163-
if err := a.Settings.CreateUserHome(u, a.Users, a.Server.Root, explicitScope); err != nil {
163+
derivedScope, err := a.Settings.CreateUserHome(u, a.Server.Root, explicitScope)
164+
if err != nil {
164165
return nil, err
165166
}
166167
log.Printf("user: %s, home dir: [%s].", u.Username, u.Scope)
167168

168-
if err := a.Users.Save(u); err != nil {
169+
if err := a.Users.SaveProvisioned(u, derivedScope); err != nil {
169170
return nil, err
170171
}
171172
} else if p := !users.CheckPwd(a.Cred.Password, u.Password); len(a.Fields.Values) > 1 || p {

auth/proxy.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,12 @@ func (a ProxyAuth) createUser(usr users.Store, setting *settings.Settings, srv *
5050
user.Perm.Execute = false
5151
user.Commands = []string{}
5252

53-
if err = setting.CreateUserHome(user, usr, srv.Root, false); err != nil {
53+
var derivedScope bool
54+
if derivedScope, err = setting.CreateUserHome(user, srv.Root, false); err != nil {
5455
return nil, err
5556
}
5657

57-
if err = usr.Save(user); err != nil {
58+
if err = usr.SaveProvisioned(user, derivedScope); err != nil {
5859
return nil, err
5960
}
6061

auth/proxy_test.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package auth
22

33
import (
44
"net/http"
5+
"strings"
56
"testing"
67

78
fberrors "github.com/thevickypedia/filebrowser/v2/errors"
@@ -22,13 +23,31 @@ func (m *mockUserStore) Get(_ string, _ bool, id interface{}) (*users.User, erro
2223
return nil, fberrors.ErrNotExist
2324
}
2425

25-
func (m *mockUserStore) GetByScope(_ string) (*users.User, error) { return nil, fberrors.ErrNotExist }
26+
func (m *mockUserStore) GetByScope(scope string) (*users.User, error) {
27+
for _, u := range m.users {
28+
if strings.EqualFold(u.Scope, scope) {
29+
return u, nil
30+
}
31+
}
32+
return nil, fberrors.ErrNotExist
33+
}
34+
2635
func (m *mockUserStore) Gets(_ string, _ bool) ([]*users.User, error) { return nil, nil }
2736
func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil }
2837
func (m *mockUserStore) Save(user *users.User) error {
2938
m.users[user.Username] = user
3039
return nil
3140
}
41+
42+
func (m *mockUserStore) SaveProvisioned(user *users.User, derivedScope bool) error {
43+
if derivedScope {
44+
if _, err := m.GetByScope(user.Scope); err == nil {
45+
return fberrors.ErrExist
46+
}
47+
}
48+
return m.Save(user)
49+
}
50+
3251
func (m *mockUserStore) Delete(_ interface{}) error { return nil }
3352
func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 }
3453

frontend/src/components/files/ListingItem.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
:data-dir="isDir"
1919
:data-type="type"
2020
:aria-label="name"
21-
:aria-pressed="isSelected"
21+
:aria-selected="isSelected"
2222
:data-ext="getExtension(name).toLowerCase()"
2323
@contextmenu="contextMenu"
2424
>

frontend/src/components/prompts/FileList.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
role="button"
99
tabindex="0"
1010
:aria-label="item.name"
11-
:aria-pressed="selected == item.url"
11+
:aria-selected="selected == item.url"
1212
:key="item.name"
1313
v-for="item in items"
1414
:data-url="item.url"

http/auth.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -245,16 +245,14 @@ var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int,
245245

246246
user.Password = pwd
247247

248-
switch err := d.settings.CreateUserHome(user, d.store.Users, d.server.Root, false); {
249-
case errors.Is(err, fberrors.ErrExist):
250-
return http.StatusConflict, fberrors.ErrExist
251-
case err != nil:
248+
derivedScope, err := d.settings.CreateUserHome(user, d.server.Root, false)
249+
if err != nil {
252250
return http.StatusInternalServerError, err
253251
}
254252

255253
log.Printf("new user: %s, home dir: [%s].", user.Username, user.Scope)
256254

257-
err = d.store.Users.Save(user)
255+
err = d.store.Users.SaveProvisioned(user, derivedScope)
258256
if errors.Is(err, fberrors.ErrExist) {
259257
return http.StatusConflict, err
260258
} else if err != nil {

settings/dir.go

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111

1212
"github.com/spf13/afero"
1313

14-
fberrors "github.com/thevickypedia/filebrowser/v2/errors"
1514
"github.com/thevickypedia/filebrowser/v2/users"
1615
)
1716

@@ -48,31 +47,25 @@ func (s *Settings) MakeUserDir(username, userScope, serverRoot string) (string,
4847
// supply an explicit scope, the scope is cleared so that MakeUserDir derives a
4948
// per-user home from the username instead of falling back to the default scope
5049
// (which normalizes to the server root, leaving every provisioned user sharing
51-
// it). When a home directory is derived, it also rejects a scope already owned
52-
// by another user, so that distinct usernames cannot silently share one home
53-
// directory.
54-
func (s *Settings) CreateUserHome(user *users.User, store users.Store, serverRoot string, explicitScope bool) error {
55-
derived := s.CreateUserDir && !explicitScope
50+
// it).
51+
//
52+
// It reports whether the scope was derived from the username. A derived scope
53+
// must be persisted with users.Storage.SaveProvisioned, which rejects a scope
54+
// already owned by another user so that distinct usernames cannot silently
55+
// share one home directory.
56+
func (s *Settings) CreateUserHome(user *users.User, serverRoot string, explicitScope bool) (derived bool, err error) {
57+
derived = s.CreateUserDir && !explicitScope
5658
if derived {
5759
user.Scope = ""
5860
}
5961

6062
userHome, err := s.MakeUserDir(user.Username, user.Scope, serverRoot)
6163
if err != nil {
62-
return err
64+
return false, err
6365
}
6466
user.Scope = userHome
6567

66-
if derived {
67-
switch _, err := store.GetByScope(user.Scope); {
68-
case err == nil:
69-
return fberrors.ErrExist
70-
case !errors.Is(err, fberrors.ErrNotExist):
71-
return err
72-
}
73-
}
74-
75-
return nil
68+
return derived, nil
7669
}
7770

7871
func cleanUsername(s string) string {

settings/dir_test.go

Lines changed: 12 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,44 @@
11
package settings
22

33
import (
4-
"errors"
54
"testing"
65

7-
fberrors "github.com/thevickypedia/filebrowser/v2/errors"
86
"github.com/thevickypedia/filebrowser/v2/users"
97
)
108

11-
// stubStore is a minimal users.Store used to exercise CreateUserHome.
12-
type stubStore struct {
13-
byScope map[string]*users.User
14-
}
15-
16-
func (s *stubStore) Get(_ string, _ bool, _ interface{}) (*users.User, error) {
17-
return nil, fberrors.ErrNotExist
18-
}
19-
20-
func (s *stubStore) GetByScope(scope string) (*users.User, error) {
21-
if u, ok := s.byScope[scope]; ok {
22-
return u, nil
23-
}
24-
return nil, fberrors.ErrNotExist
25-
}
26-
27-
func (s *stubStore) Gets(_ string, _ bool) ([]*users.User, error) { return nil, nil }
28-
func (s *stubStore) Update(_ *users.User, _ ...string) error { return nil }
29-
func (s *stubStore) Save(_ *users.User) error { return nil }
30-
func (s *stubStore) Delete(_ interface{}) error { return nil }
31-
func (s *stubStore) LastUpdate(_ uint) int64 { return 0 }
32-
339
// A user provisioned with CreateUserDir must receive a per-user home directory
3410
// derived from its username, not the default scope which normalizes to the
3511
// server root.
3612
func TestCreateUserHomeDerivesPerUserScope(t *testing.T) {
3713
s := &Settings{CreateUserDir: true, UserHomeBasePath: "/users"}
38-
store := &stubStore{byScope: map[string]*users.User{}}
3914

4015
user := &users.User{Username: "alice", Scope: "."}
41-
if err := s.CreateUserHome(user, store, t.TempDir(), false); err != nil {
16+
derived, err := s.CreateUserHome(user, t.TempDir(), false)
17+
if err != nil {
4218
t.Fatalf("unexpected error: %v", err)
4319
}
20+
if !derived {
21+
t.Error("expected the scope to be reported as derived")
22+
}
4423
if user.Scope != "/users/alice" {
4524
t.Errorf("expected derived scope /users/alice, got %q", user.Scope)
4625
}
4726
}
4827

49-
// When the derived scope is already owned by another user, provisioning must be
50-
// rejected so distinct usernames cannot silently share one home directory.
51-
func TestCreateUserHomeRejectsCollision(t *testing.T) {
52-
s := &Settings{CreateUserDir: true, UserHomeBasePath: "/users"}
53-
store := &stubStore{byScope: map[string]*users.User{"/users/alice": {Username: "alice"}}}
54-
55-
user := &users.User{Username: "alice", Scope: "."}
56-
if err := s.CreateUserHome(user, store, t.TempDir(), false); !errors.Is(err, fberrors.ErrExist) {
57-
t.Fatalf("expected ErrExist on scope collision, got %v", err)
58-
}
59-
}
60-
6128
// A scope explicitly supplied by the caller (e.g. returned by an auth hook) must
62-
// be preserved instead of being replaced by a derived home directory.
29+
// be preserved instead of being replaced by a derived home directory, and must
30+
// not be reported as derived: it is legitimate for several users to share it.
6331
func TestCreateUserHomePreservesExplicitScope(t *testing.T) {
6432
s := &Settings{CreateUserDir: true, UserHomeBasePath: "/users"}
65-
store := &stubStore{byScope: map[string]*users.User{"/users/alice": {Username: "alice"}}}
6633

6734
user := &users.User{Username: "alice", Scope: "/custom"}
68-
if err := s.CreateUserHome(user, store, t.TempDir(), true); err != nil {
35+
derived, err := s.CreateUserHome(user, t.TempDir(), true)
36+
if err != nil {
6937
t.Fatalf("unexpected error: %v", err)
7038
}
39+
if derived {
40+
t.Error("an explicit scope must not be reported as derived")
41+
}
7142
if user.Scope != "/custom" {
7243
t.Errorf("explicit scope should be preserved, got %q", user.Scope)
7344
}

users/storage.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package users
22

33
import (
4+
"errors"
45
"sync"
56
"time"
67

@@ -25,6 +26,7 @@ type Store interface {
2526
Gets(baseScope string, followExternalSymlinks bool) ([]*User, error)
2627
Update(user *User, fields ...string) error
2728
Save(user *User) error
29+
SaveProvisioned(user *User, derivedScope bool) error
2830
Delete(id interface{}) error
2931
LastUpdate(id uint) int64
3032
}
@@ -34,6 +36,10 @@ type Storage struct {
3436
back StorageBackend
3537
updated map[uint]int64
3638
mux sync.RWMutex
39+
40+
// provision serializes the scope-collision check and the save of newly
41+
// provisioned users, which must not interleave. See SaveProvisioned.
42+
provision sync.Mutex
3743
}
3844

3945
// NewStorage creates a users storage from a backend.
@@ -108,6 +114,32 @@ func (s *Storage) Save(user *User) error {
108114
return s.back.Save(user)
109115
}
110116

117+
// SaveProvisioned saves a user that is being provisioned (via signup, proxy
118+
// auth or hook auth). When its scope was derived from the username, it first
119+
// rejects the save if another user already owns that scope, so that distinct
120+
// usernames cannot silently share one home directory.
121+
//
122+
// The check and the save are held under a single lock. Performing them as two
123+
// independent operations lets two concurrent provisioning requests both observe
124+
// a free scope and both save, leaving two users sharing one home directory.
125+
func (s *Storage) SaveProvisioned(user *User, derivedScope bool) error {
126+
if !derivedScope {
127+
return s.Save(user)
128+
}
129+
130+
s.provision.Lock()
131+
defer s.provision.Unlock()
132+
133+
switch _, err := s.back.GetByScope(user.Scope); {
134+
case err == nil:
135+
return fberrors.ErrExist
136+
case !errors.Is(err, fberrors.ErrNotExist):
137+
return err
138+
}
139+
140+
return s.Save(user)
141+
}
142+
111143
// Delete allows you to delete a user by its name or username. The provided
112144
// id must be a string for username lookup or a uint for id lookup. If id
113145
// is neither, a ErrInvalidDataType will be returned.

0 commit comments

Comments
 (0)