Compare commits

...
Author SHA1 Message Date
Graham Steffaniak 885adcb3a2 Beta/v2.1.5 (#3389) 2026-11-13 19:32:18 -05:00
Graham Steffaniak aaa34e9bb3 Fix generation counters (#3391) 2026-11-13 18:17:06 -06:00
Graham Steffaniak 844f8f9fdd address coderabbit blocker comments (#3390) 2026-11-13 18:03:02 -06:00
15 changed files with 417 additions and 5 deletions

No files matched your search

@@ -674,6 +674,9 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
return err
}
if isDir {
indexing.ClearRealPathCaches()
}
index.InvalidateRealPathCache(deleteIndexPath, boundIndexPath)
if parent := utils.GetParentDirectoryPath(deleteIndexPath); parent != "" && parent != "/" {
index.InvalidateRealPathCache(parent, boundIndexPath)
@@ -807,6 +810,9 @@ func moveResourceImpl(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst st
if err != nil {
return err
}
if isSrcDir {
indexing.ClearRealPathCaches()
}
// Handle SOURCE cleanup (treat as deletion)
// Run async to avoid blocking the HTTP response
+18 -1
View File
@@ -2,6 +2,8 @@ package access
import (
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
@@ -13,7 +15,22 @@ func (s *Storage) PermittedAtRealPath(index *indexing.Index, username, realPath
if s == nil || index == nil || username == "" || realPath == "" {
return false
}
indexPath := index.MakeIndexPath(realPath, isDir).String()
lexical := realPath
if !strings.HasPrefix(realPath, index.Path) {
root, err := filepath.EvalSymlinks(index.Path)
if err != nil {
return false
}
rel, err := filepath.Rel(root, realPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return false
}
lexical = index.Path
if rel != "." {
lexical = filepath.Join(index.Path, rel)
}
}
indexPath := index.MakeIndexPath(lexical, isDir).String()
return s.Permitted(index.Path, utils.IndexPathFromNormalized(indexPath, true), username)
}
@@ -88,6 +88,37 @@ func TestPermittedAtRealPath_DeniesCopySourceResolved(t *testing.T) {
}
}
func TestPermittedAtRealPath_SymlinkedSourceRoot(t *testing.T) {
sourceRoot, _, _, user := setupSymlinkDenyFixture(t)
linkRoot := filepath.Join(filepath.Dir(sourceRoot), "root-link")
if err := os.Symlink(sourceRoot, linkRoot); err != nil {
t.Skipf("symlinks required: %v", err)
}
if _, err := filepath.EvalSymlinks(linkRoot); err != nil {
t.Fatal(err)
}
settings.Config.Server.SourceMap = map[string]*settings.Source{
linkRoot: {Path: linkRoot, Config: settings.SourceConfig{DenyByDefault: false}},
}
store := NewStorage(nil)
if err := store.DenyUser(linkRoot, utils.IndexPathFromNormalized("/bob", true), user); err != nil {
t.Fatal(err)
}
idx := &indexing.Index{Source: settings.Source{Path: linkRoot}}
realPath, _, err := idx.GetRealPathScoped("/", "/alice/link.txt")
if err != nil {
t.Fatal(err)
}
realPath, err = filepath.EvalSymlinks(realPath)
if err != nil {
t.Fatal(err)
}
if store.PermittedAtRealPath(idx, user, realPath, false) {
t.Fatal("resolved target under denied /bob must not be permitted with symlinked source root")
}
}
func TestIsDirAtRealPath(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o755); err != nil {
+3
View File
@@ -465,6 +465,9 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
}
if orientStr != "" && orientStr != "1" {
if !embeddedPreviewWithinLimits(thm) {
return nil, wrappedReader, fmt.Errorf("embedded thumbnail exceeds decode limits")
}
thm = applyOrientationToPreviewBytes(thm, orientStr)
}
+4
View File
@@ -288,6 +288,9 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
if err != nil {
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
}
if !state.AccessPermittedAtRealPath(idxTo, d.User.Username, destFileReal, false) {
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
}
if err = os.MkdirAll(filepath.Dir(destFileReal), fileutils.EffectiveDirPerm()); err != nil {
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
}
@@ -1269,6 +1272,7 @@ func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time
logger.Debugf("unarchive: symlink: %v", err)
return fmt.Errorf("failed to create symlink from archive entry")
}
indexing.ClearRealPathCaches()
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
}
@@ -0,0 +1,86 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestArchiveCreate_DeniesResolvedSymlinkDestination(t *testing.T) {
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/denied", true), username); err != nil {
t.Fatal(err)
}
allowedDir := filepath.Join(sourceRoot, "allowed")
deniedDir := filepath.Join(sourceRoot, "denied")
for _, dir := range []string{allowedDir, deniedDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
victim := filepath.Join(deniedDir, "victim")
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(allowedDir, "report.zip")
if err := os.Symlink(filepath.Join("..", "denied", "victim"), link); err != nil {
t.Fatal(err)
}
user, err := state.GetUserByUsername(username)
if err != nil {
t.Fatal(err)
}
settings.Config.Server.SourceMap[sourceRoot].Config.DenyByDefault = false
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/allowed/report.zip","format":"zip"}`
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
rec := httptest.NewRecorder()
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
if status != http.StatusForbidden || handlerErr == nil {
t.Fatalf("status=%d err=%v", status, handlerErr)
}
got, err := os.ReadFile(victim)
if err != nil || string(got) != "keep" {
t.Fatalf("victim content=%q err=%v", got, err)
}
}
func TestArchiveCreate_AllowsNonSymlinkDestination(t *testing.T) {
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
dest := filepath.Join(sourceRoot, "out.zip")
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
user, err := state.GetUserByUsername(username)
if err != nil {
t.Fatal(err)
}
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/out.zip","format":"zip"}`
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
rec := httptest.NewRecorder()
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
if status != http.StatusOK || handlerErr != nil {
t.Fatalf("status=%d err=%v body=%s", status, handlerErr, rec.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(dest); err != nil {
t.Fatalf("expected archive at %s: %v", dest, err)
}
}
+4
View File
@@ -11,6 +11,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
@@ -125,6 +126,9 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if err != nil {
return realPathErrStatus(err), err
}
if !state.AccessPermittedAtRealPath(idx, d.ShareUser.Username, subtitlePath, false) {
return http.StatusForbidden, fmt.Errorf("access denied")
}
} else {
subtitlePath = filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
}
@@ -242,6 +242,54 @@ func TestSubtitlesHandler_ShareSingleFileLoadsSidecar(t *testing.T) {
}
}
func TestSubtitlesHandler_ShareDeniesSidecarSymlinkToDeniedTarget(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
sharedDir := filepath.Join(sourceRoot, "shared")
privateDir := filepath.Join(sourceRoot, "private")
for _, dir := range []string{sharedDir, privateDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
}
secret := filepath.Join(privateDir, "clip.srt")
if err := os.WriteFile(secret, []byte("SECRET_DENIED_SUBTITLE"), 0644); err != nil {
t.Fatal(err)
}
videoPath := filepath.Join(sharedDir, "clip.mov")
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join("..", "private", "clip.srt"), filepath.Join(sharedDir, "clip.srt")); err != nil {
t.Fatal(err)
}
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/private", true), "admin"); err != nil {
t.Fatal(err)
}
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/"}}
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=clip.mov&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{
User: adminUser,
ShareUser: adminUser,
IndexPath: "/shared/clip.mov/",
Share: share.Share{
ShareColumns: share.ShareColumns{
Hash: "h",
Path: "/shared",
},
SourcePath: sourceRoot,
},
})
if status != http.StatusForbidden {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "SECRET_DENIED_SUBTITLE") {
t.Fatalf("response leaked denied subtitle")
}
}
func TestSubtitlesHandler_ShareRejectsSidecarPastEscapingDirSymlink(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
videoIndexPath := "/shared/link/clip.mov/"
+22
View File
@@ -16,6 +16,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
@@ -226,6 +227,27 @@ func getDirectoryPreview(ctx context.Context, r *http.Request, d *Context, frame
if err != nil {
return nil, err
}
if d.Share.Hash != "" {
idx := indexing.GetIndex(source)
if idx == nil {
return nil, fmt.Errorf("source not available")
}
userScope, scopeErr := d.ShareUser.GetScopeForSourceName(source)
if scopeErr != nil {
return nil, scopeErr
}
shareRel := strings.TrimPrefix(d.Share.Path, userScope)
bound := utils.JoinScopedIndexPath(userScope, shareRel)
childIndexPath := utils.JoinScopedIndexPath(userScope, path)
realPath, _, rerr := idx.GetRealPathScoped(bound, childIndexPath)
if rerr != nil {
if index != 0 {
return getDirectoryPreview(ctx, r, d, 0)
}
return nil, rerr
}
fileInfo.RealPath = realPath
}
tempCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
cancel()
@@ -0,0 +1,70 @@
package web
import (
"context"
"errors"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
func TestGetDirectoryPreview_ShareRejectsSymlinkChildOutsideShare(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
sharedDir := filepath.Join(sourceRoot, "shared")
privateDir := filepath.Join(sourceRoot, "private")
for _, dir := range []string{sharedDir, privateDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(privateDir, "secret.jpg"), []byte{0xff, 0xd8, 0xff, 0xd9}, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join("..", "private", "secret.jpg"), filepath.Join(sharedDir, "link.jpg")); err != nil {
t.Fatal(err)
}
ctx := context.Background()
req := httptest.NewRequest("GET", "/", nil)
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/"}}
d := &Context{
User: adminUser,
ShareUser: adminUser,
IndexPath: "/shared/",
Share: share.Share{
ShareColumns: share.ShareColumns{Hash: "h", Path: "/shared"},
SourcePath: sourceRoot,
},
FileInfo: iteminfo.ExtendedFileInfo{
Source: "Downloads",
FileInfo: iteminfo.FileInfo{
Path: "/shared/",
ItemInfo: iteminfo.ItemInfo{
Type: "directory",
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "link.jpg", Type: "image/jpeg", HasPreview: true}},
},
},
},
}
idx := indexing.GetIndex("Downloads")
if idx == nil {
t.Fatal("missing index")
}
_, err := getDirectoryPreview(ctx, req, d, 0)
if err == nil {
t.Fatal("expected scope error for share child symlink outside share root")
}
if !errors.Is(err, commonerrors.ErrPathEscapesScope) {
t.Fatalf("error=%v want ErrPathEscapesScope", err)
}
}
+9
View File
@@ -1595,6 +1595,15 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
// Auto-rename if requested
if req.Rename {
realDest = addVersionSuffix(realDest)
if !state.AccessPermittedAtRealPath(dstIdx, d.User.Username, realDest, isSrcDir) {
item.Message = "access denied to destination path"
if d.Share.Hash != "" {
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
}
// Validate move/rename operation to prevent circular references
@@ -0,0 +1,60 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
)
func TestResourcePatchHandler_CopyRename_DeniesDeniedVersionSuffix(t *testing.T) {
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
destDir := filepath.Join(sourceRoot, "dest")
if err := os.MkdirAll(destDir, 0o755); err != nil {
t.Fatal(err)
}
src := filepath.Join(sourceRoot, "src.txt")
if err := os.WriteFile(src, []byte("src"), 0o644); err != nil {
t.Fatal(err)
}
existing := filepath.Join(destDir, "report.txt")
if err := os.WriteFile(existing, []byte("existing"), 0o644); err != nil {
t.Fatal(err)
}
denied := filepath.Join(destDir, "report(1).txt")
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/report(1).txt", false), username); err != nil {
t.Fatal(err)
}
user, err := state.GetUserByUsername(username)
if err != nil {
t.Fatal(err)
}
body := `{"action":"copy","rename":true,"items":[{"fromSource":"` + sourceName +
`","fromPath":"/src.txt","toSource":"` + sourceName + `","toPath":"/dest/report.txt"}]}`
req := httptest.NewRequest(http.MethodPatch, "/api/resources", strings.NewReader(body))
rec := httptest.NewRecorder()
status, handlerErr := ResourcePatchHandler(rec, req, &Context{User: &user})
if handlerErr != nil {
t.Fatalf("err=%v", handlerErr)
}
if status != http.StatusInternalServerError && status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, rec.Body.String())
}
var resp MoveCopyResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Failed) != 1 || !strings.Contains(resp.Failed[0].Message, "access denied") {
t.Fatalf("failed=%+v", resp.Failed)
}
if _, err := os.Stat(denied); err == nil {
t.Fatal("denied destination file should not exist")
}
}
+18 -2
View File
@@ -8,6 +8,7 @@ import (
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
@@ -23,8 +24,9 @@ import (
)
var (
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
IsDirCache = cache.NewCache[bool](48*time.Hour, 72*time.Hour)
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
IsDirCache = cache.NewCache[bool](48*time.Hour, 72*time.Hour)
realPathGeneration atomic.Uint64
)
// getDiskUsage returns the actual disk space used by a file in bytes
@@ -1221,6 +1223,7 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
if ok && cached != "" {
return cached, isDir, nil
}
generation := realPathGeneration.Load()
absolutePath, err := filepath.Abs(joinedPath)
if err != nil {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
@@ -1238,6 +1241,10 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
}
RealPathCache.Set(cacheKey, realPath)
IsDirCache.Set(cacheKey+":isdir", isDir)
if realPathGeneration.Load() != generation {
RealPathCache.Delete(cacheKey)
IsDirCache.Delete(cacheKey + ":isdir")
}
return realPath, isDir, nil
}
@@ -1263,10 +1270,19 @@ func realPathCacheIndexAncestors(indexPath string) []string {
return ancestors
}
// ClearRealPathCaches drops every cached symlink resolution. The cache library has no
// prefix delete, so subtree invalidation uses a full clear; entries repopulate lazily.
func ClearRealPathCaches() {
realPathGeneration.Add(1)
RealPathCache.ClearAll()
IsDirCache.ClearAll()
}
// 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) {
realPathGeneration.Add(1)
boundSet := map[string]struct{}{"/": {}, "": {}}
for _, b := range scopeBounds {
b = strings.TrimRight(strings.TrimSpace(b), "/")
@@ -164,6 +164,42 @@ func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
}
}
func TestRealPathCacheGeneration_BumpsOnInvalidateAndClear(t *testing.T) {
idx := symlinkTestIndex(t.TempDir())
gen := realPathGeneration.Load()
idx.InvalidateRealPathCache("/any")
if realPathGeneration.Load() != gen+1 {
t.Fatalf("InvalidateRealPathCache: gen=%d want %d", realPathGeneration.Load(), gen+1)
}
gen = realPathGeneration.Load()
ClearRealPathCaches()
if realPathGeneration.Load() != gen+1 {
t.Fatalf("ClearRealPathCaches: gen=%d want %d", realPathGeneration.Load(), gen+1)
}
}
func TestClearRealPathCaches_ClearsWarmEntries(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.GetRealPathScoped(userScope, "/user1/foo"); err != nil {
t.Fatalf("warm cache: %v", err)
}
joined := filepath.Join(sourceRoot, "user1", "foo")
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", true, userScope, joined)
if _, ok := RealPathCache.Get(key); !ok {
t.Fatalf("expected cache key %q", key)
}
ClearRealPathCaches()
if _, ok := RealPathCache.Get(key); ok {
t.Fatal("ClearRealPathCaches should drop warm entries")
}
}
func TestInvalidateRealPathCache_ClearsGetRealPathEntry(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
+2 -2
View File
@@ -2,7 +2,7 @@
import path from 'node:path';
import { globSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { Translator } from 'deepl-node';
import { DeepLClient } from 'deepl-node';
import fs from 'fs-extra';
// Parse command line arguments
@@ -27,7 +27,7 @@ if (requireApiKey && !DEEPL_API_KEY) {
process.exit(1);
}
const translator = requireApiKey ? new Translator(DEEPL_API_KEY) : null;
const translator = requireApiKey ? new DeepLClient(DEEPL_API_KEY) : null;
const deeplLangMap = {
'zh-cn': 'ZH-HANS',