Compare commits

...
12 changed files with 574 additions and 22 deletions

No files matched your search

+13 -1
View File
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. For commit
## v2.1.5
**Security**:
- [High] Server-side archive create (`POST /api/resources/archive`) now resolves the full destination path for writes via symlink-scoped mutation resolution, closing arbitrary host file overwrite through an in-scope symlink leaf (GHSA-jg32-ppcc-ph62) reporter @manus-use
- [Moderate] `GET /api/media/subtitles` no longer follows symlink external subtitle sidecars beside the video file, and public share requests resolve sidecar paths under the share prefix before read (GHSA-hfvf-2639-p2mq) reporter @manus-use
- [Moderate] Directory motion-preview now resolves symlinked children within the caller's scope, closing the incomplete GHSA-g2q5 remediation path through `getDirectoryPreview` (GHSA-2546-vf4j-vh9v) reporter @manus-use
- [Moderate] RealPathCache entries for affected index paths are invalidated after symlink-scoped mutations so stale resolved paths cannot bypass containment on subsequent create, write, delete, or move operations (GHSA-j764-qg3m-rhv7 follow-up, #3381)
**New Features**:
- feat(cache): add server.cacheDirMaxSizeMB to cap the preview cache size (#3314)
- Square image previews: avoid the full-resolution crop copy in imaging.Fill (#3340)
@@ -15,6 +21,12 @@ All notable changes to this project will be documented in this file. For commit
- 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)
- fix(upload): don't pause while the server saves, and make the stall timeout a setting (#3346)
- fix(preview): respect EXIF orientation on embedded JPEG thumbnails (#3358)
- fix(picker): open the parent folder on a single click (#3355)
- fix(users): show password-change requirement in edit preferences (#3354)
- fix(sidebar): keep source selector stationary during horizontal tree scrolling (#3368)
- fix(http): restore Unix socket reverse-proxy behavior after #3309 hardening (#3365)
**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).
@@ -64,7 +76,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 (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
}
+8 -2
View File
@@ -15,12 +15,18 @@ type SubtitleTrack struct {
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
}
// ErrSubtitleSidecarNotRegular is returned when an external subtitle path is not a regular file (e.g. symlink).
var ErrSubtitleSidecarNotRegular = errors.New("subtitle sidecar is not a regular file")
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
// validate the size is not too large
info, err := os.Stat(subtitlePath)
// Sidecars sit beside the media file; do not follow symlink leaves (see lyricsSidecar).
info, err := os.Lstat(subtitlePath)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", ErrSubtitleSidecarNotRegular
}
if info.Size() > 1024*1024*50 { // 50MB
return "", errors.New("subtitle file is too large")
}
+7 -8
View File
@@ -279,19 +279,18 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
if fullDestParent != fullDestFile && !state.AccessPermitted(idxTo.Path, utils.IndexPathFromNormalized(fullDestParent, true), d.User.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination parent of %s", req.Destination)
}
parentReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestParent)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %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)
destFileReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestFile)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
}
if err = os.MkdirAll(filepath.Dir(destFileReal), fileutils.EffectiveDirPerm()); err != nil {
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
}
format := strings.ToLower(strings.TrimSpace(req.Format))
if format == "" {
+21
View File
@@ -3,11 +3,16 @@ package web
import (
"archive/tar"
"archive/zip"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestNormalizeArchiveEntryName(t *testing.T) {
@@ -557,3 +562,19 @@ func TestExtractZip_symlinkDestination(t *testing.T) {
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
}
}
func TestArchiveCreateDestination_RejectsEscapingSymlinkLeaf(t *testing.T) {
t.Parallel()
root := t.TempDir()
outside := filepath.Join(filepath.Dir(root), "outside-archive")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "report.zip")); err != nil {
t.Fatal(err)
}
idx := &indexing.Index{Source: settings.Source{Path: root}}
if _, err := idx.GetRealPathForMutation("", "/report.zip"); !errors.Is(err, commonerrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
}
+53 -1
View File
@@ -2,6 +2,7 @@ package web
import (
"bytes"
"errors"
"fmt"
"net/http"
"path/filepath"
@@ -11,6 +12,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
@@ -106,9 +108,31 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
var content string
if !embedded {
subtitlePath := filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
var subtitlePath string
if d.Share.Hash != "" {
if d.Share.Path == "" {
return http.StatusForbidden, fmt.Errorf("share has no path scope")
}
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
bound, sidecarIndexPath, scopeErr := shareSubtitleSidecarIndexPaths(d, source, path, track.Name)
if scopeErr != nil {
return http.StatusForbidden, scopeErr
}
subtitlePath, _, err = idx.GetRealPathScoped(bound, sidecarIndexPath)
if err != nil {
return realPathErrStatus(err), err
}
} else {
subtitlePath = filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
}
content, err = utils.GetSubtitleSidecarContent(subtitlePath)
if err != nil {
if errors.Is(err, utils.ErrSubtitleSidecarNotRegular) {
return http.StatusForbidden, err
}
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
}
} else {
@@ -143,6 +167,34 @@ func findSubtitleTrack(subtitles []utils.SubtitleTrack, name string, embedded bo
return nil
}
// shareSubtitleSidecarIndexPaths maps middleware IndexPath (user-scope-relative) and Share.Path
// to scoped index paths for sidecar containment (see publicDeleteHandler).
func shareSubtitleSidecarIndexPaths(d *Context, sourceName, videoIndexPath, sidecarFileName string) (bound, sidecarIndexPath string, err error) {
userScope, scopeErr := d.ShareUser.GetScopeForSourceName(sourceName)
if scopeErr != nil || userScope == "" {
return "", "", fmt.Errorf("user has no access to source: %s", sourceName)
}
if d.Share.Path == "" {
return "", "", fmt.Errorf("share has no path scope")
}
shareRel := strings.TrimPrefix(d.Share.Path, userScope)
bound = utils.JoinScopedIndexPath(userScope, shareRel)
videoRel := strings.Trim(strings.TrimSpace(videoIndexPath), "/")
fullVideo := utils.JoinScopedIndexPath(userScope, videoRel)
if strings.TrimSuffix(bound, "/") == strings.TrimSuffix(fullVideo, "/") {
parent := utils.GetParentDirectoryPath(strings.TrimSuffix(bound, "/"))
if parent == "" {
parent = "/"
}
bound = parent
}
sidecarIndexPath = utils.JoinPathAsUnix(
utils.GetParentDirectoryPath(strings.TrimSuffix(fullVideo, "/")),
filepath.Base(sidecarFileName),
)
return bound, sidecarIndexPath, nil
}
// metadataHandler returns the same directory resource shape as GET /api/resources with metadata enabled,
// for client-side patching after a fast listing load.
// @Summary Directory with media metadata
@@ -0,0 +1,285 @@
package web
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"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/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupSubtitlesHTTPTest(t *testing.T) (downloadsPath string, admin *users.User) {
t.Helper()
lockWebGlobals(t)
downloadsPath = filepath.Join(t.TempDir(), "downloads")
dbPath := filepath.Join(t.TempDir(), "subtitles-http-test.sqlite")
if _, err := state.Initialize(dbPath); err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
t.Cleanup(func() {
state.Close()
})
settings.Config.Server.SourceMap = map[string]*settings.Source{
downloadsPath: {
Path: downloadsPath,
Name: "Downloads",
},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"Downloads": settings.Config.Server.SourceMap[downloadsPath],
}
settings.InitializeUserResolvers()
adminUser := &users.User{
ID: 1,
FrontendUser: users.FrontendUser{
Username: "admin",
Permissions: users.Permissions{Admin: true},
},
BackendScopes: []users.BackendScope{
{Path: downloadsPath, Scope: "/"},
},
BackendSourcePermissions: map[string]users.SourceFilePermissions{
downloadsPath: {
View: true,
Download: true,
Modify: true,
Create: true,
Delete: true,
},
},
Version: users.SourcePermissionsMigrationVersion,
}
if err := state.CreateUser(adminUser, ""); err != nil {
t.Fatal(err)
}
adminUser.Permissions = users.Permissions{Admin: true}
if err := state.UpdateUser(adminUser, "", "permissions"); err != nil {
t.Fatal(err)
}
if err := state.UpdateUser(adminUser, "", "backendScopes"); err != nil {
t.Fatal(err)
}
if err := state.UpdateUser(adminUser, "", "backendSourcePermissions"); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(downloadsPath, 0755); err != nil {
t.Fatal(err)
}
indexing.SetTestIndex("Downloads", downloadsPath)
t.Cleanup(func() {
indexing.ClearTestIndices()
})
loaded, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
return downloadsPath, &loaded
}
func mockVideoWithExternalSubtitle(t *testing.T, trackName, videoRealPath string) {
t.Helper()
prev := files.FileInfoFasterFunc
t.Cleanup(func() { files.FileInfoFasterFunc = prev })
files.FileInfoFasterFunc = func(opts utils.FileOptions, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
return &iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
Path: opts.Path,
ItemInfo: iteminfo.ItemInfo{
Type: "video/mp4",
},
},
Subtitles: []utils.SubtitleTrack{
{Name: trackName, Embedded: false},
},
RealPath: videoRealPath,
}, nil
}
}
func TestSubtitlesHandler_RejectsEscapingSidecarSymlink(t *testing.T) {
downloadsPath, adminUser := setupSubtitlesHTTPTest(t)
videoPath := filepath.Join(downloadsPath, "clip.mov")
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
outside := filepath.Join(filepath.Dir(downloadsPath), "outside-secret")
if err := os.MkdirAll(outside, 0755); err != nil {
t.Fatal(err)
}
secret := filepath.Join(outside, "secret.srt")
if err := os.WriteFile(secret, []byte("SECRET_OUTSIDE_SUBTITLE"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(secret, filepath.Join(downloadsPath, "clip.srt")); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/media/subtitles?source=Downloads&path=/clip.mov&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{User: adminUser})
if status != http.StatusForbidden {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "SECRET_OUTSIDE_SUBTITLE") {
t.Fatalf("response leaked outside subtitle content")
}
}
func TestSubtitlesHandler_ReturnsInScopeSidecar(t *testing.T) {
downloadsPath, adminUser := setupSubtitlesHTTPTest(t)
videoPath := filepath.Join(downloadsPath, "clip.mov")
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
srtBody := "1\n00:00:01,000 --> 00:00:02,000\nHello in scope"
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(downloadsPath, "clip.srt"), []byte(srtBody), 0644); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/media/subtitles?source=Downloads&path=/clip.mov&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{User: adminUser})
if status != http.StatusOK || handlerErr != nil {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Hello in scope") {
t.Fatalf("body=%q want in-scope subtitle text", rec.Body.String())
}
}
func TestSubtitlesHandler_ShareScopedOwnerLoadsSidecar(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
aliceRoot := filepath.Join(sourceRoot, "users", "alice", "shared")
if err := os.MkdirAll(aliceRoot, 0755); err != nil {
t.Fatal(err)
}
srtBody := "1\n00:00:01,000 --> 00:00:02,000\nScoped share OK"
videoPath := filepath.Join(aliceRoot, "clip.mov")
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(aliceRoot, "clip.srt"), []byte(srtBody), 0644); err != nil {
t.Fatal(err)
}
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/users/alice"}}
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: "/users/alice/shared",
},
SourcePath: sourceRoot,
},
})
if status != http.StatusOK || handlerErr != nil {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Scoped share OK") {
t.Fatalf("body=%q", rec.Body.String())
}
}
func TestSubtitlesHandler_ShareSingleFileLoadsSidecar(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
srtBody := "single-file share subtitle"
videoPath := filepath.Join(sourceRoot, "clip.mov")
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceRoot, "clip.srt"), []byte(srtBody), 0644); err != nil {
t.Fatal(err)
}
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=/&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{
User: adminUser,
ShareUser: adminUser,
IndexPath: "/clip.mov/",
Share: share.Share{
ShareColumns: share.ShareColumns{
Hash: "h",
Path: "/clip.mov",
},
SourcePath: sourceRoot,
},
})
if status != http.StatusOK || handlerErr != nil {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "single-file share subtitle") {
t.Fatalf("body=%q", rec.Body.String())
}
}
func TestSubtitlesHandler_ShareRejectsSidecarPastEscapingDirSymlink(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
videoIndexPath := "/shared/link/clip.mov/"
mockVideoWithExternalSubtitle(t, "clip.srt", filepath.Join(sourceRoot, "shared", "link", "clip.mov"))
sharedDir := filepath.Join(sourceRoot, "shared")
if err := os.MkdirAll(sharedDir, 0755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(filepath.Dir(sourceRoot), "outside-share")
if err := os.MkdirAll(outside, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "clip.srt"), []byte("SECRET_PAST_DIR_SYMLINK"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(sharedDir, "link")); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=link/clip.mov&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{
User: adminUser,
ShareUser: adminUser,
IndexPath: videoIndexPath,
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_PAST_DIR_SYMLINK") {
t.Fatalf("response leaked subtitle past directory symlink")
}
}
+4 -1
View File
@@ -122,7 +122,10 @@ func withHashFileHelper(fn handleFunc) handleFunc {
pathWithoutUserScope = pathWithoutUserScope + "/"
}
data.IndexPath = pathWithoutUserScope
// skip file fetch for certain apis
// Skip FileInfoFaster prefetch for performance; handlers must apply equivalent
// checks (access rules + GetRealPathScoped with d.Share.Path where bytes are read).
// TODO: align all skipped public-share routes on one bound (Share.Path) or stop
// skipping FileInfoFaster here for media/metadata/items.
if (r.Method == "POST" && strings.Contains(r.URL.Path, "/resources")) ||
(r.Method == "POST" && strings.Contains(r.URL.Path, "/resources/view-token")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
+5 -4
View File
@@ -217,10 +217,11 @@ func getDirectoryPreview(ctx context.Context, r *http.Request, d *Context, frame
}
fileInfo, err := files.FileInfoFaster(
utils.FileOptions{
Path: path,
Source: source,
AlbumArt: true,
Metadata: true,
Path: path,
Source: source,
AlbumArt: true,
Metadata: true,
FollowSymlinks: true,
}, user)
if err != nil {
return nil, err
@@ -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)
}
}
+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)