filebrowser/http/auth_test.go

151 lines
5.2 KiB
Go

package fbhttp
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/asdine/storm/v3"
"github.com/golang-jwt/jwt/v5"
fbAuth "github.com/filebrowser/filebrowser/v2/auth"
"github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/storage/bolt"
"github.com/filebrowser/filebrowser/v2/users"
)
// Regression for the username-normalization home-directory collision
// (GHSA-7rc3-g7h6-22m7): with Signup and CreateUserDir enabled, two distinct
// usernames that cleanUsername() normalizes to the same directory must not be
// handed the same home directory. The second registration is rejected.
func TestSignupRejectsCollidingNormalizedScope(t *testing.T) {
root := t.TempDir()
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.Settings.Save(&settings.Settings{
Key: []byte("test-signing-key"),
Signup: true,
CreateUserDir: true,
UserHomeBasePath: "/users",
MinimumPasswordLength: 1,
}); err != nil {
t.Fatalf("failed to save settings: %v", err)
}
server := &settings.Server{Root: root}
signup := func(username string) *httptest.ResponseRecorder {
body := `{"username":"` + username + `","password":"CollidePw12345!"}`
req, _ := http.NewRequest(http.MethodPost, "/signup", strings.NewReader(body))
rec := httptest.NewRecorder()
handle(signupHandler, "", st, server).ServeHTTP(rec, req)
return rec
}
// Victim registers first and gets /users/teamone-x.
if rec := signup("teamone-x"); rec.Code != http.StatusOK {
t.Fatalf("first signup: expected 200, got %d body=%q", rec.Code, rec.Body.String())
}
// Attacker picks a distinct username that normalizes to the same scope.
if rec := signup("teamone/x"); rec.Code != http.StatusConflict {
t.Fatalf("VULNERABLE: colliding signup expected 409, got %d body=%q", rec.Code, rec.Body.String())
}
// The shared scope must still be owned solely by the first user.
owner, err := st.Users.GetByScope("/users/teamone-x")
if err != nil {
t.Fatalf("expected first user to own the scope: %v", err)
}
if owner.Username != "teamone-x" {
t.Fatalf("scope owner = %q, want teamone-x", owner.Username)
}
}
// Regression for GHSA-v3jv-rmh2-635j: under proxy auth with a non-default
// logout page the JWT expiration is waived, because the proxy owns the session
// lifetime. That exception used to apply to every route on the strength of the
// token alone, so a token stolen before it expired kept working — and could be
// renewed — indefinitely. The proxy must still assert the same identity.
func TestExpiredTokenNeedsProxyAssertion(t *testing.T) {
const proxyHeader = "X-Fb-User"
key := []byte("test-signing-key")
perm := users.Permissions{Download: true}
st := scopedUserStorage(t, t.TempDir(), perm, key)
if err := st.Settings.Save(&settings.Settings{
Key: key,
AuthMethod: fbAuth.MethodProxyAuth,
LogoutPage: "/logged-out",
}); err != nil {
t.Fatalf("failed to save settings: %v", err)
}
if err := st.Auth.Save(&fbAuth.ProxyAuth{Header: proxyHeader}); err != nil {
t.Fatalf("failed to save auther: %v", err)
}
expired := &authToken{
User: userInfo{ID: 1, Username: "u", Perm: perm},
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
},
}
expiredToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, expired).SignedString(key)
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
protected := withUser(func(w http.ResponseWriter, _ *http.Request, _ *data) (int, error) {
_, writeErr := w.Write([]byte("protected"))
return 0, writeErr
})
get := func(token, proxyUser string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(http.MethodGet, "/", http.NoBody)
req.Header.Set("X-Auth", token)
if proxyUser != "" {
req.Header.Set(proxyHeader, proxyUser)
}
rec := httptest.NewRecorder()
handle(protected, "", st, &settings.Server{}).ServeHTTP(rec, req)
return rec
}
t.Run("expired token alone is rejected", func(t *testing.T) {
if rec := get(expiredToken, ""); rec.Code != http.StatusUnauthorized {
t.Errorf("VULNERABLE: expired token without the proxy header = %d, body=%q; want 401", rec.Code, rec.Body.String())
}
})
t.Run("expired token for another identity is rejected", func(t *testing.T) {
if rec := get(expiredToken, "someone-else"); rec.Code != http.StatusUnauthorized {
t.Errorf("VULNERABLE: expired token with a foreign proxy identity = %d; want 401", rec.Code)
}
})
t.Run("expired token the proxy still asserts is accepted", func(t *testing.T) {
if rec := get(expiredToken, "u"); rec.Code != http.StatusOK {
t.Errorf("expired token asserted by the proxy = %d, body=%q; want 200", rec.Code, rec.Body.String())
}
})
t.Run("valid token needs no assertion", func(t *testing.T) {
if rec := get(signToken(t, perm, key), ""); rec.Code != http.StatusOK {
t.Errorf("valid token = %d, body=%q; want 200", rec.Code, rec.Body.String())
}
})
}