Compare commits

...
41 changed files with 1870 additions and 1775 deletions

No files matched your search

+6 -1
View File
@@ -9,12 +9,17 @@ All notable changes to this project will be documented in this file. For commit
- Square image previews: avoid the full-resolution crop copy in imaging.Fill (#3340)
**Bug Fixes**:
- fix(http): Unix socket hardening — default `http.socketFileMode` is `0660`; the socket is created under a restrictive umask and then `chmod`/`chown` apply your configured mode; on filesystem sockets, forwarded headers require `http.unixSocketTrustedUIDs` and/or `http.unixSocketTrustedGIDs`, with `http.unixSocketTrustAllLocalPeers: true` as an explicit opt-in for pre-#3309 trust-all behavior (startup warning) (#3309)
- fix(users): save the prompt close button and new file templates when editing a user (#3342) (#3323)
- fix(upload): serialize directory creation before uploading files (#3321)
- fix(preview): open RAW, HEIC and rendered documents in the viewer when previews are disabled (#3313)
- fix(users): keep requested profile values when creating a user
- fix advanced user toggle duplicate (#3353) (#3324)
**Upgrade notes** (Unix socket listeners):
- If you listen on a filesystem `http.socket` with `http.trustProxyHeaders: true`, set `http.unixSocketTrustedUIDs` and/or `http.unixSocketTrustedGIDs` to the UID/GID(s) from Linux peer credentials (`SO_PEERCRED`) that may set `X-Forwarded-*`. A GID allowlist trusts every peer with that GID (for example when the proxy and FileBrowser share a group), not only one process. If you relied on empty allowlists before, set `http.unixSocketTrustAllLocalPeers: true` (a warning is logged).
- Default `http.socketFileMode` is `0660`; use optional `http.socketGroup` when the proxy runs as another user in the same group.
## v2.1.4
**Security**:
@@ -59,7 +64,7 @@ All notable changes to this project will be documented in this file. For commit
- [Moderate] Authenticated `GET /api/resources/view` no longer accepts view tokens minted for a public share when the caller lacks view permission on the requested source (GHSA-wxj5-g3qp-g732) reporter @BlueN0r
- [Low] SSE source-update and user-targeted delivery hold the client registry lock through nonblocking sends, preventing concurrent map mutation and send-on-closed-channel panics when Realtime clients connect, disconnect, or shut down (GHSA-p9wm-f5jj-75pj) reporter @tao0845
- [High] Incomplete symlink-containment remediation: an authenticated non-admin user could create a relative symlink via archive extraction, relocate it with WebDAV MOVE, and write through it to arbitrary host paths with the service account's privileges (GHSA-3j46-5w28-f8p2) reporter @Saku0512
- [Moderate] Source-root containment bypass: create, write, delete, and move operations now resolve symlinks uncached and enforce the caller's scope, closing escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints (GHSA-j764-qg3m-rhv7) reporter @chaoming0625
- [Moderate] Source-root containment bypass: create, write, delete, and move operations now resolve symlinks uncached and enforce the caller's scope, closing API escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints; RealPathCache entries for affected index paths are invalidated after mutations (GHSA-j764-qg3m-rhv7) reporter @chaoming0625
- [Moderate] Preview reads resolve symlinks within the caller's scope so authenticated preview cannot follow an out-of-scope symlink target (GHSA-g2q5-r6ch-v25f) reporter @tao0845
- [Moderate] Archive downloads and extraction no longer read or write through symlinks that resolve outside the caller's share or user scope; escaping archive-walk entries are skipped (GHSA-qqf3-2wvq-q4w9) reporter @SmokinCaterpillar
- [Moderate] Share directory-archive downloads now apply the share owner's access rules to every entry, closing a bypass where anonymous share visitors could download admin-denied content (GHSA-9fcr-gm58-p839) reporter @Tan-JunWei
+16 -5
View File
@@ -655,6 +655,7 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
if utils.CleanIndexPathSegment(indexPath) == "" {
return fmt.Errorf("refusing to delete source root directory: %s", indexPath)
}
deleteIndexPath := indexPath
realPath, err := index.GetRealPathForRemoval(boundIndexPath, indexPath)
if err != nil {
return err
@@ -673,9 +674,10 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
return err
}
// Clear cache entries
indexing.RealPathCache.Delete(realPath)
indexing.IsDirCache.Delete(realPath + ":isdir")
index.InvalidateRealPathCache(deleteIndexPath, boundIndexPath)
if parent := utils.GetParentDirectoryPath(deleteIndexPath); parent != "" && parent != "/" {
index.InvalidateRealPathCache(parent, boundIndexPath)
}
// Remove metadata from index
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
@@ -727,8 +729,7 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
if !Exists(realPath) {
// Directory no longer exists, remove it from the index
// This clears both Directories and DirectoriesLedger maps
indexing.RealPathCache.Delete(realPath)
indexing.IsDirCache.Delete(realPath + ":isdir")
idx.InvalidateRealPathCache(path)
idx.DeleteMetadata(path, true, false)
return nil
}
@@ -966,6 +967,11 @@ func WriteDirectory(boundIndexPath string, opts utils.FileOptions) error {
}
}
idx.InvalidateRealPathCache(opts.Path, boundIndexPath)
if parent := utils.GetParentDirectoryPath(opts.Path); parent != "" && parent != "/" {
idx.InvalidateRealPathCache(parent, boundIndexPath)
}
return nil
}
@@ -1041,6 +1047,11 @@ func writeFile(source, boundIndexPath, path string, in io.Reader) error {
}
}
idx.InvalidateRealPathCache(path, boundIndexPath)
if parent := utils.GetParentDirectoryPath(path); parent != "" && parent != "/" {
idx.InvalidateRealPathCache(parent, boundIndexPath)
}
return nil
}
+22 -1
View File
@@ -446,8 +446,29 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
return nil, wrappedReader, exif.ErrNoThumbnail
}
// Read IFD1's Orientation first; fall back to IFD0's Orientation if not specified (per MWG guidelines)
orientStr := ""
tags1, err1 := ifd.FindTagWithName("Orientation")
if err1 == nil && len(tags1) > 0 {
orientStr, _ = tags1[0].FormatFirst()
}
if orientStr == "" {
tags0, err0 := index.RootIfd.FindTagWithName("Orientation")
if err0 == nil && len(tags0) > 0 {
orientStr, _ = tags0[0].FormatFirst()
}
}
thm, err := ifd.Thumbnail()
return thm, wrappedReader, err
if err != nil {
return nil, wrappedReader, err
}
if orientStr != "" && orientStr != "1" {
thm = applyOrientationToPreviewBytes(thm, orientStr)
}
return thm, wrappedReader, nil
}
// CreateThumbnail decodes an image and creates a fixed-size thumbnail.
+38
View File
@@ -549,3 +549,41 @@ func TestReadImageFileDimensions(t *testing.T) {
require.Greater(t, width, 0)
require.Greater(t, height, 0)
}
func TestApplyOrientationToPreviewBytes(t *testing.T) {
t.Parallel()
// Generate a 100x60 test JPEG image in memory (landscape)
img := image.NewRGBA(image.Rect(0, 0, 100, 60))
var buf bytes.Buffer
err := jpeg.Encode(&buf, img, nil)
require.NoError(t, err)
rawBytes := buf.Bytes()
tests := []struct {
orientation string
wantWidth int
wantHeight int
}{
{orientation: "1", wantWidth: 100, wantHeight: 60},
{orientation: "Horizontal (normal)", wantWidth: 100, wantHeight: 60},
{orientation: "8", wantWidth: 60, wantHeight: 100},
{orientation: "Rotate 270 CW", wantWidth: 60, wantHeight: 100},
{orientation: "6", wantWidth: 60, wantHeight: 100},
{orientation: "Rotate 90 CW", wantWidth: 60, wantHeight: 100},
{orientation: "3", wantWidth: 100, wantHeight: 60},
{orientation: "Rotate 180", wantWidth: 100, wantHeight: 60},
}
for _, tt := range tests {
t.Run(tt.orientation, func(t *testing.T) {
rotated := applyOrientationToPreviewBytes(rawBytes, tt.orientation)
require.NotEmpty(t, rotated)
cfg, _, decodeErr := image.DecodeConfig(bytes.NewReader(rotated))
require.NoError(t, decodeErr)
require.Equal(t, tt.wantWidth, cfg.Width)
require.Equal(t, tt.wantHeight, cfg.Height)
})
}
}
+1
View File
@@ -22,6 +22,7 @@ import (
func setupAccessHTTPTest(t *testing.T) {
t.Helper()
lockWebGlobals(t)
dbPath := filepath.Join(t.TempDir(), "access-http-test.sqlite")
if _, err := state.Initialize(dbPath); err != nil {
t.Fatal(err)
@@ -23,6 +23,7 @@ import (
func setupExtractAccessPolicyTest(t *testing.T) (destDir, sourceRoot, sourceName, username string) {
t.Helper()
lockWebGlobals(t)
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "access.sqlite")
if _, err := state.Initialize(dbPath); err != nil {
@@ -25,6 +25,7 @@ import (
func setupShareArchiveDownloadTest(t *testing.T) (sourceRoot string, sourceName string, owner *users.User) {
t.Helper()
lockWebGlobals(t)
dbPath := filepath.Join(t.TempDir(), "share-dl-test.sqlite")
if _, err := state.Initialize(dbPath); err != nil {
t.Fatal(err)
+1
View File
@@ -22,6 +22,7 @@ import (
)
func setupTestEnv(t *testing.T) {
lockWebGlobals(t)
utils.BcryptCost = bcrypt.MinCost
dbPath := filepath.Join(t.TempDir(), "test.sqlite")
+30
View File
@@ -9,6 +9,7 @@ import (
)
func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
lockWebGlobals(t)
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
@@ -27,6 +28,7 @@ func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
// proxy hops so multi-hop deployments (CDN -> proxy -> filebrowser) resolve the
// real client IP instead of collapsing everyone onto the shared edge address.
func TestGetRemoteIPTrustedProxies(t *testing.T) {
lockWebGlobals(t)
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
@@ -59,6 +61,7 @@ func TestGetRemoteIPTrustedProxies(t *testing.T) {
// Bare IPs (not just CIDRs) are accepted in http.trustedProxies.
func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
lockWebGlobals(t)
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
@@ -74,6 +77,7 @@ func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
// Without trustProxyHeaders the XFF chain is ignored entirely.
func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
lockWebGlobals(t)
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = false
@@ -90,6 +94,7 @@ func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
// A direct client that is not a trusted proxy cannot spoof its address with
// forwarded headers, even when trustProxyHeaders is enabled.
func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
lockWebGlobals(t)
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
@@ -104,8 +109,26 @@ func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
}
}
func TestGetRemoteIPUnixSocketHonorsForwardedWithTrustAllLocalPeers(t *testing.T) {
lockWebGlobals(t)
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
settings.Config.Http.Socket = "/var/run/filebrowser.sock"
settings.Config.Http.UnixSocketTrustAllLocalPeers = true
settings.Config.Http.TrustedProxies = nil
req := httptest.NewRequest("GET", "http://example.com/", nil)
req.RemoteAddr = "@"
req.Header.Set("X-Forwarded-For", "203.0.113.50")
if got := GetRemoteIP(req); got != "203.0.113.50" {
t.Fatalf("GetRemoteIP = %q, want 203.0.113.50", got)
}
}
// With no trusted proxies configured, forwarded headers are never honored.
func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
lockWebGlobals(t)
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
@@ -121,6 +144,7 @@ func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
// An IPv4-mapped IPv6 trusted hop in the XFF chain must still be skipped.
func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
lockWebGlobals(t)
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
@@ -135,6 +159,7 @@ func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
}
func TestGetScheme(t *testing.T) {
lockWebGlobals(t)
t.Run("trusted proto", func(t *testing.T) {
settings.Config.Http.TrustProxyHeaders = true
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
@@ -158,6 +183,7 @@ func TestGetScheme(t *testing.T) {
}
func TestRequestSchemeTLSFallback(t *testing.T) {
lockWebGlobals(t)
settings.Config.Http.TrustProxyHeaders = false
req := httptest.NewRequest("GET", "https://example.com/", nil)
@@ -168,6 +194,7 @@ func TestRequestSchemeTLSFallback(t *testing.T) {
}
func TestRequestSchemeIgnoresInvalidProto(t *testing.T) {
lockWebGlobals(t)
settings.Config.Http.TrustProxyHeaders = true
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
@@ -179,6 +206,7 @@ func TestRequestSchemeIgnoresInvalidProto(t *testing.T) {
}
func TestRequestHostTrustProxyHeaders(t *testing.T) {
lockWebGlobals(t)
settings.Config.Http.TrustProxyHeaders = true
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
@@ -192,6 +220,7 @@ func TestRequestHostTrustProxyHeaders(t *testing.T) {
}
func TestRequestSchemeForPublicURL(t *testing.T) {
lockWebGlobals(t)
orig := settings.Config.Http.TrustProxyHeaders
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = orig })
@@ -225,6 +254,7 @@ func TestRequestSchemeForPublicURL(t *testing.T) {
}
func TestRequestPageFullURL(t *testing.T) {
lockWebGlobals(t)
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.ExternalUrl = ""
+1
View File
@@ -237,6 +237,7 @@ func TestResourcePutHandler_SuccessResponseOmitsRealPath(t *testing.T) {
func setupPutHTTPTest(t *testing.T) string {
t.Helper()
lockWebGlobals(t)
downloadsPath := filepath.Join(t.TempDir(), "downloads")
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
+1
View File
@@ -19,6 +19,7 @@ import (
func setupQuotaHTTPTest(t *testing.T) {
t.Helper()
lockWebGlobals(t)
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
if _, err := state.Initialize(dbPath); err != nil {
t.Fatal(err)
@@ -17,6 +17,7 @@ import (
func setupPutQuotaTest(t *testing.T) string {
t.Helper()
lockWebGlobals(t)
downloadsPath := filepath.Join(t.TempDir(), "downloads")
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
@@ -0,0 +1,82 @@
package web
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
func TestResourcePostHandler_RejectsEscapingParentSymlinkMkdir(t *testing.T) {
downloadsPath := setupPutHTTPTest(t)
outside := filepath.Join(filepath.Dir(downloadsPath), "outside-mkdir")
if err := os.MkdirAll(outside, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(downloadsPath, "evil")); err != nil {
t.Fatal(err)
}
adminUser, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/resources?source=Downloads&path=/evil/newdir&isDir=true", nil)
rec := httptest.NewRecorder()
status, handlerErr := ResourcePostHandler(rec, req, &requestContext{User: &adminUser})
if status != http.StatusForbidden {
t.Fatalf("status=%d err=%v", status, handlerErr)
}
if handlerErr == nil {
t.Fatal("expected mkdir to be forbidden")
}
if _, err := os.Stat(filepath.Join(outside, "newdir")); !os.IsNotExist(err) {
t.Fatal("mkdir escaped outside the source via symlink parent")
}
}
func TestResourceDeleteHandler_RejectsEscapingIntermediate(t *testing.T) {
downloadsPath := setupPutHTTPTest(t)
if preview.GetService() == nil {
if err := preview.StartPreviewGenerator(1, t.TempDir()); err != nil {
t.Fatal(err)
}
}
outsideDir := filepath.Join(filepath.Dir(downloadsPath), "outside-delete")
if err := os.MkdirAll(outsideDir, 0755); err != nil {
t.Fatal(err)
}
victim := filepath.Join(outsideDir, "victim.txt")
if err := os.WriteFile(victim, []byte("keep"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideDir, filepath.Join(downloadsPath, "evil")); err != nil {
t.Fatal(err)
}
adminUser, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/resources?source=Downloads&path=/evil/victim.txt", nil)
rec := httptest.NewRecorder()
status, handlerErr := resourceDeleteHandler(rec, req, &requestContext{User: &adminUser})
if status != http.StatusForbidden {
t.Fatalf("status=%d err=%v", status, handlerErr)
}
if handlerErr == nil {
t.Fatal("expected delete to be forbidden")
}
got, err := os.ReadFile(victim)
if err != nil || string(got) != "keep" {
t.Fatalf("delete escaped: err=%v content=%q", err, got)
}
}
+15
View File
@@ -0,0 +1,15 @@
package web
import (
"sync"
"testing"
)
// webGlobalTestMu serializes tests that mutate process-wide settings.Config or state.
var webGlobalTestMu sync.Mutex
func lockWebGlobals(t testing.TB) {
t.Helper()
webGlobalTestMu.Lock()
t.Cleanup(webGlobalTestMu.Unlock)
}
+1
View File
@@ -30,6 +30,7 @@ import (
// setupWebDAVTestEnv sets up the test environment with multiple sources and users
func setupWebDAVTestEnv(t *testing.T) (string, string) {
lockWebGlobals(t)
// Create temp directories for test sources
tempDir := t.TempDir()
source1Path := filepath.Join(tempDir, "source1")
+55
View File
@@ -1241,6 +1241,61 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
return realPath, isDir, nil
}
// realPathCacheJoinedPath builds the filesystem join used in RealPathCache keys.
func realPathCacheJoinedPath(sourceRoot, indexPath string) string {
seg := utils.CleanIndexPathSegment(indexPath)
if seg == "" {
return sourceRoot
}
return filepath.Join(sourceRoot, seg)
}
func realPathCacheIndexAncestors(indexPath string) []string {
rel := utils.CleanIndexPathSegment(indexPath)
if rel == "" {
return []string{"/"}
}
parts := strings.Split(rel, "/")
ancestors := []string{"/"}
for i := range parts {
ancestors = append(ancestors, "/"+strings.Join(parts[:i+1], "/"))
}
return ancestors
}
// InvalidateRealPathCache drops cached symlink resolutions for indexPath and its
// ancestors. scopeBounds should include any caller scope prefixes used with
// GetRealPathScoped (e.g. user or share path).
func (idx *Index) InvalidateRealPathCache(indexPath string, scopeBounds ...string) {
boundSet := map[string]struct{}{"/": {}, "": {}}
for _, b := range scopeBounds {
b = strings.TrimRight(strings.TrimSpace(b), "/")
if b == "" || b == "/" {
boundSet["/"] = struct{}{}
boundSet[""] = struct{}{}
continue
}
if !strings.HasPrefix(b, "/") {
b = "/" + b
}
boundSet[b] = struct{}{}
}
bounds := make([]string, 0, len(boundSet))
for b := range boundSet {
bounds = append(bounds, b)
}
for _, ip := range realPathCacheIndexAncestors(indexPath) {
joined := realPathCacheJoinedPath(idx.Path, ip)
for _, enforceScope := range []bool{false, true} {
for _, bound := range bounds {
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", enforceScope, bound, joined)
RealPathCache.Delete(key)
IsDirCache.Delete(key + ":isdir")
}
}
}
}
// GetRealPathForMutation resolves an index path for a mutating filesystem
// operation (create/overwrite/mkdir/move-into). When the leaf exists it is fully
// resolved; otherwise the deepest existing ancestor is resolved and the missing
@@ -2,6 +2,7 @@ package indexing
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
@@ -163,6 +164,30 @@ func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
}
}
func TestInvalidateRealPathCache_ClearsGetRealPathEntry(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
userDir := filepath.Join(sourceRoot, "user1")
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, _, err := idx.GetRealPath("/user1/foo"); err != nil {
t.Fatalf("warm cache: %v", err)
}
joined := realPathCacheJoinedPath(sourceRoot, "/user1/foo")
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", false, "/", joined)
if _, ok := RealPathCache.Get(key); !ok {
t.Fatalf("expected cache key %q", key)
}
idx.InvalidateRealPathCache("/user1/foo", userScope)
if _, ok := RealPathCache.Get(key); ok {
t.Fatal("InvalidateRealPathCache should drop unscoped cache entry")
}
}
func TestGetRealPathForMutation_ExistingInScope(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
+5 -1
View File
@@ -124,7 +124,11 @@ func warnHttpProxyConfig() {
}
if Config.Http.TrustProxyHeaders && Config.Http.Socket != "" && !strings.HasPrefix(Config.Http.Socket, "@") &&
len(Config.Http.UnixSocketTrustedUIDs) == 0 && len(Config.Http.UnixSocketTrustedGIDs) == 0 {
logger.Warning("http.trustProxyHeaders is true and http.socket is a filesystem Unix socket, but http.unixSocketTrustedUIDs and http.unixSocketTrustedGIDs are empty; X-Forwarded-For and X-Real-IP are ignored until you list the reverse proxy's UID or GID (Linux peer credentials).")
if Config.Http.UnixSocketTrustAllLocalPeers {
logger.Warning("http.unixSocketTrustAllLocalPeers is true: any local process that can connect to the filesystem socket may present X-Forwarded-For and X-Real-IP. Prefer http.unixSocketTrustedUIDs and/or http.unixSocketTrustedGIDs so only peers with allowed Linux credentials can set forwarded headers.")
} else {
logger.Warning("http.trustProxyHeaders is true on a filesystem Unix socket, but http.unixSocketTrustedUIDs and http.unixSocketTrustedGIDs are empty; forwarded client headers from socket peers are ignored. List allowed peer UIDs and/or GIDs (Linux SO_PEERCRED), or set http.unixSocketTrustAllLocalPeers: true only if you accept legacy trust-all behavior.")
}
}
if !Env.IsDevMode {
if u := Config.Http.ExternalUrl; u != "" && strings.HasPrefix(strings.ToLower(u), "http://") {
+8 -5
View File
@@ -37,11 +37,14 @@ type Http struct {
TrustProxyHeaders bool `json:"trustProxyHeaders"` // honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
TrustedProxies []string `json:"trustedProxies"` // IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)
// UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
// present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
// are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
UnixSocketTrustedUIDs []int `json:"unixSocketTrustedUIDs"`
UnixSocketTrustedGIDs []int `json:"unixSocketTrustedGIDs"`
SocketFileMode string `json:"socketFileMode"` // octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
// present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
// unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
// every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
// forwarded headers.
UnixSocketTrustedUIDs []int `json:"unixSocketTrustedUIDs"`
UnixSocketTrustedGIDs []int `json:"unixSocketTrustedGIDs"`
UnixSocketTrustAllLocalPeers bool `json:"unixSocketTrustAllLocalPeers"` // default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty
SocketFileMode string `json:"socketFileMode"` // octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
SocketGroup string `json:"socketGroup"` // optional group name or numeric gid for the socket file (filesystem sockets only)
DisableRateLimit bool `json:"disableRateLimit"` // turns off built-in auth route rate limiting and failed-login lockout (default false).
}
+8 -4
View File
@@ -113,10 +113,11 @@ func ipMatchesPrefixes(ip string, prefixes []netip.Prefix) bool {
// proxy headers. Forwarded client headers must only be honored when this returns
// true; otherwise any client that can reach the listener directly could spoof its address.
//
// When http.socket is set the server listens only on that Unix socket. Filesystem
// socket peers are trusted only when their UID or GID matches
// http.unixSocketTrustedUIDs / http.unixSocketTrustedGIDs (Linux peer credentials).
// Abstract sockets ("@name") have no filesystem permissions and their peers are never trusted.
// When http.socket is set the server listens only on that Unix socket. Abstract
// sockets ("@name") have no filesystem permissions and their peers are never trusted.
// On filesystem sockets, when either allowlist is set, only peers whose Linux SO_PEERCRED
// UID or GID matches may present forwarded headers. When both allowlists are empty,
// peers are trusted only if http.unixSocketTrustAllLocalPeers is true (legacy opt-in).
func IsTrustedProxyPeer(r *http.Request) bool {
if r == nil {
return false
@@ -125,6 +126,9 @@ func IsTrustedProxyPeer(r *http.Request) bool {
if strings.HasPrefix(socket, "@") {
return false
}
if len(Config.Http.UnixSocketTrustedUIDs) == 0 && len(Config.Http.UnixSocketTrustedGIDs) == 0 {
return Config.Http.UnixSocketTrustAllLocalPeers
}
cred, ok := UnixPeerFromContext(r.Context())
if !ok {
return false
+12 -5
View File
@@ -132,7 +132,6 @@ func TestIsTrustedProxyPeer(t *testing.T) {
}
}
// With http.socket set, forwarded headers require matching unixSocketTrustedUIDs/GIDs.
func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
resetTrustedProxyCache()
Config.Http.TrustedProxies = nil
@@ -141,15 +140,23 @@ func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
Config.Http.Socket = ""
Config.Http.UnixSocketTrustedUIDs = nil
Config.Http.UnixSocketTrustedGIDs = nil
Config.Http.UnixSocketTrustAllLocalPeers = false
resetTrustedProxyCache()
})
for _, peer := range []string{"", "@"} {
if IsTrustedProxyPeer(&http.Request{RemoteAddr: peer}) {
t.Fatalf("unix socket peer %q must not be trusted without peer credentials", peer)
}
if IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
t.Fatal("filesystem unix socket with empty allowlists must not trust forwarded headers without opt-in")
}
Config.Http.UnixSocketTrustAllLocalPeers = true
if !IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
t.Fatal("unixSocketTrustAllLocalPeers should restore legacy trust-all behavior")
}
Config.Http.UnixSocketTrustAllLocalPeers = false
Config.Http.UnixSocketTrustedUIDs = []int{33}
reqNoCred := &http.Request{RemoteAddr: "@"}
if IsTrustedProxyPeer(reqNoCred) {
t.Fatal("with allowlists set, peer without credentials must not be trusted")
}
req := (&http.Request{}).WithContext(ContextWithUnixPeerForTest(context.Background(), 33, 1000))
if !IsTrustedProxyPeer(req) {
t.Fatal("peer with allowed UID should be trusted")
+6 -2
View File
@@ -8,8 +8,11 @@ import (
"os/user"
"strconv"
"strings"
"sync"
)
var unixListenUmaskMu sync.Mutex
type unixPeerCredKey struct{}
// UnixPeerCredential holds the peer process identity for a Unix domain connection.
@@ -69,14 +72,15 @@ func ListenUnixFilesystem(socketPath string) (net.Listener, error) {
}
setGroup = true
}
unixListenUmaskMu.Lock()
oldMask := unixUmask(0o077)
defer unixUmask(oldMask)
listener, err := net.Listen("unix", socketPath)
unixUmask(oldMask)
unixListenUmaskMu.Unlock()
if err != nil {
return nil, err
}
closeListener := func() { _ = listener.Close() }
// Assign group ownership while the socket is still owner-only (umask 077), then grant mode bits.
if setGroup {
if err := os.Chown(socketPath, -1, gid); err != nil {
closeListener()
+32 -1
View File
@@ -1,6 +1,11 @@
package settings
import "testing"
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestParseSocketFileModeDefaults(t *testing.T) {
mode, err := parseSocketFileMode("")
@@ -51,3 +56,29 @@ func TestUnixPeerAllowed(t *testing.T) {
t.Fatal("empty allowlists must deny")
}
}
func TestListenUnixFilesystemSetsModeWithRestrictiveUmask(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("filesystem unix sockets are not used on Windows")
}
dir := t.TempDir()
socketPath := filepath.Join(dir, "fb.sock")
orig := Config.Http
Config.Http.SocketFileMode = ""
t.Cleanup(func() {
Config.Http = orig
_ = os.Remove(socketPath)
})
ln, err := ListenUnixFilesystem(socketPath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
st, err := os.Stat(socketPath)
if err != nil {
t.Fatal(err)
}
if st.Mode().Perm() != 0o660 {
t.Fatalf("socket perm = %o, want 0660", st.Mode().Perm())
}
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !unix && !windows
package settings
func unixUmask(_ int) int {
return 0
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !unix
package settings
func unixUmask(mask int) int {
return 0
}
@@ -1,7 +0,0 @@
//go:build windows
package settings
func unixUmask(_ int) int {
return 0
}
+5 -1
View File
@@ -6040,6 +6040,10 @@ const docTemplate = `{
"type": "string"
}
},
"unixSocketTrustAllLocalPeers": {
"description": "default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty",
"type": "boolean"
},
"unixSocketTrustedGIDs": {
"type": "array",
"items": {
@@ -6047,7 +6051,7 @@ const docTemplate = `{
}
},
"unixSocketTrustedUIDs": {
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers\nare not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.",
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted\nunless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts\nevery peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust\nforwarded headers.",
"type": "array",
"items": {
"type": "integer"
+5 -1
View File
@@ -6029,6 +6029,10 @@
"type": "string"
}
},
"unixSocketTrustAllLocalPeers": {
"description": "default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty",
"type": "boolean"
},
"unixSocketTrustedGIDs": {
"type": "array",
"items": {
@@ -6036,7 +6040,7 @@
}
},
"unixSocketTrustedUIDs": {
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers\nare not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.",
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted\nunless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts\nevery peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust\nforwarded headers.",
"type": "array",
"items": {
"type": "integer"
+8 -2
View File
@@ -500,6 +500,10 @@ definitions:
items:
type: string
type: array
unixSocketTrustAllLocalPeers:
description: 'default false: set true to trust forwarded headers from any
local filesystem socket peer when allowlists are empty'
type: boolean
unixSocketTrustedGIDs:
items:
type: integer
@@ -507,8 +511,10 @@ definitions:
unixSocketTrustedUIDs:
description: |-
UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
forwarded headers.
items:
type: integer
type: array
+1 -1
View File
@@ -1 +1 @@
22
22.22.2
+684 -1436
View File
File diff suppressed because it is too large. Load diff
+15 -14
View File
@@ -5,7 +5,7 @@
"type": "module",
"engines": {
"npm": ">=9.0.0",
"node": "^22.13.0 || ^24.0.0 || >=26.0.0"
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"scripts": {
"build": "mkdir -p ../backend/internal/web/embed && rm -rf ../backend/internal/web/embed/* && vite build && cp -r ../backend/internal/web/dist/* ../backend/internal/web/embed",
@@ -30,34 +30,34 @@
"test": "vitest run "
},
"dependencies": {
"@likecoin/epub-ts": "^0.7.2",
"@likecoin/epub-ts": "^0.7.3",
"@onlyoffice/document-editor-vue": "^1.7.0",
"ace-builds": "^1.45.0",
"chart.js": "^4.5.1",
"ace-builds": "^1.45.2",
"chart.js": "^4.6.1",
"dompurify": "^3.4.17",
"highlight.js": "^11.12.0",
"js-yaml": "^5.5.0",
"mammoth": "^1.13.0",
"marked": "^18.1.1",
"marked": "^18.2.0",
"plyr": "^3.8.5",
"qrcode.vue": "^3.11.0",
"temml": "^0.14.0",
"three": "^0.186.1",
"vue": "^3.5.44",
"vue-i18n": "^11.4.16",
"vue-router": "^5.4.0"
"three": "^0.187.0",
"vue": "^3.5.45",
"vue-i18n": "^11.4.19",
"vue-router": "^5.5.0"
},
"overrides": {
"minimatch": "^10.2.6",
"minimatch": "^11.0.8",
"@xmldom/xmldom": "^0.8.15",
"mammoth": {
"argparse": "^2.0.1"
"argparse": "^3.0.4"
},
"@intlify/vue-i18n-extensions": {
"vue-i18n": "$vue-i18n"
},
"@intlify/eslint-plugin-vue-i18n": {
"glob": "^13.0.0"
"glob": "^14.0.6"
}
},
"devDependencies": {
@@ -69,12 +69,13 @@
"@typescript-eslint/parser": "^8.72.0",
"@vitejs/plugin-vue": "^6.0.10",
"deepl-node": "^1.28.1",
"esbuild": "^0.28.2",
"eslint": "^10.13.0",
"eslint-plugin-vue": "^10.11.1",
"fs-extra": "^11.4.1",
"jsdom": "^30.1.3",
"stylelint": "^17.16.0",
"stylelint-config-standard": "^40.0.0",
"stylelint": "^18.0.0",
"stylelint-config-standard": "^41.0.0",
"stylelint-config-standard-vue": "^2.0.0",
"typescript": "^6.0.3",
"vite": "^8.3.4",
+5 -2
View File
@@ -318,9 +318,12 @@ http:
trustProxyHeaders: false # honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
trustedProxies: # IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)
unixSocketTrustedUIDs: # UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
# present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
# are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
# present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
# unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
# every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
# forwarded headers.
unixSocketTrustedGIDs:
unixSocketTrustAllLocalPeers: false # default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty
socketFileMode: "" # octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
socketGroup: "" # optional group name or numeric gid for the socket file (filesystem sockets only)
disableRateLimit: false # turns off built-in auth route rate limiting and failed-login lockout (default false).
@@ -0,0 +1,112 @@
import { createApp, h, nextTick } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
const { fetchFiles, state } = vi.hoisted(() => ({
fetchFiles: vi.fn(),
state: { req: {}, prompts: [], selected: [], sources: { info: { data: {} } } },
}));
vi.mock("@/api", () => ({ resourcesApi: { fetchFiles } }));
vi.mock("@/store", () => ({
state,
mutations: {},
getters: {
isShare: () => false,
isMobile: () => false,
currentPromptName: () => null,
pickerSorting: () => ({ by: "name", asc: true }),
},
}));
vi.mock("@/utils", async () => {
const { removeLastDir } = await import("@/utils/url.js");
return { url: { removeLastDir } };
});
vi.mock("@/router/index.ts", () => ({ router: {} }));
vi.mock("@/components/files/ListingHeader.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/files/ListingItem.vue", () => ({
default: {
props: ["name"],
render() {
return h("button", {}, this.name);
},
},
}));
import FileList from "./FileList.vue";
let app, root;
const selection = vi.fn();
function mount(items = [], path = "/child/") {
state.req = { source: "data", path, items };
root = document.createElement("div");
document.body.append(root);
app = createApp(FileList, { hidePathChrome: true, "onUpdate:selected": selection });
app.config.globalProperties.$t = (key) => key;
app.mount(root);
}
function row(name) {
return Array.from(root.querySelectorAll("button")).find((button) => button.textContent === name);
}
afterEach(() => {
app?.unmount();
root?.remove();
fetchFiles.mockReset();
selection.mockReset();
});
describe("file picker parent navigation", () => {
it("loads the parent and shows sibling folders after one click on '..'", async () => {
mount();
fetchFiles.mockResolvedValue({
source: "data",
items: [{ name: "sibling", path: "/sibling/", type: "directory" }],
});
await nextTick();
row("..").click();
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/", source: "data" }));
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
await vi.waitFor(() => expect(row("sibling")).toBeDefined());
});
it("keeps single-click selection and double-click navigation for ordinary folders", async () => {
mount([{ name: "nested", path: "/child/nested/", type: "directory" }]);
fetchFiles.mockResolvedValue({ source: "data", items: [] });
await nextTick();
row("nested").click();
expect(fetchFiles).not.toHaveBeenCalled();
row("nested").dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/child/nested/");
});
it("does not fetch the parent again when the same row receives a double-click", async () => {
mount();
fetchFiles.mockResolvedValue({ source: "data", items: [] });
await nextTick();
const parent = row("..");
parent.click();
parent.click();
parent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
});
it("ignores the rest of a double-click on a replacement parent row", async () => {
mount([], "/parent/child/");
fetchFiles.mockResolvedValue({ source: "data", items: [] });
await nextTick();
const firstParent = row("..");
firstParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
await vi.waitFor(() => {
expect(row("..")).toBeDefined();
expect(row("..")).not.toBe(firstParent);
});
const nextParent = row("..");
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 2 }));
nextParent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, detail: 2 }));
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/parent/");
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
expect(fetchFiles).toHaveBeenLastCalledWith("data", "/");
expect(fetchFiles).toHaveBeenCalledTimes(2);
});
});
+9 -1
View File
@@ -542,6 +542,7 @@ export default {
this.next(event);
}
},
/** @param {MouseEvent} event */
handleItemClick(item, _index, event) {
event.preventDefault();
event.stopPropagation();
@@ -552,7 +553,6 @@ export default {
return;
}
// Browse mode: single click selects only, emit selected, do not navigate
const syntheticEvent = {
currentTarget: {
dataset: {
@@ -562,11 +562,19 @@ export default {
preventDefault: () => {},
stopPropagation: () => {},
};
if (item.name === "..") {
if (event.detail > 1 || this.path === item.path) return;
this.select(syntheticEvent);
this.next(syntheticEvent);
return;
}
this.select(syntheticEvent);
},
/** @param {MouseEvent} event */
handleItemDblClick(item, _index, event) {
event.preventDefault();
event.stopPropagation();
if (item.name === ".." && (!this.fileList || this.path === item.path)) return;
const syntheticEvent = {
currentTarget: {
@@ -0,0 +1,120 @@
import { createApp, nextTick } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createUserEditSession, destroyUserEditSession, getUserEditSession } from "@/utils/userEditSession.js";
import { applySectionsToFlatUser, sectionsFromFlatUser } from "@/utils/userProfileSections.js";
const { fetchURL, fetchJSON, state, mutations } = vi.hoisted(() => ({
fetchURL: vi.fn(),
fetchJSON: vi.fn(),
state: { user: { username: "admin" }, enforcedUserDefaults: {} },
mutations: { closeTopPrompt: vi.fn() },
}));
vi.mock("@/api", async () => ({
usersApi: await vi.importActual("@/api/users.js"),
settingsApi: {},
authApi: {},
accessApi: {},
}));
vi.mock("@/api/utils.ts", () => ({ fetchURL, fetchJSON }));
vi.mock("@/router/index.ts", () => ({ router: {} }));
vi.mock("@/store", () => ({ state, mutations }));
vi.mock("@/store/state.ts", () => ({ state }));
vi.mock("@/store/mutations.ts", () => ({ mutations }));
vi.mock("@/store/eventBus", () => ({ eventBus: { emit: vi.fn() } }));
vi.mock("@/notify", () => ({ notify: { showSuccessToast: vi.fn(), showError: vi.fn() } }));
vi.mock("@/i18n/index.ts", () => ({ default: { global: { t: (key) => key } } }));
vi.mock("@/utils/auth", () => ({ validateLogin: vi.fn() }));
vi.mock("@/components/settings/UserProfilePreferences.vue", () => ({ default: { render: () => null } }));
import { get } from "@/api/users.js";
import UserEdit from "./UserEdit.vue";
import UserEditPreferences from "./UserEditPreferences.vue";
let app, root;
function openPreferences(user) {
createUserEditSession({ user, profileUser: { ...user } });
root = document.createElement("div");
document.body.append(root);
app = createApp(UserEditPreferences);
app.config.globalProperties.$t = (key) => key;
app.mount(root);
}
function passwordChangeToggle() {
return root.querySelector('input[aria-label="settings.requirePasswordChange"]');
}
afterEach(() => {
app?.unmount();
app = undefined;
root?.remove();
destroyUserEditSession();
state.enforcedUserDefaults = {};
fetchURL.mockReset();
fetchJSON.mockReset();
});
describe("password changes in user edit preferences", () => {
it.each([false, true])("passes the changed requirement to the parent save (initially %s)", async (initial) => {
const user = { username: "user", loginMethod: "password", requirePasswordChange: initial };
applySectionsToFlatUser(user, sectionsFromFlatUser(user));
const editor = {
...UserEdit.data(),
user: { ...user },
profileUser: { ...user },
loaded: true,
groupsReady: true,
isNew: false,
enforcedAccount: {},
enforcedAccountPermissions: {},
enforcedPreferences: {},
$t: (key) => key,
};
for (const [name, method] of Object.entries(UserEdit.methods)) {
editor[name] = method.bind(editor);
}
editor.saveGroups = vi.fn();
editor.syncEditAccountForm();
editor.originalSnapshot = editor.buildEditableSnapshot();
openPreferences(user);
await nextTick();
const toggle = passwordChangeToggle();
expect(toggle?.checked).toBe(initial);
toggle.click();
await nextTick();
expect(getUserEditSession().user.requirePasswordChange).toBe(!initial);
expect(getUserEditSession().profileUser.requirePasswordChange).toBe(!initial);
await editor.save({ preventDefault: vi.fn() });
expect(fetchURL).toHaveBeenCalledTimes(1);
const [url, request] = fetchURL.mock.calls[0];
expect(url).toMatch(/\/api\/users\?username=user$/);
expect(request.method).toBe("PATCH");
const body = JSON.parse(request.body);
expect(body).toEqual({
which: ["requirePasswordChange"],
data: { requirePasswordChange: !initial },
});
app.unmount();
root.remove();
fetchJSON.mockResolvedValue({ ...user, ...body.data });
openPreferences(await get(user.username));
await nextTick();
expect(passwordChangeToggle()?.checked).toBe(!initial);
});
it("hides the toggle for externally authenticated users", async () => {
openPreferences({ username: "user", loginMethod: "oidc" });
await nextTick();
expect(passwordChangeToggle()).toBeNull();
});
it("disables an enforced requirement", async () => {
state.enforcedUserDefaults = { account: { requirePasswordChange: true } };
openPreferences({ username: "user", loginMethod: "password", requirePasswordChange: true });
await nextTick();
expect(passwordChangeToggle()?.disabled).toBe(true);
});
});
@@ -7,6 +7,7 @@
:account="editAccount"
:enforced="enforcedAccount"
:enforced-permissions="enforcedAccountPermissions"
:show-require-password-change="session.user?.loginMethod === 'password'"
respect-enforced-policy
@account-change="onEditAccountChange"
/>
@@ -62,6 +63,7 @@ export default {
sessionUnsubscribe: null,
editAccount: {
lockPassword: false,
requirePasswordChange: false,
disableSettings: false,
disableUpdateNotifications: false,
showAdvancedProfile: false,
@@ -114,6 +116,7 @@ export default {
const user = this.session?.user || {};
const permissions = user.permissions || {};
this.editAccount.lockPassword = !!user.lockPassword;
this.editAccount.requirePasswordChange = !!user.requirePasswordChange;
this.editAccount.disableSettings = !!user.disableSettings;
this.editAccount.disableUpdateNotifications = !!user.disableUpdateNotifications;
this.editAccount.showAdvancedProfile = !!user.showAdvancedProfile;
@@ -131,6 +134,7 @@ export default {
const user = {
...this.session.user,
lockPassword: this.editAccount.lockPassword,
requirePasswordChange: this.editAccount.requirePasswordChange,
disableSettings: this.editAccount.disableSettings,
disableUpdateNotifications: this.editAccount.disableUpdateNotifications,
showAdvancedProfile: this.editAccount.showAdvancedProfile,
@@ -144,6 +148,7 @@ export default {
sections.account = {
...(sections.account || {}),
lockPassword: this.editAccount.lockPassword,
requirePasswordChange: this.editAccount.requirePasswordChange,
disableSettings: this.editAccount.disableSettings,
disableUpdateNotifications: this.editAccount.disableUpdateNotifications,
showAdvancedProfile: this.editAccount.showAdvancedProfile,
+21 -14
View File
@@ -30,6 +30,21 @@
aria-label="Edit Links"
@click="openSidebarLinksPrompt" class="material-symbols action">edit</i>
</div>
<!-- Source switcher only in navigation mode and when multiple sources exist (hidden in shares).
Kept outside the scrollable content area so it does not scroll horizontally with the folder tree. -->
<div
v-if="mode === 'navigation' && !isShare && sourceNames.length > 1"
class="source-card-container"
ref="sourceCardContainer"
>
<ExpandDropdown
class="sidebar-source-switcher"
:model-value="activeSource"
:options="sourceDropdownOptions"
:aria-label="$t('general.source')"
@update:model-value="navigateToSource"
/>
</div>
<!-- Scrollable Content Area -->
<div class="sidebar-links-content">
<!-- Links Mode -->
@@ -131,20 +146,6 @@
</template>
<!-- Navigation Mode -->
<template v-else>
<!-- Source switcher only when multiple sources (hidden in shares) -->
<div
v-if="!isShare && sourceNames.length > 1"
class="source-card-container"
ref="sourceCardContainer"
>
<ExpandDropdown
class="sidebar-source-switcher"
:model-value="activeSource"
:options="sourceDropdownOptions"
:aria-label="$t('general.source')"
@update:model-value="navigateToSource"
/>
</div>
<FileTree
:currentPath="currentPath"
:currentSource="currentSource"
@@ -897,11 +898,17 @@ a.sidebar-link-button {
.source-card-container {
position: relative;
margin-top: 0.5em;
margin-bottom: 0;
padding-bottom: 0;
border-bottom: 1px solid var(--divider);
}
/* When the source switcher sits above the scroll area, the content keeps its tree flush under the divider. */
.source-card-container + .sidebar-links-content {
margin-top: 0;
}
.sidebar-source-switcher {
margin-top: 0;
margin-bottom: 0.5em;
+264 -264
View File
File diff suppressed because it is too large. Load diff
@@ -111,6 +111,22 @@ test("2x copy from listing to new folder", async({ page, checkForErrors }) => {
await checkForNotification(page, "Files copied successfully!");
await page.goto("/files/playwright%20%2B%20files/myfolder/newfolder/");
await expect(page).toHaveTitle(/.* - newfolder/);
await page.locator('a[aria-label="copyme.txt"]').click({ button: "right" });
await page.locator('button[aria-label="Copy file"]').click();
const parentRow = page.locator('div[aria-label="copy-prompt"] .listing-item[aria-label=".."]');
const originalParentRow = await parentRow.elementHandle();
await parentRow.click();
await expect(copyDestLabel(page)).toHaveText(`/myfolder/ (${GENERAL_COPY_SOURCE})`);
await expect(parentRow).toBeVisible();
expect(await originalParentRow?.evaluate((row) => row.isConnected)).toBe(false);
await parentRow.evaluate((row) => {
for (const type of ["click", "dblclick"]) {
row.dispatchEvent(new MouseEvent(type, { bubbles: true, detail: 2 }));
}
});
await expect(copyDestLabel(page)).toHaveText(`/myfolder/ (${GENERAL_COPY_SOURCE})`);
await parentRow.click();
await expect(copyDestLabel(page)).toHaveText(`/ (${GENERAL_COPY_SOURCE})`);
checkForErrors();
})
@@ -0,0 +1,225 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../test-setup.ts";
/*
* Regression test for issue #3351.
*
* Defect: when the folder tree in the sidebar overflows horizontally, the
* source path selector (rendered inside the same scroll container,
* `.sidebar-links-content`) moves with the tree instead of staying fixed.
*
* The no-auth configuration already provides two enabled sources (`exclude`
* and `include`), so the path selector renders. The nested fixture is created
* through the application's own API before each test and removed afterwards.
*/
// Enabled source from the no-auth configuration (see _docker/src/noauth/backend/config.yaml).
const SOURCE = "exclude";
// Fixture location inside the source. The exact name is only used by this test.
const FIXTURE_ROOT_NAME = "sidebar-scroll-3351";
const FIXTURE_ROOT = `/${FIXTURE_ROOT_NAME}/`;
const LEVEL_NAMES = [
"level-1-sidebar-width-overflow-probe-directory",
"level-2-nested-directory-with-an-extremely-long-name",
"level-3-deep-directory-name-that-keeps-going-for-width",
"level-4-directory-used-to-widen-the-tree-past-sidebar",
"level-5-directory-with-a-very-long-name-to-force-scroll",
"level-6-directory-name-long-enough-to-overflow-content",
"level-7-leaf-directory-with-the-longest-name-in-tree-x",
];
const DEEPEST_LEVEL_NAME = LEVEL_NAMES[LEVEL_NAMES.length - 1];
const DEEP_PATH = `${FIXTURE_ROOT}${LEVEL_NAMES.join("/")}/`;
// API paths are absolute, so they resolve under the configured baseURL (the app is served under /files/).
const RESOURCES_API = "/files/api/resources";
const MIN_OVERFLOW_PX = 100;
const MIN_TREE_SCROLL_PX = 50;
const STATIONARY_TOLERANCE_PX = 1;
const MIN_VERTICAL_SCROLL_PX = 50;
/** Removes the fixture directory tree. A missing fixture (404) is not an error. */
async function deleteFixture(page: Page): Promise<void> {
const response = await page.request.delete(RESOURCES_API, {
params: { path: FIXTURE_ROOT, source: SOURCE },
});
if (response.status() !== 404 && !response.ok()) {
throw new Error(`could not delete fixture ${FIXTURE_ROOT}: HTTP ${response.status()}`);
}
}
/** Creates the nested directories one level at a time, parents first. */
async function createFixture(page: Page): Promise<void> {
let path = "/";
for (const name of [FIXTURE_ROOT_NAME, ...LEVEL_NAMES]) {
path = `${path}${name}/`;
const response = await page.request.post(RESOURCES_API, {
params: { path, source: SOURCE, isDir: "true" },
});
if (!response.ok()) {
throw new Error(`could not create fixture directory ${path}: HTTP ${response.status()}`);
}
}
}
async function requireBox(locator: Locator, name: string) {
const box = await locator.boundingBox();
if (!box) {
throw new Error(`${name} has no bounding box (not rendered or not visible)`);
}
return box;
}
/**
* Waits until the sidebar's scroll position and a tree row's position stop changing.
* The sidebar scrolls on its own after the folder tree first renders (it brings the current
* folder into view), so baselines must be taken after that movement ends.
*/
async function waitForSidebarToSettle(content: Locator, row: Locator) {
let previous: string | null = null;
await expect
.poll(
async () => {
const box = await requireBox(row, "deepest tree row");
const scrollLeft = await content.evaluate((el) => el.scrollLeft);
const current = `${scrollLeft}|${box.x}`;
const settled = current === previous;
previous = current;
return settled;
},
{ message: "sidebar scroll position and tree row should settle before measuring" },
)
.toBe(true);
}
// Viewport used to reproduce the overflow (sidebar width 288px at this size).
test.use({ viewport: { width: 1400, height: 900 } });
test.beforeEach(async ({ page }) => {
// Remove any fixture left behind by an interrupted run, then create a fresh one.
await deleteFixture(page);
await createFixture(page);
});
test.afterEach(async ({ page }) => {
await deleteFixture(page);
});
test("sidebar path selector stays stationary while the folder tree scrolls horizontally", async ({ page, checkForErrors }) => {
const sidebarContent = page.locator(".sidebar-links-content");
const pathSelector = page.locator(".sidebar-source-switcher");
const homeButton = page.getByLabel("Navigate Home");
const fileTree = page.locator(".file-tree-container");
const deepestRow = page.locator(".tree-node", { hasText: DEEPEST_LEVEL_NAME });
const sidebar = page.locator(".sidebar-links");
const modeToggle = page.locator(".sidebar-mode-toggle");
const editButton = page.locator('.sidebar-links-header i[aria-label="Edit Links"]');
const levelOneRow = page.locator(".tree-node", { hasText: LEVEL_NAMES[0] });
const levelTwoRow = page.locator(".tree-node", { hasText: LEVEL_NAMES[1] });
await page.goto(`/files/${SOURCE}${DEEP_PATH}`);
// The sidebar opens in links mode; the folder tree renders only in navigation mode.
await page.locator(".sidebar-mode-toggle label.switch").click();
await expect(fileTree).toBeVisible();
await expect(deepestRow).toBeVisible();
await waitForSidebarToSettle(sidebarContent, deepestRow);
// Two enabled sources must render the path selector. Fail if it is absent.
await expect(pathSelector, "path selector must render with two enabled sources").toBeVisible();
// Overflow precondition: the scroll container that holds the tree must overflow by >= 100px.
const overflowX = await sidebarContent.evaluate((el) => getComputedStyle(el).overflowX);
expect(overflowX, ".sidebar-links-content must be a horizontal scroll container").toMatch(/auto|scroll/);
const overflowPx = await sidebarContent.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(overflowPx, "horizontal overflow precondition").toBeGreaterThanOrEqual(MIN_OVERFLOW_PX);
// Deterministic start: measure relative to the current scrollLeft, which may be nonzero.
const scrollLeftBefore = await sidebarContent.evaluate((el) => el.scrollLeft);
const remainingScrollPx = await sidebarContent.evaluate((el) => el.scrollWidth - el.clientWidth - el.scrollLeft);
expect(remainingScrollPx, "container must have room to scroll at least 50px from its starting position")
.toBeGreaterThanOrEqual(MIN_TREE_SCROLL_PX);
const homeBefore = await requireBox(homeButton, "home button");
const pathBefore = await requireBox(pathSelector, "path selector");
const treeRowBefore = await requireBox(deepestRow, "deepest tree row");
const modeToggleBefore = await requireBox(modeToggle, "mode toggle");
const editButtonBefore = await requireBox(editButton, "edit links button");
// Scroll the actual overflowing container horizontally with a wheel gesture over its content.
const contentBox = await requireBox(sidebarContent, ".sidebar-links-content");
await page.mouse.move(contentBox.x + contentBox.width / 2, contentBox.y + contentBox.height / 2);
await page.mouse.wheel(200, 0);
await expect
.poll(() => sidebarContent.evaluate((el) => el.scrollLeft), { message: "container should scroll horizontally" })
.toBeGreaterThanOrEqual(scrollLeftBefore + MIN_TREE_SCROLL_PX);
// The folder tree itself must move horizontally.
const treeRowAfter = await requireBox(deepestRow, "deepest tree row");
const treeMovedLeftPx = treeRowBefore.x - treeRowAfter.x;
expect(treeMovedLeftPx, "folder tree rows must move left by at least 50px").toBeGreaterThanOrEqual(MIN_TREE_SCROLL_PX);
// Preservation check: the home button must stay put while the tree scrolls.
const homeAfter = await requireBox(homeButton, "home button");
expect(Math.abs(homeAfter.x - homeBefore.x), "home button must not move").toBeLessThanOrEqual(STATIONARY_TOLERANCE_PX);
// Regression assertion: the path selector must stay stationary. Fails on the pre-fix code.
const pathAfter = await requireBox(pathSelector, "path selector");
expect(
Math.abs(pathAfter.x - pathBefore.x),
"path selector moved horizontally with the folder tree (issue #3351)",
).toBeLessThanOrEqual(STATIONARY_TOLERANCE_PX);
// Mode toggle and edit links button must also stay put.
const modeToggleAfter = await requireBox(modeToggle, "mode toggle");
expect(Math.abs(modeToggleAfter.x - modeToggleBefore.x), "mode toggle must not move").toBeLessThanOrEqual(
STATIONARY_TOLERANCE_PX,
);
const editButtonAfter = await requireBox(editButton, "edit links button");
expect(Math.abs(editButtonAfter.x - editButtonBefore.x), "edit links button must not move").toBeLessThanOrEqual(
STATIONARY_TOLERANCE_PX,
);
// Header controls and the path selector must stay inside the sidebar's visible bounds.
const sidebarAfter = await requireBox(sidebar, ".sidebar-links");
const controlsAfter = [
["home button", homeAfter],
["mode toggle", modeToggleAfter],
["edit links button", editButtonAfter],
["path selector", pathAfter],
] as const;
for (const [name, box] of controlsAfter) {
expect(box.x, `${name} must not be clipped on the left`).toBeGreaterThanOrEqual(
sidebarAfter.x - STATIONARY_TOLERANCE_PX,
);
expect(box.x + box.width, `${name} must not be clipped on the right`).toBeLessThanOrEqual(
sidebarAfter.x + sidebarAfter.width + STATIONARY_TOLERANCE_PX,
);
}
// The active folder keeps its highlight after horizontal scrolling.
await expect(deepestRow, "active folder keeps the current-item class").toHaveClass(/current-item/);
// Vertical scrolling still works on the tall tree.
const verticalOverflowPx = await sidebarContent.evaluate((el) => el.scrollHeight - el.clientHeight);
expect(verticalOverflowPx, "tree must be tall enough to scroll vertically").toBeGreaterThanOrEqual(
MIN_VERTICAL_SCROLL_PX,
);
const scrollTopBefore = await sidebarContent.evaluate((el) => el.scrollTop);
await page.mouse.wheel(0, 200);
await expect
.poll(() => sidebarContent.evaluate((el) => el.scrollTop), { message: "sidebar should scroll vertically" })
.toBeGreaterThanOrEqual(scrollTopBefore + MIN_VERTICAL_SCROLL_PX);
// Expand and collapse still work: collapsing a folder removes its child row, expanding restores it.
const levelOneExpandIcon = levelOneRow.locator(".expand-icon");
await expect(levelTwoRow, "child row is visible before collapsing").toHaveCount(1);
await levelOneExpandIcon.click();
await expect(levelTwoRow, "collapsing a folder hides its children").toHaveCount(0);
await levelOneExpandIcon.click();
await expect(levelTwoRow, "expanding a folder shows its children again").toHaveCount(1);
checkForErrors();
});