filebrowser/http/auth.go
Henrique Dias 8ddd3d1db9
fix(auth): isolate auto-provisioned proxy and hook users to their own home
Proxy- and hook-authenticated users were auto-provisioned by applying the
default scope (".") and passing it straight to MakeUserDir, which normalizes
"." to "/". With CreateUserDir enabled, every provisioned user therefore
received the server root as its scope instead of a per-user home directory,
letting one user read, overwrite and delete another user's files.

The signup handler already cleared the scope before deriving the home
directory. Centralize that logic into Settings.CreateUserHome (clear the
scope when CreateUserDir is on and no explicit scope was supplied, derive the
home dir, then reject a scope already owned by another user) and use it from
signup, proxy and hook auth so the three provisioning paths cannot diverge.

Refs GHSA-j7jh-37pf-mf8h, GHSA-j2fc-28fx-hc8q
2026-07-25 08:16:15 +02:00

255 lines
7.1 KiB
Go

package fbhttp
import (
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/golang-jwt/jwt/v5/request"
fbAuth "github.com/filebrowser/filebrowser/v2/auth"
fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/users"
)
const (
DefaultTokenExpirationTime = time.Hour * 2
maxAuthBodySize = 1 << 20 // 1 MiB
)
type userInfo struct {
ID uint `json:"id"`
Locale string `json:"locale"`
ViewMode users.ViewMode `json:"viewMode"`
SingleClick bool `json:"singleClick"`
RedirectAfterCopyMove bool `json:"redirectAfterCopyMove"`
Perm users.Permissions `json:"perm"`
Commands []string `json:"commands"`
LockPassword bool `json:"lockPassword"`
HideDotfiles bool `json:"hideDotfiles"`
DateFormat bool `json:"dateFormat"`
Username string `json:"username"`
AceEditorTheme string `json:"aceEditorTheme"`
}
type authToken struct {
User userInfo `json:"user"`
jwt.RegisteredClaims
}
type extractor []string
func (e extractor) ExtractToken(r *http.Request) (string, error) {
token, _ := request.HeaderExtractor{"X-Auth"}.ExtractToken(r)
// Checks if the token isn't empty and if it contains two dots.
// The former prevents incompatibility with URLs that previously
// used basic auth.
if token != "" && strings.Count(token, ".") == 2 {
return token, nil
}
if r.Method == http.MethodGet {
cookie, _ := r.Cookie("auth")
if cookie != nil && strings.Count(cookie.Value, ".") == 2 {
return cookie.Value, nil
}
}
return "", request.ErrNoTokenInRequest
}
func renewableErr(err error, d *data) bool {
if d.settings.AuthMethod != fbAuth.MethodProxyAuth || err == nil {
return false
}
if d.settings.LogoutPage == settings.DefaultLogoutPage {
return false
}
if !errors.Is(err, jwt.ErrTokenExpired) {
return false
}
return true
}
func withUser(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
keyFunc := func(_ *jwt.Token) (interface{}, error) {
return d.settings.Key, nil
}
var tk authToken
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithExpirationRequired())
token, err := request.ParseFromRequest(r, &extractor{}, keyFunc, request.WithClaims(&tk), request.WithParser(p))
if (err != nil || !token.Valid) && !renewableErr(err, d) {
return http.StatusUnauthorized, nil
}
expiresSoon := tk.ExpiresAt != nil && time.Until(tk.ExpiresAt.Time) < time.Hour
updated := tk.IssuedAt != nil && tk.IssuedAt.Unix() < d.store.Users.LastUpdate(tk.User.ID)
if expiresSoon || updated {
w.Header().Add("X-Renew-Token", "true")
}
d.user, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, tk.User.ID)
if err != nil {
return http.StatusInternalServerError, err
}
return fn(w, r, d)
}
}
func withAdmin(fn handleFunc) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Admin {
return http.StatusForbidden, nil
}
return fn(w, r, d)
})
}
func loginHandler(tokenExpireTime time.Duration) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxAuthBodySize)
}
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
if err != nil {
return http.StatusInternalServerError, err
}
user, err := auther.Auth(r, d.store.Users, d.settings, d.server)
switch {
case errors.Is(err, os.ErrPermission):
return http.StatusForbidden, nil
case err != nil:
return http.StatusInternalServerError, err
}
return printToken(w, r, d, user, tokenExpireTime)
}
}
type signupBody struct {
Username string `json:"username"`
Password string `json:"password"`
}
var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.settings.Signup {
return http.StatusMethodNotAllowed, nil
}
if r.Body == nil {
return http.StatusBadRequest, nil
}
r.Body = http.MaxBytesReader(w, r.Body, maxAuthBodySize)
info := &signupBody{}
err := json.NewDecoder(r.Body).Decode(info)
if err != nil {
return http.StatusBadRequest, err
}
if info.Password == "" || info.Username == "" {
return http.StatusBadRequest, nil
}
user := &users.User{
Username: info.Username,
}
d.settings.Defaults.Apply(user)
// Users signed up via the signup handler should never become admins, even
// if that is the default permission.
user.Perm.Admin = false
// Self-registered users should not inherit execution capabilities from
// default settings, regardless of what the administrator has configured
// as the default. Execution rights must be explicitly granted by an admin.
user.Perm.Execute = false
user.Commands = []string{}
pwd, err := users.ValidateAndHashPwd(info.Password, d.settings.MinimumPasswordLength)
if err != nil {
return http.StatusBadRequest, err
}
user.Password = pwd
switch err := d.settings.CreateUserHome(user, d.store.Users, d.server.Root, false); {
case errors.Is(err, fberrors.ErrExist):
return http.StatusConflict, fberrors.ErrExist
case err != nil:
return http.StatusInternalServerError, err
}
log.Printf("new user: %s, home dir: [%s].", user.Username, user.Scope)
err = d.store.Users.Save(user)
if errors.Is(err, fberrors.ErrExist) {
return http.StatusConflict, err
} else if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
func renewHandler(tokenExpireTime time.Duration) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
w.Header().Set("X-Renew-Token", "false")
return printToken(w, r, d, d.user, tokenExpireTime)
})
}
func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.User, tokenExpirationTime time.Duration) (int, error) {
claims := &authToken{
User: userInfo{
ID: user.ID,
Locale: user.Locale,
ViewMode: user.ViewMode,
SingleClick: user.SingleClick,
RedirectAfterCopyMove: user.RedirectAfterCopyMove,
Perm: user.Perm,
LockPassword: user.LockPassword,
Commands: user.Commands,
HideDotfiles: user.HideDotfiles,
DateFormat: user.DateFormat,
Username: user.Username,
AceEditorTheme: user.AceEditorTheme,
},
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenExpirationTime)),
Issuer: "File Browser",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(d.settings.Key)
if err != nil {
return http.StatusInternalServerError, err
}
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte(signed)); err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}