Compare commits

...
5 Commits
22 changed files with 820 additions and 234 deletions

No files matched your search

+9
View File
@@ -2,6 +2,15 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.3.7
**Notes**:
- creating/deleting password-based user requires reauthentication (#2112)
**BugFixes**:
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
- No Loginfields shown if password authentification is set to false (#2331)
## v1.3.6
**BugFixes**:
@@ -11,7 +11,7 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/global-setup",
timeout: 3000,
timeout: 5000,
testDir: "./tests/playwright/settings",
/* Run tests in files in parallel */
fullyParallel: false,
+18
View File
@@ -21,6 +21,24 @@ func SetFsPermissions(unixFileMode, unixDirMode uint32) {
PermDir = unixModeToFileMode(unixDirMode)
}
// EffectiveDirPerm returns [PermDir] if [SetFsPermissions] was called, otherwise 0o755. On Unix,
// [os.Mkdir] with perm 0 fails with permission denied, so use this (or [PermFile]) for creates when
// the process has not set globals yet.
func EffectiveDirPerm() os.FileMode {
if PermDir == 0 {
return 0o755
}
return PermDir
}
// EffectiveFilePerm returns [PermFile] if set, otherwise 0o644.
func EffectiveFilePerm() os.FileMode {
if PermFile == 0 {
return 0o644
}
return PermFile
}
func unixModeToFileMode(u uint32) os.FileMode {
m := os.FileMode(u & 0777)
if u&0o4000 != 0 {
+1 -1
View File
@@ -24,12 +24,12 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
password := r.Header.Get("X-Password")
totpCode := r.Header.Get("X-Secret")
// URL-decode password to support special characters in headers
password, err := url.QueryUnescape(password)
if err != nil {
return nil, fmt.Errorf("invalid password encoding: %v", err)
}
totpCode := r.Header.Get("X-Secret")
// If ReCaptcha is enabled, check the code.
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
+3 -3
View File
@@ -32,7 +32,7 @@ require (
golang.org/x/time v0.15.0
golang.org/x/tools v0.44.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.49.1
modernc.org/sqlite v1.50.0
)
require (
@@ -50,7 +50,7 @@ require (
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
@@ -281,7 +281,7 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+12 -12
View File
@@ -59,8 +59,8 @@ github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksuf
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -1290,10 +1290,10 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -1302,18 +1302,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+128 -50
View File
@@ -11,7 +11,9 @@ import (
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
@@ -103,34 +105,34 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if err != nil {
return http.StatusForbidden, err
}
fullDest := utils.JoinPathAsUnix(userScopeTo, req.Destination)
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDest, d.user.Username) {
fullDestFile := utils.JoinPathAsUnix(userScopeTo, req.Destination)
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestFile, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
}
// Destination is a file we are about to create; resolve parent dir only (parent must exist or be creatable).
destDir := filepath.Dir(req.Destination)
if destDir == "." {
destDir = "/"
fullDestParent := utils.GetParentDirectoryPath(fullDestFile)
if store.Access != nil && fullDestParent != fullDestFile && !store.Access.Permitted(idxTo.Path, fullDestParent, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination parent of %s", req.Destination)
}
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
}
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
parentReal, _, err := idxTo.GetRealPath(fullDestParent)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %v", err)
}
destRealPath := filepath.Join(destParentReal, filepath.Base(req.Destination))
if err = os.MkdirAll(destParentReal, fileutils.PermDir); err != nil {
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
if err = os.MkdirAll(parentReal, fileutils.EffectiveDirPerm()); err != nil {
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
}
destRel := strings.TrimLeft(filepath.ToSlash(req.Destination), "/")
fileName := path.Base(destRel)
if fileName == "." || fileName == "/" {
return http.StatusBadRequest, fmt.Errorf("invalid destination file name")
}
destFileReal := filepath.Join(parentReal, fileName)
format := strings.ToLower(strings.TrimSpace(req.Format))
if format == "" {
ext := strings.ToLower(filepath.Ext(destRealPath))
if ext == ".gz" && len(destRealPath) > 3 && strings.HasSuffix(strings.ToLower(destRealPath), ".tar.gz") {
destLower := strings.ToLower(req.Destination)
if strings.HasSuffix(destLower, ".tar.gz") {
format = "tar.gz"
} else if ext == ".zip" {
} else if strings.HasSuffix(destLower, ".zip") || filepath.Ext(req.Destination) == ".zip" {
format = "zip"
} else {
format = "zip"
@@ -172,7 +174,7 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
var createErr error
file, err := os.OpenFile(destRealPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
file, err := os.OpenFile(destFileReal, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.EffectiveFilePerm())
if err != nil {
return http.StatusInternalServerError, err
}
@@ -299,22 +301,19 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestPath, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
}
// Destination may not exist yet; resolve parent dir then build destination path.
destDir := filepath.Dir(req.Destination)
if destDir == "." {
destDir = "/"
}
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
}
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
destReal, _, err := idxTo.GetRealPath(fullDestPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
}
destReal := filepath.Join(destParentReal, filepath.Base(req.Destination))
if err = os.MkdirAll(destReal, fileutils.PermDir); err != nil {
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
destInfo, err := os.Stat(destReal)
if err != nil {
if os.IsNotExist(err) {
return http.StatusBadRequest, fmt.Errorf("destination directory does not exist: %s", req.Destination)
}
return http.StatusInternalServerError, fmt.Errorf("destination: %v", err)
}
if !destInfo.IsDir() {
return http.StatusBadRequest, fmt.Errorf("destination must be a directory: %s", req.Destination)
}
info, err := os.Stat(archiveReal)
@@ -649,13 +648,53 @@ type unarchiveRequest struct {
DeleteAfter bool `json:"deleteAfter"`
}
// safeExtractPath ensures name does not escape destDir (no ".." or absolute).
func safeExtractPath(destDir, name string) (string, error) {
clean := filepath.Clean(name)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("invalid entry path: %s", name)
// normalizeArchiveEntryName turns a raw zip/tar name into a safe relative path (slash-separated).
// Many Windows zips use backslashes; some start with a leading backslash, which would make
// filepath.Join drop destDir and write under the drive root. Backslashes are not path separators
// on Unix, so we always normalize '\' -> '/' and trim leading '/' before path.Clean.
func normalizeArchiveEntryName(name string) (string, error) {
s := strings.TrimSpace(name)
if s == "" {
return "", errors.New("empty path")
}
abs := filepath.Join(destDir, clean)
s = strings.ReplaceAll(s, "\\", "/")
if strings.HasPrefix(s, "//") {
return "", errors.New("UNC or invalid path")
}
s = strings.TrimLeft(s, "/")
// Reject e.g. "C:/path" in archive
if len(s) >= 3 && s[1] == ':' && s[2] == '/' {
if 'A' <= s[0] && s[0] <= 'Z' || 'a' <= s[0] && s[0] <= 'z' {
return "", errors.New("absolute path in archive")
}
}
if s == ".." {
return "", errors.New("path traversal")
}
if strings.HasPrefix(s, "../") {
return "", errors.New("path traversal")
}
cleaned := path.Clean(s)
if cleaned == "" || cleaned == "." {
return "", errors.New("empty path after clean")
}
if cleaned == ".." {
return "", errors.New("path traversal after clean")
}
if strings.HasPrefix(cleaned, "../") {
return "", errors.New("path traversal after clean")
}
return cleaned, nil
}
// safeExtractPath ensures name does not escape destDir (no ".." or drive-root / UNC tricks).
func safeExtractPath(destDir, name string) (string, error) {
relSlash, err := normalizeArchiveEntryName(name)
if err != nil {
return "", fmt.Errorf("invalid entry path: %q: %w", name, err)
}
// Convert to platform separators so filepath.Join and os.Mkdir work correctly
abs := filepath.Join(destDir, filepath.FromSlash(relSlash))
destAbs, err := filepath.Abs(destDir)
if err != nil {
return "", err
@@ -664,8 +703,13 @@ func safeExtractPath(destDir, name string) (string, error) {
if err != nil {
return "", err
}
if !strings.HasPrefix(entryAbs, destAbs) {
return "", fmt.Errorf("invalid entry path: %s", name)
// Case-sensitive/prefix issues on Windows: use Rel instead of HasPrefix on absolute paths
rel, err := filepath.Rel(destAbs, entryAbs)
if err != nil {
return "", fmt.Errorf("invalid entry path: %q: %w", name, err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("invalid entry path: %q", name)
}
return abs, nil
}
@@ -730,10 +774,10 @@ func symlinkTargetStaysUnderDest(destRoot, destPath, linkname string) error {
func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) error {
perm := mode.Perm()
if perm == 0 {
perm = fileutils.PermDir
perm = fileutils.EffectiveDirPerm()
}
if err := os.MkdirAll(destPath, perm); err != nil {
return err
return fmt.Errorf("mkdir %q: %w", destPath, err)
}
return applyArchivedTimesAndPerm(destPath, mode, modTime)
}
@@ -741,12 +785,13 @@ func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) er
// extractArchivedRegularFile writes one regular file from src (closed by this function) and reapplies mode and mtime.
func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.Time, src io.ReadCloser) error {
defer func() { _ = src.Close() }()
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
parent := filepath.Dir(destPath)
if err := os.MkdirAll(parent, fileutils.EffectiveDirPerm()); err != nil {
return fmt.Errorf("mkdir %q: %w", parent, err)
}
perm := mode.Perm()
if perm == 0 {
perm = fileutils.PermFile
perm = fileutils.EffectiveFilePerm()
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
@@ -766,8 +811,9 @@ func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.
// extractArchivedSymlink creates a symlink after validating the target stays under destRoot.
func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time) error {
target = strings.TrimSpace(target)
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
symParent := filepath.Dir(destPath)
if err := os.MkdirAll(symParent, fileutils.EffectiveDirPerm()); err != nil {
return fmt.Errorf("mkdir %q: %w", symParent, err)
}
if err := symlinkTargetStaysUnderDest(destRoot, destPath, target); err != nil {
return err
@@ -788,7 +834,39 @@ func extractZip(archivePath, destDir string) error {
}
defer r.Close()
// Central directory order is arbitrary. Some zips (e.g. Windows driver packs) use names that
// are similar length at different tree depths, so byte length is a poor order key. Use the
// number of path segments in the *normalized* entry name: deeper trees first, then
// lexicographic, so parent dirs exist before shorter entries that might be mis-tagged.
type orderedName struct {
f *zip.File
rel string
depth int
}
ordered := make([]orderedName, 0, len(r.File))
for _, f := range r.File {
rel, nerr := normalizeArchiveEntryName(f.Name)
if nerr != nil {
rel = strings.TrimLeft(strings.ReplaceAll(strings.TrimSpace(f.Name), "\\", "/"), "/")
}
depth := 0
if rel != "" {
depth = strings.Count(rel, "/") + 1
}
ordered = append(ordered, orderedName{f: f, rel: rel, depth: depth})
}
sort.Slice(ordered, func(i, j int) bool {
if ordered[i].depth != ordered[j].depth {
return ordered[i].depth > ordered[j].depth
}
if ordered[i].rel != ordered[j].rel {
return ordered[i].rel < ordered[j].rel
}
return ordered[i].f.Name < ordered[j].f.Name
})
for _, o := range ordered {
f := o.f
var destPath string
destPath, err = safeExtractPath(destDir, f.Name)
if err != nil {
@@ -797,8 +875,8 @@ func extractZip(archivePath, destDir string) error {
fi := f.FileInfo()
mode := fi.Mode()
if mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") {
isDirEntry := mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") || strings.HasSuffix(f.Name, "\\")
if isDirEntry {
if err = extractArchivedDir(destPath, mode, f.Modified); err != nil {
return err
}
+289
View File
@@ -0,0 +1,289 @@
package http
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeArchiveEntryName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErr string
}{
{
name: "simple forward slashes",
input: "DOCS/guide.pdf",
want: "DOCS/guide.pdf",
},
{
name: "Windows backslashes become logical path",
input: `DOCS\QUICK\chs.gif`,
want: "DOCS/QUICK/chs.gif",
},
{
name: "Intel-style leading backslash stripped so path stays relative",
input: `\PRO1000\Winx64\NDIS65\e1c65x64.cat`,
want: "PRO1000/Winx64/NDIS65/e1c65x64.cat",
},
{
name: "leading forward slashes stripped",
input: "/PRO1000/Winx64/readme.txt",
want: "PRO1000/Winx64/readme.txt",
},
{
name: "directory entry with trailing slash",
input: `PRO1000\Winx64\`,
want: "PRO1000/Winx64",
},
{
name: "mixed redundant segments cleaned",
input: "a/./b/../c/file",
want: "a/c/file",
},
{
name: "empty",
input: "",
wantErr: "empty path",
},
{
name: "whitespace only",
input: " ",
wantErr: "empty path",
},
{
name: "UNC rejected",
input: `//server/share/file`,
wantErr: "UNC or invalid path",
},
{
name: "double slash prefix after backslash replace (UNC-style)",
input: `\\?\C:\temp\file`,
wantErr: "UNC or invalid path",
},
{
name: "Windows drive absolute rejected",
input: `C:/Windows/system32`,
wantErr: "absolute path in archive",
},
{
name: "lowercase drive rejected",
input: `c:/data/file.txt`,
wantErr: "absolute path in archive",
},
{
name: "bare dot-dot",
input: "..",
wantErr: "path traversal",
},
{
name: "prefixed dot-dot",
input: "../etc/passwd",
wantErr: "path traversal",
},
{
name: "traversal after path clean",
input: "safe/../../outside",
wantErr: "path traversal after clean",
},
{
name: "only dots and slashes",
input: "./",
wantErr: "empty path after clean",
},
{
name: "path cleans to nothing",
input: "x/../.",
wantErr: "empty path after clean",
},
{
name: "spaces preserved in file name",
input: "My Folder/read me.txt",
want: "My Folder/read me.txt",
},
{
name: "deep relative tree",
input: "a/b/c/d/e/f/g.bin",
want: "a/b/c/d/e/f/g.bin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeArchiveEntryName(tt.input)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil (result %q)", tt.wantErr, got)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("normalizeArchiveEntryName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
// TestNormalizeArchiveEntryName_backslashInFileNameOnUnix documents that after normalization
// a single backslash in the raw name is never a path separator: it becomes a slash in the
// logical path (portable), so segments split correctly on every GOOS in safeExtractPath.
func TestNormalizeArchiveEntryName_unifiesBackslashes(t *testing.T) {
t.Parallel()
got, err := normalizeArchiveEntryName(`folder\file.txt`)
if err != nil {
t.Fatal(err)
}
if want := "folder/file.txt"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestSafeExtractPath(t *testing.T) {
t.Parallel()
dest := t.TempDir()
dest, err := filepath.Abs(dest)
if err != nil {
t.Fatal(err)
}
t.Run("file under dest", func(t *testing.T) {
t.Parallel()
got, err := safeExtractPath(dest, `DOCS\Adapter_User_Guide.pdf`)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(dest, "DOCS", "Adapter_User_Guide.pdf")
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("leading backslash in zip name stays under dest", func(t *testing.T) {
t.Parallel()
got, err := safeExtractPath(dest, `\sub\file.txt`)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got, dest+string(filepath.Separator)) {
t.Fatalf("result %q should be under dest %q", got, dest)
}
rel, err := filepath.Rel(dest, got)
if err != nil {
t.Fatal(err)
}
if rel != filepath.Join("sub", "file.txt") {
t.Fatalf("Rel = %q, want sub%cfile.txt", rel, filepath.Separator)
}
})
t.Run("rejects path that escapes dest", func(t *testing.T) {
t.Parallel()
_, err := safeExtractPath(dest, "../../../x")
if err == nil {
t.Fatal("expected error for parent segments in archive name")
}
if !strings.Contains(err.Error(), "invalid entry path") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("rejects when normalize fails", func(t *testing.T) {
t.Parallel()
_, err := safeExtractPath(dest, "..")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "invalid entry path") {
t.Fatalf("got %v", err)
}
})
}
// TestSafeExtractPath_rejectsParentTraversal ensures ".." in the archive name is rejected
// by normalizeArchiveEntryName, not written under dest.
func TestSafeExtractPath_rejectsParentTraversal(t *testing.T) {
t.Parallel()
dest := t.TempDir()
_, err := safeExtractPath(dest, `pkg/../../../secret`)
if err == nil {
t.Fatal("expected error for path traversal in archive name")
}
}
// TestSafeExtractPath_destJoinDoesNotDropBase verifies that a previously problematic pattern
// (root-relative second segment) does not throw away dest after normalization.
func TestSafeExtractPath_destJoinDoesNotDropBase(t *testing.T) {
t.Parallel()
dest := t.TempDir()
out, err := safeExtractPath(dest, `a\b\c.txt`)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(out, dest+string(filepath.Separator)) {
t.Fatalf("expected path under %q, got %q", dest, out)
}
}
// TestExtractZip_nestedBackslashes extracts a Windows-style path in a zip (backslashes) into
// nested directories under dest. This matches many vendor driver zips.
func TestExtractZip_nestedBackslashes(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "test.zip")
out := filepath.Join(tmp, "out")
if err := os.MkdirAll(out, 0o755); err != nil {
t.Fatal(err)
}
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
w, err := zw.Create(`DOCS\QUICK\readme.txt`)
if err != nil {
t.Fatal(err)
}
if _, err = w.Write([]byte("hello")); err != nil {
t.Fatal(err)
}
if err = zw.Close(); err != nil {
t.Fatal(err)
}
if err = f.Close(); err != nil {
t.Fatal(err)
}
if err = extractZip(zipPath, out); err != nil {
t.Fatalf("extractZip: %v", err)
}
b, err := os.ReadFile(filepath.Join(out, "DOCS", "QUICK", "readme.txt"))
if err != nil {
t.Fatal(err)
}
if string(b) != "hello" {
t.Fatalf("content = %q", b)
}
}
// Benchmarks (optional, keep small)
func BenchmarkNormalizeArchiveEntryName(b *testing.B) {
const name = `PRO1000\Winx64\NDIS65\e1c65x64.cat`
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = normalizeArchiveEntryName(name)
}
}
+3 -3
View File
@@ -92,11 +92,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
// ========================================
// Auth Routes - /api/auth/
// ========================================
api.HandleFunc("POST /auth/login", userWithoutOTP(false, loginHandler))
api.HandleFunc("POST /auth/login", loginHelper(loginHandler))
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(true, generateOTPHandler))
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(false, verifyOTPHandler))
api.HandleFunc("POST /auth/otp/generate", withOrWithoutUser(generateOTPHandler))
api.HandleFunc("POST /auth/otp/verify", withOrWithoutUser(verifyOTPHandler))
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
api.HandleFunc("POST /auth/token", withUser(createApiTokenHandler))
api.HandleFunc("DELETE /auth/token", withUser(deleteApiTokenHandler))
+3 -3
View File
@@ -314,7 +314,7 @@ func withoutUserHelper(fn handleFunc) handleFunc {
}
// allow user without OTP to pass
func userWithoutOTPhelper(disableOtp bool, fn handleFunc) handleFunc {
func LoginHelper(disableOtp bool, fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Auth.Methods.ProxyAuth.Enabled {
@@ -659,8 +659,8 @@ func withoutUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withoutUserHelper(fn))
}
func userWithoutOTP(disableOtp bool, fn handleFunc) http.HandlerFunc {
return wrapHandler(userWithoutOTPhelper(disableOtp, fn))
func loginHelper(fn handleFunc) http.HandlerFunc {
return wrapHandler(LoginHelper(false, fn))
}
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
+46 -25
View File
@@ -3,9 +3,10 @@ package http
import (
"fmt"
"net/http"
"net/url"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
)
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
@@ -21,18 +22,15 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/otp/generate [post]
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
username := r.URL.Query().Get("username")
if username == "" {
username = d.user.Username
}
if username != d.user.Username && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not authorized to generate OTP for this user")
}
targetUser, err := store.Users.Get(username)
err := checkPassword(r, d)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
return http.StatusUnauthorized, err
}
url, err := auth.GenerateOtpForUser(targetUser, store.Users)
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
if getErr != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
}
url, err := auth.GenerateOtpForUser(user, store.Users)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
}
@@ -61,21 +59,15 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if code == "" {
return http.StatusUnauthorized, fmt.Errorf("code is required")
}
targetUser := d.user
username := r.URL.Query().Get("username")
// If admin is verifying for another user
if username != "" && username != d.user.Username && d.user.Permissions.Admin {
var err error
targetUser, err = store.Users.Get(username)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
}
logger.Debugf("Admin %s verifying OTP for user: %s", d.user.Username, targetUser.Username)
err := checkPassword(r, d)
if err != nil {
return http.StatusUnauthorized, err
}
err := auth.VerifyTotpCode(targetUser, code, store.Users)
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
if getErr != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
}
err = auth.VerifyTotpCode(user, code, store.Users)
if err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
}
@@ -86,3 +78,32 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// On success, return a simple confirmation.
return renderJSON(w, r, response)
}
func checkPassword(r *http.Request, d *requestContext) error {
providedPassword := r.Header.Get("X-Password")
username := r.URL.Query().Get("username")
if d.user.Permissions.Admin {
username = d.user.Username
}
// url decode
providedPassword, err := url.QueryUnescape(providedPassword)
if err != nil {
return fmt.Errorf("invalid password encoding: %v", err)
}
if providedPassword == "" {
return fmt.Errorf("password is required")
}
user, getErr := store.Users.Get(username)
var passwordHash string
if getErr != nil {
passwordHash = utils.InvalidPasswordHash
} else {
passwordHash = user.Password
}
// always run checkPwd to prevent timing attacks
err = utils.CheckPwd(providedPassword, passwordHash)
if err != nil {
return fmt.Errorf("invalid password or user not found")
}
return nil
}
+19 -5
View File
@@ -180,12 +180,14 @@ func normalizeLocale(locale string) string {
// userDeleteHandler deletes a user by ID.
// @Summary Delete a user by ID
// @Description Deletes a user identified by their ID.
// @Description Deletes a user identified by their ID. When the authenticated actor uses password login, they must send their current password in the X-Password header.
// @Tags Users
// @Accept json
// @Produce json
// @Param id query string true "User ID"
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors"
// @Success 200 "User deleted successfully"
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users [delete]
@@ -202,8 +204,13 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
return http.StatusForbidden, fmt.Errorf("cannot delete users without admin permissions")
}
status, err := verifyActorPasswordForUserActions(r, d)
if err != nil {
return status, err
}
// Delete the user
err := store.Users.Delete(givenUserId)
err = store.Users.Delete(givenUserId)
if err != nil {
return errToStatus(err), err
}
@@ -212,13 +219,15 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
// usersPostHandler creates a new user.
// @Summary Create a new user
// @Description Adds a new user to the system.
// @Description Adds a new user to the system. When the authenticated actor uses password login, they must send their current password in the X-Password header.
// @Tags Users
// @Accept json
// @Produce json
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors"
// @Param data body users.User true "User data to create a new user"
// @Success 201 {object} users.User "Created user"
// @Failure 400 {object} map[string]string "Bad Request"
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users [post]
func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
@@ -249,6 +258,11 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, errors.ErrEmptyPassword
}
status, err := verifyActorPasswordForUserActions(r, d)
if err != nil {
return status, err
}
err = storage.CreateUser(req.User, req.User.Permissions)
if err != nil {
return http.StatusInternalServerError, err
@@ -292,7 +306,7 @@ func userPutOnlyNonAdminEditableFields(which []string) bool {
// verifyActorPasswordForUserPut requires URL-encoded X-Password when the authenticated actor uses
// password login. Callers should invoke this only when the update requires re-authentication.
func verifyActorPasswordForUserPut(r *http.Request, d *requestContext) (int, error) {
func verifyActorPasswordForUserActions(r *http.Request, d *requestContext) (int, error) {
if d.user.LoginMethod != users.LoginMethodPassword {
return 0, nil
}
@@ -382,7 +396,7 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if d.user.LoginMethod == users.LoginMethodPassword && !userPutOnlyNonAdminEditableFields(req.Which) {
var status int
status, err = verifyActorPasswordForUserPut(r, d)
status, err = verifyActorPasswordForUserActions(r, d)
if err != nil {
return status, err
}
+32 -2
View File
@@ -3156,7 +3156,7 @@ const docTemplate = `{
}
},
"post": {
"description": "Adds a new user to the system.",
"description": "Adds a new user to the system. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
"consumes": [
"application/json"
],
@@ -3168,6 +3168,12 @@ const docTemplate = `{
],
"summary": "Create a new user",
"parameters": [
{
"type": "string",
"description": "Actor's current password (URL-encoded); required for password-login actors",
"name": "X-Password",
"in": "header"
},
{
"description": "User data to create a new user",
"name": "data",
@@ -3194,6 +3200,15 @@ const docTemplate = `{
}
}
},
"401": {
"description": "Unauthorized - invalid or missing actor password when required",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
@@ -3206,7 +3221,7 @@ const docTemplate = `{
}
},
"delete": {
"description": "Deletes a user identified by their ID.",
"description": "Deletes a user identified by their ID. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
"consumes": [
"application/json"
],
@@ -3224,12 +3239,27 @@ const docTemplate = `{
"name": "id",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Actor's current password (URL-encoded); required for password-login actors",
"name": "X-Password",
"in": "header"
}
],
"responses": {
"200": {
"description": "User deleted successfully"
},
"401": {
"description": "Unauthorized - invalid or missing actor password when required",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
+32 -2
View File
@@ -3145,7 +3145,7 @@
}
},
"post": {
"description": "Adds a new user to the system.",
"description": "Adds a new user to the system. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
"consumes": [
"application/json"
],
@@ -3157,6 +3157,12 @@
],
"summary": "Create a new user",
"parameters": [
{
"type": "string",
"description": "Actor's current password (URL-encoded); required for password-login actors",
"name": "X-Password",
"in": "header"
},
{
"description": "User data to create a new user",
"name": "data",
@@ -3183,6 +3189,15 @@
}
}
},
"401": {
"description": "Unauthorized - invalid or missing actor password when required",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
@@ -3195,7 +3210,7 @@
}
},
"delete": {
"description": "Deletes a user identified by their ID.",
"description": "Deletes a user identified by their ID. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
"consumes": [
"application/json"
],
@@ -3213,12 +3228,27 @@
"name": "id",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Actor's current password (URL-encoded); required for password-login actors",
"name": "X-Password",
"in": "header"
}
],
"responses": {
"200": {
"description": "User deleted successfully"
},
"401": {
"description": "Unauthorized - invalid or missing actor password when required",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
+27 -2
View File
@@ -4136,18 +4136,31 @@ paths:
delete:
consumes:
- application/json
description: Deletes a user identified by their ID.
description: Deletes a user identified by their ID. When the authenticated actor
uses password login, they must send their current password in the X-Password
header.
parameters:
- description: User ID
in: query
name: id
required: true
type: string
- description: Actor's current password (URL-encoded); required for password-login
actors
in: header
name: X-Password
type: string
produces:
- application/json
responses:
"200":
description: User deleted successfully
"401":
description: Unauthorized - invalid or missing actor password when required
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
@@ -4204,8 +4217,14 @@ paths:
post:
consumes:
- application/json
description: Adds a new user to the system.
description: Adds a new user to the system. When the authenticated actor uses
password login, they must send their current password in the X-Password header.
parameters:
- description: Actor's current password (URL-encoded); required for password-login
actors
in: header
name: X-Password
type: string
- description: User data to create a new user
in: body
name: data
@@ -4225,6 +4244,12 @@ paths:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized - invalid or missing actor password when required
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
+69 -91
View File
@@ -35,7 +35,7 @@
"@types/node": "^24.1.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
"deepl-node": "^1.18.0",
"deepl-node": "^1.16.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.24.0",
@@ -1959,53 +1959,53 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
"integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/shared": "3.5.32",
"@vue/shared": "3.5.33",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz",
"integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.32",
"@vue/shared": "3.5.32"
"@vue/compiler-core": "3.5.33",
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
"integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.32",
"@vue/compiler-dom": "3.5.32",
"@vue/compiler-ssr": "3.5.32",
"@vue/shared": "3.5.32",
"@vue/compiler-core": "3.5.33",
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.8",
"postcss": "^8.5.10",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz",
"integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.32",
"@vue/shared": "3.5.32"
"@vue/compiler-dom": "3.5.33",
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/compiler-vue2": {
@@ -2076,53 +2076,53 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz",
"integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.32"
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz",
"integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.32",
"@vue/shared": "3.5.32"
"@vue/reactivity": "3.5.33",
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz",
"integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.32",
"@vue/runtime-core": "3.5.32",
"@vue/shared": "3.5.32",
"@vue/reactivity": "3.5.33",
"@vue/runtime-core": "3.5.33",
"@vue/shared": "3.5.33",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz",
"integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.32",
"@vue/shared": "3.5.32"
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33"
},
"peerDependencies": {
"vue": "3.5.32"
"vue": "3.5.33"
}
},
"node_modules/@vue/shared": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz",
"integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
@@ -2163,16 +2163,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adm-zip": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -2184,9 +2174,9 @@
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2267,9 +2257,9 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz",
"integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==",
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
@@ -2647,18 +2637,16 @@
"license": "MIT"
},
"node_modules/deepl-node": {
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.26.0.tgz",
"integrity": "sha512-KaMZ248O/N2HrevQnsJoCoHxDUNBbsQ7+OZJa2YAOhym4qtVCNTzRst+4rLX9B9T84SVN4xVQSuJyFIdlI/x1w==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.16.0.tgz",
"integrity": "sha512-sYrh8UngVHKBrgffLPl+ng065mlC+Ep3LBdSN3xqDBd4KdBZzgvdxHiHd9Pm9xQCI5pwyuCHyq0cDUW1T3vQUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=12.0",
"adm-zip": "^0.5.16",
"axios": "^1.7.4",
"form-data": "^3.0.4",
"loglevel": ">=1.6.2",
"uuid": "^8.3.2"
"form-data": "^3.0.0",
"loglevel": ">=1.6.2"
},
"engines": {
"node": ">=12.0"
@@ -2723,9 +2711,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
"integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -5746,16 +5734,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
@@ -6069,16 +6047,16 @@
"license": "MIT"
},
"node_modules/vue": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.32",
"@vue/compiler-sfc": "3.5.32",
"@vue/runtime-dom": "3.5.32",
"@vue/server-renderer": "3.5.32",
"@vue/shared": "3.5.32"
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-sfc": "3.5.33",
"@vue/runtime-dom": "3.5.33",
"@vue/server-renderer": "3.5.33",
"@vue/shared": "3.5.33"
},
"peerDependencies": {
"typescript": "*"
+1 -1
View File
@@ -53,7 +53,7 @@
"@types/node": "^24.1.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
"deepl-node": "^1.18.0",
"deepl-node": "^1.16.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.24.0",
+101 -18
View File
@@ -28,25 +28,66 @@ export async function get(id) {
}
// POST /api/users (create user)
export async function create(user) {
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
export async function create(user, options = {}) {
const mergedHeaders = { ...(options.headers || {}) }
const apiPath = getApiPath('users')
const body = JSON.stringify({
which: [],
data: user
})
const needsActorPasswordRetry = (err) =>
state.user?.loginMethod === 'password' &&
options.skipActorPasswordConfirm !== true &&
mergedHeaders['X-Password'] === undefined &&
err &&
err.status === 401 &&
typeof err.message === 'string' &&
err.message.includes('X-Password')
try {
const apiPath = getApiPath('users')
const res = await fetchURL(apiPath, {
method: 'POST',
body: JSON.stringify({
which: [],
data: user
})
body,
headers: mergedHeaders,
})
if (res.status === 201) {
return res.headers.get('Location')
} else {
throw new Error('Failed to create user')
}
} catch (err) {
notify.showError(err.message || 'Error creating user')
throw err
throw new Error('Failed to create user')
} catch (e) {
if (!needsActorPasswordRetry(e)) {
throw e
}
const promptKey =
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
return new Promise((resolve, reject) => {
mutations.showPrompt({
name: 'password',
props: {
infoText: i18n.global.t(promptKey),
submitLabel: i18n.global.t('general.confirm'),
submitCallback: async (actorPassword) => {
try {
const location = await create(user, {
...options,
headers: {
...mergedHeaders,
'X-Password': encodeURIComponent(actorPassword),
},
skipActorPasswordConfirm: true,
})
resolve(location)
} catch (err) {
reject(err)
}
},
},
})
})
}
}
@@ -128,15 +169,57 @@ export async function update(user, which = ['all'], options = {}) {
}
// DELETE /api/users (remove user)
export async function remove(id) {
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
export async function remove(id, options = {}) {
const mergedHeaders = { ...(options.headers || {}) }
const apiPath = getApiPath('users', { id: id })
const needsActorPasswordRetry = (err) =>
state.user?.loginMethod === 'password' &&
options.skipActorPasswordConfirm !== true &&
mergedHeaders['X-Password'] === undefined &&
err &&
err.status === 401 &&
typeof err.message === 'string' &&
err.message.includes('X-Password')
try {
const apiPath = getApiPath('users', { id: id })
await fetchURL(apiPath, {
method: 'DELETE'
method: 'DELETE',
headers: mergedHeaders,
})
} catch (e) {
if (!needsActorPasswordRetry(e)) {
throw e
}
const promptKey =
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
return new Promise((resolve, reject) => {
mutations.showPrompt({
name: 'password',
props: {
infoText: i18n.global.t(promptKey),
submitLabel: i18n.global.t('general.confirm'),
submitCallback: async (actorPassword) => {
try {
await remove(id, {
...options,
headers: {
...mergedHeaders,
'X-Password': encodeURIComponent(actorPassword),
},
skipActorPasswordConfirm: true,
})
resolve(undefined)
} catch (err) {
reject(err)
}
},
},
})
})
} catch (err) {
notify.showError(err.message || `Failed to delete user with ID: ${id}`)
throw err
}
}
+9 -3
View File
@@ -374,7 +374,6 @@ export default {
deletePrompt() {
mutations.showPrompt({
name: "generic",
pinned: true,
props: {
title: this.$t("general.delete"),
body: this.$t("prompts.deleteUserMessage", { username: this.user.username }),
@@ -383,7 +382,9 @@ export default {
label: this.$t("general.delete"),
action: async () => {
try {
await usersApi.remove(this.user.id);
await usersApi.remove(this.user.id, {
actorPasswordPromptI18nKey: "prompts.confirmPasswordToSaveUser",
});
notify.showSuccessToast(this.$t("settings.userDeleted"));
eventBus.emit('usersChanged');
mutations.closeTopPrompt(); // close delete user prompt confirmation
@@ -414,7 +415,12 @@ export default {
notify.showError(this.$t("settings.userNotAdmin"));
return;
}
await usersApi.create({ ...this.user, scopes: scopesToSend });
await usersApi.create(
{ ...this.user, scopes: scopesToSend },
{
actorPasswordPromptI18nKey: "prompts.confirmPasswordToSaveUser",
}
);
// Emit event to refresh user list
eventBus.emit('usersChanged');
// Close the prompt
+2 -4
View File
@@ -92,8 +92,7 @@ export function getApiPath(path, params = {}, skipEncode = false, isPublic = fal
const encodedParams = [];
for (const key of paramKeys) {
const value = params[key];
if (value === undefined) continue;
if (value === undefined || value === null || value === "") continue;
// Handle array values for repeated parameters
if (Array.isArray(value)) {
value.forEach(v => {
@@ -110,8 +109,7 @@ export function getApiPath(path, params = {}, skipEncode = false, isPublic = fal
const queryParams = [];
for (const key in params) {
const value = params[key];
if (value === undefined) continue;
if (value === undefined || value === null || value === "") continue;
// Handle array values for repeated parameters
if (Array.isArray(value)) {
value.forEach(v => {
+2 -1
View File
@@ -32,7 +32,7 @@
<LoadingSpinner size="medium" />
</div>
<div v-else class="loginOptions no-padding" key="loginForm">
<div v-if="passwordAvailable" class="password-entry">
<div v-if="passwordAvailable || ldapAvailable" class="password-entry">
<div v-if="error !== ''" class="wrong-login card">
<span>{{ $t("login.failedLogin") }}</span>
<i class="no-select material-symbols-outlined tooltip-info-icon" @mouseenter="showTooltip($event, error)"
@@ -216,6 +216,7 @@ export default {
signup: () => globalVars.signup,
oidcAvailable: () => globalVars.oidcAvailable,
passwordAvailable: () => globalVars.passwordAvailable,
ldapAvailable: () => globalVars.ldapAvailable,
name: () => globalVars.name || "FileBrowser Quantum",
loginIconUrl: () => globalVars.loginIcon,
isDarkMode() {
@@ -2,12 +2,14 @@ import { test, expect } from "../test-setup";
import { Page } from "@playwright/test";
/**
* User PUT for admin-only fields returns 401 until X-Password is supplied; the UI opens password-prompt on top.
* User POST/PUT/DELETE for sensitive actions return 401 until X-Password is supplied; the UI opens password-prompt on top.
* Password must match tests/playwright/global-setup.ts (same as two factor auth check).
*/
async function confirmActorPasswordPrompt(page: Page) {
const passwordModal = page.locator('div[aria-label="password-prompt"]');
await expect(passwordModal).toBeVisible({ timeout: 3000 });
const passwordModal = page.locator(
'div[aria-label="password-prompt"]:not(.prompt-behind)'
);
await expect(passwordModal).toBeVisible();
await passwordModal.locator("input").fill("admin");
await passwordModal.locator('button[aria-label="Confirm"]').click();
await expect(passwordModal).not.toBeVisible();
@@ -32,7 +34,7 @@ test("create, check settings, and delete user (retry-safe name)", async ({
)
await page.locator('input[aria-label="Password2"]').fill('testpassword')
// Just create the user first (listen before click to avoid missing a fast 201).
// usersApi.create tries POST without X-Password first; server returns 401, then password-prompt + retry (201).
const createResponse = page.waitForResponse(
(resp) =>
resp.url().includes('/api/users') &&
@@ -40,6 +42,7 @@ test("create, check settings, and delete user (retry-safe name)", async ({
resp.status() === 201
);
await page.locator('button[aria-label="Save"]').click();
await confirmActorPasswordPrompt(page);
await createResponse;
// We should be back on the settings page
@@ -86,11 +89,12 @@ test("create, check settings, and delete user (retry-safe name)", async ({
const genericModal = page.locator('div[aria-label="generic-prompt"]');
await expect(genericModal).toBeVisible();
await genericModal.locator('button[aria-label="Delete"]').click();
await confirmActorPasswordPrompt(page);
// After deletion, we should be back on the settings page.
await expect(page.locator('tr.item', { hasText: username })).not.toBeVisible();
// usersApi.update tries PUT without X-Password first; server returns 401, then the UI retries with password (204).
checkForErrors(0, 1);
// usersApi.create/update/remove try without X-Password first; server returns 401, then the UI retries (201/204/200).
checkForErrors(0, 3);
})
test("two factor auth check", async ({ page, checkForErrors }) => {
@@ -116,7 +120,9 @@ test("two factor auth check", async ({ page, checkForErrors }) => {
await expect(twoFactorCheckbox).toBeChecked();
await modal.locator('button[aria-label="Generate Code"]').click();
const passwordModal = page.locator('div[aria-label="password-prompt"]');
const passwordModal = page.locator(
'div[aria-label="password-prompt"]:not(.prompt-behind)'
);
// Must match the admin password used in tests/playwright/global-setup.ts (not testuser passwords).
await passwordModal.locator('input').fill('admin');
await passwordModal.locator('button[aria-label="Confirm"]').click();