Compare commits

...
14 Commits
31 changed files with 1702 additions and 113 deletions

No files matched your search

+2 -1
View File
@@ -1,6 +1,6 @@
*.db
*.db.old
*.sqlite
*.sqlite**
*.bak
*.log
*.mjs
@@ -8,6 +8,7 @@ _old
rice-box.go
.idea/
/backend/backend
/backend/internal
/backend/filebrowser
/backend/filebrowser.exe
frontend/tsconfig.tsbuildinfo
+18
View File
@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.5.2-stable
**Security**:
- [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds
- [High] Share create/update could set delete/modify/create flags beyond the owner's permissions; POST /api/share with hash could update another user's share (GHSA-x79q-5hqm-x839) -- thanks @pant0m
- [High] PATCH /api/share could re-point a share outside the owner's scope (GHSA-wfjp-qhvc-69wp) -- thanks @maximeborges
- [High] Public upload ACL check used scope-stripped path, bypassing per-folder DENY rules (GHSA-qv53-4557-m65h) -- thanks @hypnguyen1209
- [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo
- [Moderate] Authenticated upload and pause endpoints checked access rules on scope-relative paths, bypassing per-folder DENY rules (GHSA-cw65-p35p-633w) -- thanks @5ud0er
- [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu)
- [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec
- [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209
## v1.5.5
**BugFixes**:
- fix uncustomized (minimal) API tokens creation needed by webdav clients (#2503)
## v1.5.4
**Notes**:
+23 -7
View File
@@ -143,7 +143,7 @@ func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *
}
// Combine scope + sanitized path
indexPath := utils.JoinPathAsUnix(userScope, safePath)
indexPath := utils.JoinScopedIndexPath(userScope, safePath)
// Layer 1: USER ACCESS CONTROL
// Quick check: Does THIS user have permission?
if !access.Permitted(idx.Path, indexPath, user.Username) {
@@ -173,7 +173,7 @@ func filterFilesByExt(files []iteminfo.ExtendedItemInfo, hideFileExt string) []i
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
items := Items{}
indexPath, _, topLevelErr := CheckPermissions(opts, access, user)
indexPath, userScope, topLevelErr := CheckPermissions(opts, access, user)
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
if topLevelErr != nil && !accessRulesErr {
return items, topLevelErr
@@ -183,14 +183,18 @@ func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.Use
if idx == nil {
return items, fmt.Errorf("could not get index: %v ", opts.Source)
}
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
infoReq := indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
HideFileExt: opts.HideFileExt,
Expand: true,
SkipExtendedAttrs: true,
})
}
if opts.FollowSymlinks {
infoReq.BoundIndexPath = userScope
}
info, err := idx.GetFileInfo(infoReq)
if err != nil {
return items, err // Path excluded by index rules OR doesn't exist
}
@@ -242,14 +246,18 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
}
// Layer 2: INDEX RULES (global)
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
fileInfoReq := indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
HideFileExt: opts.HideFileExt,
Expand: opts.Expand,
SkipExtendedAttrs: opts.SkipExtendedAttrs,
})
}
if opts.FollowSymlinks {
fileInfoReq.BoundIndexPath = userScope
}
info, err := idx.GetFileInfo(fileInfoReq)
if err != nil {
return response, err // Path excluded by index rules OR doesn't exist
}
@@ -266,7 +274,15 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
// Build response
response.FileInfo = *info
response.RealPath = filepath.Join(idx.Path, indexPath)
if opts.FollowSymlinks && info.Type != "directory" {
realPath, _, err := idx.GetRealPathScoped(userScope, indexPath)
if err != nil {
return response, err
}
response.RealPath = realPath
} else {
response.RealPath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
}
response.Source = opts.Source
if shareStore != nil && user.Permissions.Share && opts.ShowSharedAttr {
for i := range response.Files {
@@ -0,0 +1,57 @@
package files
import (
"errors"
"os"
"path/filepath"
"testing"
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
func setupSymlinkDirEscapeFixture(t *testing.T) (sourceRoot, userScope string) {
t.Helper()
root := t.TempDir()
sourceRoot = filepath.Join(root, "srv")
userDir := filepath.Join(sourceRoot, "user1")
if err := os.MkdirAll(userDir, 0o755); err != nil {
t.Fatal(err)
}
otherDir := filepath.Join(sourceRoot, "otheruser")
if err := os.MkdirAll(otherDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(otherDir, "leaked.txt"), []byte("leaked"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(otherDir, filepath.Join(userDir, "escape_dir")); err != nil {
t.Fatal(err)
}
return sourceRoot, "/user1"
}
func TestGetDirItems_RejectsDirectorySymlinkOutsideUserScope(t *testing.T) {
sourceRoot, userScope := setupSymlinkDirEscapeFixture(t)
sourceName := "test_getdiritems_scope"
indexing.SetTestIndex(sourceName, sourceRoot)
t.Cleanup(indexing.ClearTestIndices)
origCheck := CheckPermissionsFunc
t.Cleanup(func() { CheckPermissionsFunc = origCheck })
CheckPermissionsFunc = func(opts utils.FileOptions, _ *access.Storage, _ *users.User) (string, string, error) {
return utils.JoinPathAsUnix(userScope, opts.Path), userScope, nil
}
_, err := GetDirItems(utils.FileOptions{
Source: sourceName,
Path: "/escape_dir",
FollowSymlinks: true,
}, nil, &users.User{})
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
}
+7 -1
View File
@@ -55,7 +55,13 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
claim.Permissions = perms
claim.BelongsTo = user.ID
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
signedClaims := jwt.Claims(claim)
if minimal {
// Sign only standard JWT claims. The full AuthToken struct always JSON-marshals
// a zero Permissions object, which bloats the token past WebDAV client limits.
signedClaims = claim.MinimalAuthToken
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, signedClaims)
tokenString, err := token.SignedString([]byte(settings.Config.Auth.Key))
if err != nil {
return "", users.AuthToken{}, err
+124
View File
@@ -0,0 +1,124 @@
package auth
import (
"bytes"
"encoding/base64"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
const base64URLAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
func decodeJWTBase64URL(s string) ([]byte, error) {
switch len(s) % 4 {
case 2:
s += "=="
case 3:
s += "="
}
return base64.URLEncoding.DecodeString(s)
}
// equivalentSpelling returns a different compact JWT string with the same decoded signature bytes.
func equivalentSpelling(token string) string {
parts := strings.Split(token, ".")
if len(parts) != 3 {
panic("equivalentSpelling: invalid JWT")
}
header, payload, signature := parts[0], parts[1], parts[2]
orig, err := decodeJWTBase64URL(signature)
if err != nil {
panic("equivalentSpelling: decode canonical signature: " + err.Error())
}
last := signature[len(signature)-1]
for _, c := range base64URLAlphabet {
if byte(c) == last {
continue
}
trial := signature[:len(signature)-1] + string(c)
dec, err := decodeJWTBase64URL(trial)
if err != nil || !bytes.Equal(dec, orig) {
continue
}
respelled := header + "." + payload + "." + trial
if respelled != token {
return respelled
}
}
panic("equivalentSpelling: no alternate spelling found")
}
func invalidSignatureSpelling(token string) string {
parts := strings.Split(token, ".")
header, payload, signature := parts[0], parts[1], parts[2]
idx := strings.IndexByte(base64URLAlphabet, signature[len(signature)-1])
replacement := base64URLAlphabet[((idx&0b110000)+16)%64]
return header + "." + payload + "." + signature[:len(signature)-1] + string(replacement)
}
func parseSessionToken(tokenString string) error {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(settings.Config.Auth.Key), nil
}
var tk users.AuthToken
_, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
return err
}
func TestDecodeStrictRejectsEquivalentSpelling(t *testing.T) {
origKey := settings.Config.Auth.Key
origStrict := jwt.DecodeStrict
settings.Config.Auth.Key = "test-signing-key-strict-decode"
jwt.DecodeStrict = true
t.Cleanup(func() {
settings.Config.Auth.Key = origKey
jwt.DecodeStrict = origStrict
})
user := &users.User{ID: 1, Username: "alice"}
canonical, _, err := MakeSignedTokenAPI(user, "WEB_TOKEN_test", time.Hour, user.Permissions, false)
if err != nil {
t.Fatalf("MakeSignedTokenAPI: %v", err)
}
if err := parseSessionToken(canonical); err != nil {
t.Fatalf("canonical token should parse under strict mode: %v", err)
}
respelled := equivalentSpelling(canonical)
if err := parseSessionToken(respelled); err == nil {
t.Fatal("equivalent spelling should be rejected when jwt.DecodeStrict is true")
}
tampered := invalidSignatureSpelling(canonical)
if err := parseSessionToken(tampered); err == nil {
t.Fatal("tampered signature should be rejected")
}
}
func TestEquivalentSpellingParsesWithoutStrictMode(t *testing.T) {
origKey := settings.Config.Auth.Key
origStrict := jwt.DecodeStrict
settings.Config.Auth.Key = "test-signing-key-strict-decode"
jwt.DecodeStrict = false
t.Cleanup(func() {
settings.Config.Auth.Key = origKey
jwt.DecodeStrict = origStrict
})
user := &users.User{ID: 1, Username: "alice"}
canonical, _, err := MakeSignedTokenAPI(user, "WEB_TOKEN_test", time.Hour, user.Permissions, false)
if err != nil {
t.Fatalf("MakeSignedTokenAPI: %v", err)
}
respelled := equivalentSpelling(canonical)
if err := parseSessionToken(respelled); err != nil {
t.Fatalf("equivalent spelling should parse without strict mode (documents pre-fix behavior): %v", err)
}
}
+1
View File
@@ -23,6 +23,7 @@ var (
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
ErrNotViewable = errors.New("directory or item is not viewable")
ErrPathEscapesScope = errors.New("resolved path escapes scope")
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
+63
View File
@@ -0,0 +1,63 @@
package utils
import (
"path"
"path/filepath"
"strings"
)
// CleanIndexPathSegment normalizes a path segment as if it were rooted, so relative
// ".." components cannot escape via path.Clean on an unrooted input.
func CleanIndexPathSegment(segment string) string {
return strings.TrimPrefix(path.Clean("/"+segment), "/")
}
// JoinIndexPathComponents combines index path segments into a single rooted Unix path.
func JoinIndexPathComponents(relativePath ...string) string {
if len(relativePath) == 0 {
return "/"
}
cleaned := CleanIndexPathSegment(relativePath[0])
for _, p := range relativePath[1:] {
segment := CleanIndexPathSegment(p)
if segment == "" {
continue
}
if cleaned == "" {
cleaned = segment
continue
}
cleaned = cleaned + "/" + segment
}
if cleaned == "" {
return "/"
}
return "/" + cleaned
}
// JoinScopedIndexPath joins a user's index scope with a sanitized path segment.
// Unlike filepath.Join, a leading slash on rel does not discard scope (avoids absolute-path scope bypass).
func JoinScopedIndexPath(scope, rel string) string {
rel = CleanIndexPathSegment(rel)
scope = strings.TrimRight(scope, "/")
if rel == "" {
if scope == "" || scope == "/" {
return "/"
}
return scope
}
if scope == "" || scope == "/" {
return "/" + rel
}
return scope + "/" + rel
}
// JoinUnderSourceRoot maps an index path onto a source filesystem root without treating
// indexPath as a host-absolute path (filepath.Join would discard sourceRoot when indexPath starts with "/").
func JoinUnderSourceRoot(sourceRoot, indexPath string) string {
rel := CleanIndexPathSegment(indexPath)
if rel == "" {
return sourceRoot
}
return filepath.Join(sourceRoot, rel)
}
+76
View File
@@ -0,0 +1,76 @@
package utils
import (
"path/filepath"
"testing"
)
func TestJoinScopedIndexPath_absoluteRelDoesNotDropScope(t *testing.T) {
const scope = "/home/alice"
got := JoinScopedIndexPath(scope, "/etc/passwd")
want := "/home/alice/etc/passwd"
if got != want {
t.Fatalf("JoinScopedIndexPath(%q, /etc/passwd) = %q, want %q", scope, got, want)
}
}
func TestJoinScopedIndexPath_traversalResolvedUnderScope(t *testing.T) {
const scope = "/home/alice"
got := JoinScopedIndexPath(scope, "/../../../etc/passwd")
want := "/home/alice/etc/passwd"
if got != want {
t.Fatalf("JoinScopedIndexPath(%q, /../../../etc/passwd) = %q, want %q", scope, got, want)
}
}
func TestJoinScopedIndexPath_unrootedTraversalResolvedUnderScope(t *testing.T) {
const scope = "/home/alice"
got := JoinScopedIndexPath(scope, "../../etc/passwd")
want := "/home/alice/etc/passwd"
if got != want {
t.Fatalf("JoinScopedIndexPath(%q, ../../etc/passwd) = %q, want %q", scope, got, want)
}
}
func TestJoinScopedIndexPath_rootScope(t *testing.T) {
got := JoinScopedIndexPath("/", "projects/acme/file.txt")
if got != "/projects/acme/file.txt" {
t.Fatalf("got %q want /projects/acme/file.txt", got)
}
}
func TestJoinUnderSourceRoot_absoluteIndexPathStaysUnderSource(t *testing.T) {
const sourceRoot = "/srv/mount"
got := JoinUnderSourceRoot(sourceRoot, "/etc/passwd")
want := filepath.Join(sourceRoot, "etc/passwd")
if got != want {
t.Fatalf("JoinUnderSourceRoot = %q, want %q", got, want)
}
}
func TestJoinUnderSourceRoot_relativeIndexPath(t *testing.T) {
const sourceRoot = "/srv/mount"
got := JoinUnderSourceRoot(sourceRoot, "/home/alice/projects/foo")
want := filepath.Join(sourceRoot, "home/alice/projects/foo")
if got != want {
t.Fatalf("JoinUnderSourceRoot = %q, want %q", got, want)
}
}
func TestJoinUnderSourceRoot_unrootedTraversalStaysUnderSource(t *testing.T) {
const sourceRoot = "/srv/mount"
got := JoinUnderSourceRoot(sourceRoot, "../../etc/passwd")
want := filepath.Join(sourceRoot, "etc/passwd")
if got != want {
t.Fatalf("JoinUnderSourceRoot(%q, ../../etc/passwd) = %q, want %q", sourceRoot, got, want)
}
}
func TestJoinScopedIndexPath_authorizationMustUseScopedPath(t *testing.T) {
const userScope = "/home/alice"
requestPath := "/etc/passwd"
scoped := JoinScopedIndexPath(userScope, requestPath)
if scoped == requestPath {
t.Fatalf("access checks must use scoped path %q, not raw request path %q", scoped, requestPath)
}
}
+22
View File
@@ -0,0 +1,22 @@
package share
import "github.com/gtsteffaniak/filebrowser/backend/database/users"
// ClampShareEditable limits client-requested share flags to what the owner is allowed.
func ClampShareEditable(ownerPerms users.Permissions, cs *CommonShare) {
if cs == nil {
return
}
if !ownerPerms.Modify {
cs.AllowModify = false
}
if !ownerPerms.Create {
cs.AllowCreate = false
}
if !ownerPerms.Delete {
cs.AllowDelete = false
}
if !ownerPerms.Download {
cs.DisableDownload = true
}
}
@@ -0,0 +1,39 @@
package share
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
func TestClampShareEditable(t *testing.T) {
t.Parallel()
ownerPerms := users.Permissions{
Modify: false,
Create: true,
Delete: false,
Download: false,
}
cs := &CommonShare{
AllowModify: true,
AllowCreate: true,
AllowDelete: true,
DisableDownload: false,
}
ClampShareEditable(ownerPerms, cs)
if cs.AllowModify {
t.Fatal("expected AllowModify clamped to false")
}
if !cs.AllowCreate {
t.Fatal("expected AllowCreate to remain true")
}
if cs.AllowDelete {
t.Fatal("expected AllowDelete clamped to false")
}
if !cs.DisableDownload {
t.Fatal("expected DisableDownload forced true when owner lacks download")
}
}
+6 -2
View File
@@ -45,8 +45,8 @@ func createApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusBadRequest, fmt.Errorf("api token duration must be valid")
}
// For full tokens (minimal=false), permissions are required in the claim
// For minimal tokens (minimal=true), permissions are not in the token
// For full tokens (minimal=false), permissions are embedded in the JWT claim.
// For minimal tokens (minimal=true), only standard JWT claims are included.
var permissions users.Permissions
if !minimal {
// Parse permissions from the query parameter
@@ -60,6 +60,10 @@ func createApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestCon
Realtime: strings.Contains(permissionsStr, "realtime") && d.user.Permissions.Realtime,
Download: strings.Contains(permissionsStr, "download") && d.user.Permissions.Download,
}
if !permissions.Api && !permissions.Admin && !permissions.Modify && !permissions.Delete &&
!permissions.Create && !permissions.Share && !permissions.Realtime && !permissions.Download {
minimal = true
}
}
// Convert the duration string to an int64
+38 -45
View File
@@ -2,14 +2,15 @@ package http
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -173,13 +174,13 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
// modify all filepaths for user scope
if d.share == nil {
userscope, err = d.user.GetScopeForSourceName(source)
if err != nil {
if err != nil || userscope == "" {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
// Try to get document ID for error logging
idx := indexing.GetIndex(source)
if idx != nil {
tempPath := utils.JoinPathAsUnix(userscope, firstFilePath)
tempPath := utils.JoinScopedIndexPath(userscope, firstFilePath)
if realPath, _, realErr := idx.GetRealPath(tempPath); realErr == nil {
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
@@ -190,10 +191,10 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
}
}
return http.StatusForbidden, err
return http.StatusForbidden, fmt.Errorf("user has no access to source: %s", source)
}
for i, filePath := range fileList {
fileList[i] = utils.JoinPathAsUnix(userscope, filePath)
fileList[i] = utils.JoinScopedIndexPath(userscope, filePath)
}
}
firstFilePath = fileList[0]
@@ -207,22 +208,41 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
realPath, isDir, err := idx.GetRealPath(firstFilePath)
if err != nil {
// Send OnlyOffice error log if this was an OnlyOffice file
if isOnlyOffice {
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not resolve path: %s - %v", firstFilePath, err))
}
}
var bound string
if d.share != nil {
if d.share.Path == "" {
return http.StatusForbidden, fmt.Errorf("share has no path scope")
}
return http.StatusInternalServerError, err
bound = d.share.Path
} else {
bound = userscope
}
// ** Single file download with Content-Length **
if len(fileList) == 1 && !isDir {
if len(fileList) == 1 {
fd, fileInfo, openErr := idx.OpenScopedPath(bound, firstFilePath)
if openErr != nil {
if errors.Is(openErr, liberrors.ErrPathEscapesScope) {
return http.StatusForbidden, openErr
}
if isOnlyOffice {
if docId, _ := getOnlyOfficeId(filepath.Join(idx.Path, firstFilePath)); docId != "" {
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not open file: %s - %v", firstFilePath, openErr))
}
}
}
return http.StatusInternalServerError, openErr
}
defer fd.Close()
realPath := fd.Name()
if fileInfo.IsDir() {
return BuildAndStreamArchive(w, r, d, source, fileList)
}
// Get document ID and log context for OnlyOffice downloads
if isOnlyOffice {
documentId, _ = getOnlyOfficeId(realPath)
@@ -231,11 +251,10 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
}
// Verify access control before opening the file (direct rule check)
// Verify access control before serving the file (direct rule check)
if d.share == nil && store.Access != nil {
if !store.Access.Permitted(idx.Path, firstFilePath, d.user.Username) {
logger.Debugf("user %s denied access to path %s", d.user.Username, firstFilePath)
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - access denied by rule: %s", firstFilePath))
@@ -244,28 +263,6 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
}
fd, err2 := os.Open(realPath)
if err2 != nil {
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not open file: %s - %v", firstFilePath, err2))
}
return http.StatusInternalServerError, err2
}
defer fd.Close()
// Get file size
fileInfo, err2 := fd.Stat()
if err2 != nil {
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - could not get file info: %s - %v", firstFilePath, err2))
}
return http.StatusInternalServerError, err2
}
// Send success log for OnlyOffice downloads
if isOnlyOffice && logContext != nil {
logger.Infof("OnlyOffice Server is downloading file: %s (documentId: %s)",
@@ -279,13 +276,9 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
setContentDisposition(w, r, fileName)
w.Header().Set("Cache-Control", "private")
w.Header().Set("X-Content-Type-Options", "nosniff")
// video scrubbing, etc.
// Note: http.ServeContent will respect our already-set Content-Disposition header
var reader io.ReadSeeker = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
+16 -3
View File
@@ -201,7 +201,10 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
data.token = tokenString
var tk users.AuthToken
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
// Validate signature without enforcing exp/iat so expired session JWTs can still yield
// user context on public-share routes; revocation is checked explicitly below.
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
token, err := parser.ParseWithClaims(tokenString, &tk, keyFunc)
if err != nil {
return nil
}
@@ -210,8 +213,18 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
return nil
}
// Token is valid (but might be expired or revoked)
// Try to get the user regardless of expiration status
if auth.IsRevokedApiToken(store.Access, tokenString) {
return nil
}
if tk.BelongsTo == 0 {
userID, found := store.Access.GetUserIDFromToken(tokenString)
if !found {
return nil
}
tk.BelongsTo = userID
}
user, err := store.Users.Get(tk.BelongsTo)
if err != nil {
logger.Errorf("Failed to get user with ID %v: %v", tk.BelongsTo, err)
+171 -3
View File
@@ -46,6 +46,18 @@ func setupTestEnv(t *testing.T) {
mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test
}
func saveTestUser(t *testing.T, user *users.User) *users.User {
t.Helper()
if err := store.Users.Save(user, false, false); err != nil {
t.Fatalf("Save(%s): %v", user.Username, err)
}
got, err := store.Users.Get(user.ID)
if err != nil {
t.Fatal(err)
}
return got
}
func mockFileInfoFaster(t *testing.T) {
// Backup the original function
originalFileInfoFaster := FileInfoFasterFunc
@@ -80,10 +92,10 @@ func TestWithAdminHelper(t *testing.T) {
Permissions: users.Permissions{Admin: false}, // Non-admin user
}
// Save the users to the mock database
if err := store.Users.Save(adminUser, true, true); err != nil {
if err := store.Users.Save(adminUser, false, false); err != nil {
t.Fatal("failed to save admin user:", err)
}
if err := store.Users.Save(nonAdminUser, true, true); err != nil {
if err := store.Users.Save(nonAdminUser, false, false); err != nil {
t.Fatal("failed to save non-admin user:", err)
}
// Test cases for different scenarios
@@ -144,6 +156,162 @@ func TestWithAdminHelper(t *testing.T) {
}
}
func TestPublicShare_RejectsRevokedJWT(t *testing.T) {
setupTestEnv(t)
victim := &users.User{
Username: "victim",
Permissions: users.Permissions{Api: true},
Scopes: []users.SourceScope{
{Name: "srv", Scope: "/"},
},
}
if err := store.Users.Save(victim, false, false); err != nil {
t.Fatal("failed to create victim user:", err)
}
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
if err := store.Settings.Save(&settings.Settings{Auth: settings.Auth{Key: "key"}}); err != nil {
t.Fatal(err)
}
tokenString, _, err := auth.MakeSignedTokenAPI(victim, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, victim.Permissions, false)
if err != nil {
t.Fatalf("failed to issue token: %v", err)
}
shareLink := &share.Link{
Hash: "revoked_jwt_hash",
UserID: victim.ID,
CommonShare: share.CommonShare{
Source: "/srv",
Path: "/",
AllowedUsernames: []string{"victim"},
},
}
if err := store.Share.Save(shareLink); err != nil {
t.Fatal("failed to create share:", err)
}
handler := withHashFile(publicGetResourceHandler)
makeRequest := func(jwt string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=revoked_jwt_hash&path=/", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: jwt,
})
handler.ServeHTTP(recorder, req)
return recorder
}
rec := makeRequest(tokenString)
if rec.Code != http.StatusOK {
t.Fatalf("valid token: expected status %d, got %d", http.StatusOK, rec.Code)
}
if err := auth.RevokeApiToken(store.Access, tokenString); err != nil {
t.Fatalf("failed to revoke token: %v", err)
}
rec = makeRequest(tokenString)
if rec.Code == http.StatusOK {
t.Fatalf("revoked token: expected non-200 status, got %d (JWT resurrection bypass)", rec.Code)
}
}
func TestExtractUserFromExpiredToken_RejectsRevokedJWT(t *testing.T) {
_, tokenString := issueExtractUserTestToken(t, time.Hour*2)
if err := auth.RevokeApiToken(store.Access, tokenString); err != nil {
t.Fatalf("failed to revoke token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
})
data := &requestContext{}
if got := extractUserFromExpiredToken(req, data); got != nil {
t.Fatalf("extractUserFromExpiredToken() = user %q, want nil for revoked token", got.Username)
}
}
func TestExtractUserFromExpiredToken_AcceptsExpiredNonRevoked(t *testing.T) {
user, tokenString := issueExtractUserTestToken(t, -time.Hour)
req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
})
data := &requestContext{}
got := extractUserFromExpiredToken(req, data)
if got == nil {
t.Fatal("extractUserFromExpiredToken() = nil, want user for expired non-revoked token")
}
if got.Username != user.Username {
t.Fatalf("extractUserFromExpiredToken() username = %q, want %q", got.Username, user.Username)
}
}
func TestExtractUserFromExpiredToken_RejectsRevokedExpired(t *testing.T) {
_, tokenString := issueExtractUserTestToken(t, -time.Hour)
if err := auth.RevokeApiToken(store.Access, tokenString); err != nil {
t.Fatalf("failed to revoke token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
})
data := &requestContext{}
if got := extractUserFromExpiredToken(req, data); got != nil {
t.Fatalf("extractUserFromExpiredToken() = user %q, want nil for revoked expired token", got.Username)
}
}
func issueExtractUserTestToken(t *testing.T, duration time.Duration) (*users.User, string) {
t.Helper()
setupTestEnv(t)
user := &users.User{
Username: "victim",
Permissions: users.Permissions{Api: true},
}
if err := store.Users.Save(user, false, false); err != nil {
t.Fatal("failed to create user:", err)
}
got, err := store.Users.Get(user.ID)
if err != nil {
t.Fatal("failed to load user:", err)
}
got.Permissions = users.Permissions{Api: true}
if err = store.Users.Save(got, false, false); err != nil {
t.Fatal("failed to set user permissions:", err)
}
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
if err = store.Settings.Save(&settings.Settings{Auth: settings.Auth{Key: "key"}}); err != nil {
t.Fatal(err)
}
tokenString, _, err := auth.MakeSignedTokenAPI(got, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), duration, got.Permissions, false)
if err != nil {
t.Fatalf("failed to issue token: %v", err)
}
return got, tokenString
}
func TestPublicShareHandlerAuthentication(t *testing.T) {
setupTestEnv(t)
@@ -158,7 +326,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
{Name: "srv", Scope: "/"}, // Root scope on srv source
},
}
if err := store.Users.Save(dummyUser, true, true); err != nil {
if err := store.Users.Save(dummyUser, false, false); err != nil {
t.Fatal("failed to save dummy user:", err)
}
+4 -4
View File
@@ -95,7 +95,7 @@ func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestCon
}
// Join the share path with the requested path
filePath := utils.JoinPathAsUnix(d.share.Path, cleanFile)
filePath := utils.JoinScopedIndexPath(d.share.Path, cleanFile)
fileList = append(fileList, filePath)
}
@@ -259,7 +259,7 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, err
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
resolvedPath := utils.JoinScopedIndexPath(d.share.Path, cleanPath)
err = files.WriteFile(sourceName, resolvedPath, r.Body)
// hide the error
if err != nil {
@@ -373,13 +373,13 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusBadRequest, fmt.Errorf("invalid from path: %w", err)
}
req.Items[i].FromSource = sourceName
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, sanitizedPath)
req.Items[i].FromPath = utils.JoinScopedIndexPath(d.share.Path, sanitizedPath)
sanitizedPath, err = utils.SanitizeUserPath(req.Items[i].ToPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid to path: %w", err)
}
req.Items[i].ToSource = sourceName
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, sanitizedPath)
req.Items[i].ToPath = utils.JoinScopedIndexPath(d.share.Path, sanitizedPath)
}
d.Data = req
+16 -16
View File
@@ -333,7 +333,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
continue
}
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
indexPath := utils.JoinPathAsUnix(withoutUserScope, sanitizedPath)
indexPath := utils.JoinScopedIndexPath(withoutUserScope, sanitizedPath)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
@@ -471,13 +471,15 @@ func resourcePauseHandler(w http.ResponseWriter, r *http.Request, d *requestCont
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
if _, err := d.user.GetScopeForSourceName(source); err != nil {
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
fullIndexPath := utils.JoinScopedIndexPath(userscope, path)
if !store.Access.Permitted(idx.Path, fullIndexPath, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", fullIndexPath)
}
pauseCache.Set(pauseUploadCacheKey(source, path), "1")
pauseCache.Set(pauseUploadCacheKey(source, fullIndexPath), "1")
return http.StatusOK, nil
}
@@ -566,14 +568,14 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
userscope = strings.TrimRight(userscope, "/")
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
fullIndexPath := utils.JoinScopedIndexPath(userscope, path)
// get scoped path
realPath, _, _ := idx.GetRealPath(fullIndexPath)
// Check access control for the target path
if !store.Access.Permitted(idx.Path, path, filePermUser.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
if !store.Access.Permitted(idx.Path, fullIndexPath, filePermUser.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", fullIndexPath)
}
// Check for file/folder conflicts before creation
@@ -690,7 +692,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
publicPauseCache.Delete(k)
}
} else {
k := pauseUploadCacheKey(source, path)
k := pauseUploadCacheKey(source, fullIndexPath)
if _, ok := pauseCache.Get(k); ok {
gracefulPause = true
pauseCache.Delete(k)
@@ -765,7 +767,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
fullIndexPath := utils.JoinScopedIndexPath(userScope, path)
// Check access control for the target path
idx := indexing.GetIndex(source)
if idx == nil {
@@ -930,8 +932,8 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
// Build full index paths for access control
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
fullSrcIndexPath := utils.JoinScopedIndexPath(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinScopedIndexPath(userscopeDst, item.ToPath)
if fullDstIndexPath == "/" || fullSrcIndexPath == "/" {
item.Message = "source or destination is the root or unautharized directory"
response.Failed = append(response.Failed, item)
@@ -963,9 +965,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
// Get real paths
// Combine user scope with item paths BEFORE calling GetRealPath to avoid double scope application
fullSrcPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcPath)
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcIndexPath)
if err != nil {
logger.Errorf("could not resolve source path: %v, item.FromPath: %v", err, item.FromPath)
item.Message = "could not resolve source path"
@@ -981,7 +981,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// Check destination parent directory exists
dstParentPath := filepath.Dir(item.ToPath)
fullDstParentPath := utils.JoinPathAsUnix(userscopeDst, dstParentPath)
fullDstParentPath := utils.JoinScopedIndexPath(userscopeDst, dstParentPath)
parentDir, _, err := dstIdx.GetRealPath(fullDstParentPath)
if err != nil {
item.Message = "destination directory does not exist"
+125
View File
@@ -0,0 +1,125 @@
package http
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
func setupResourceACLTestSource(t *testing.T) string {
t.Helper()
srvRoot := t.TempDir()
settings.Config.Server.SourceMap = map[string]*settings.Source{
srvRoot: {Path: srvRoot, Name: "srv"},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"srv": {Path: srvRoot, Name: "srv"},
}
indexing.SetTestIndex("srv", srvRoot)
t.Cleanup(indexing.ClearTestIndices)
return srvRoot
}
func TestResourcePostACLUsesFullIndexPathKey(t *testing.T) {
setupTestEnv(t)
const (
sourcePath = "/srv"
userScope = "/home/alice"
deniedFolder = "/home/alice/projects/acme/private"
relPath = "/projects/acme/private/poison.docx"
)
fullIndexPath := utils.JoinScopedIndexPath(userScope, relPath)
alice := &users.User{
ID: 1,
Username: "alice",
}
if err := store.Users.Save(alice, false, false); err != nil {
t.Fatal(err)
}
if err := store.Access.DenyUser(sourcePath, deniedFolder, "alice"); err != nil {
t.Fatalf("DenyUser: %v", err)
}
if !store.Access.Permitted(sourcePath, relPath, "alice") {
t.Fatal("scope-stripped path does not match deny rule keyed at full index path (documents pre-fix ACL miss)")
}
if store.Access.Permitted(sourcePath, fullIndexPath, "alice") {
t.Fatalf("full index path %q should be denied for alice", fullIndexPath)
}
}
func TestResourcePostHandler_DeniesAuthenticatedUploadToDeniedPath(t *testing.T) {
setupTestEnv(t)
sourcePath := setupResourceACLTestSource(t)
alice := &users.User{
ID: 1,
Username: "alice",
Permissions: users.Permissions{Create: true},
Scopes: []users.SourceScope{
{Name: sourcePath, Scope: "/home/alice"},
},
}
if err := store.Users.Save(alice, false, false); err != nil {
t.Fatal(err)
}
deniedFolder := "/home/alice/projects/acme/private"
if err := store.Access.DenyUser(sourcePath, deniedFolder, "alice"); err != nil {
t.Fatalf("DenyUser: %v", err)
}
req := httptest.NewRequest(
http.MethodPost,
"/api/resources?source=srv&path=/projects/acme/private/evil.txt",
strings.NewReader("payload"),
)
rec := httptest.NewRecorder()
status, err := resourcePostHandler(rec, req, &requestContext{user: alice})
if status != http.StatusForbidden {
t.Fatalf("expected 403 for denied upload path, got status=%d err=%v", status, err)
}
}
func TestResourcePauseHandler_DeniesAuthenticatedPauseOnDeniedPath(t *testing.T) {
setupTestEnv(t)
sourcePath := setupResourceACLTestSource(t)
alice := &users.User{
ID: 1,
Username: "alice",
Permissions: users.Permissions{Create: true},
Scopes: []users.SourceScope{
{Name: sourcePath, Scope: "/home/alice"},
},
}
if err := store.Users.Save(alice, false, false); err != nil {
t.Fatal(err)
}
if err := store.Access.DenyUser(sourcePath, "/home/alice/projects/acme/private", "alice"); err != nil {
t.Fatalf("DenyUser: %v", err)
}
req := httptest.NewRequest(
http.MethodPost,
"/api/resources/pause?source=srv&path=/projects/acme/private/evil.txt",
http.NoBody,
)
rec := httptest.NewRecorder()
status, err := resourcePauseHandler(rec, req, &requestContext{user: alice})
if status != http.StatusForbidden {
t.Fatalf("expected 403 for denied pause path, got status=%d err=%v", status, err)
}
}
+33 -3
View File
@@ -220,8 +220,32 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
if thisShare.UserID != d.user.ID && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
}
// Update the share path
err = store.Share.UpdateSharePath(body.Hash, sanitizedPath)
sourceName := thisShare.GetSourceName()
if sourceName == "" {
return http.StatusBadRequest, fmt.Errorf("source not available for share")
}
shareOwner, err := store.Users.Get(thisShare.UserID)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("share owner not found")
}
userscope, err := shareOwner.GetScopeForSourceName(sourceName)
if err != nil {
return http.StatusForbidden, err
}
storedPath := utils.JoinPathAsUnix(userscope, body.Path)
storedPath = utils.AddTrailingSlashIfNotExists(storedPath)
idx := indexing.GetIndex(sourceName)
if idx == nil {
return http.StatusBadRequest, fmt.Errorf("index not found for source: %s", sourceName)
}
_, _, err = idx.GetRealPath(storedPath)
if err != nil {
return http.StatusForbidden, fmt.Errorf("path not found: %s", body.Path)
}
err = store.Share.UpdateSharePath(body.Hash, storedPath)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -320,6 +344,11 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
stringHash = string(hash)
}
if s != nil {
if !s.UserCanEdit(d.user) {
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
}
share.ClampShareEditable(d.user.Permissions, &body.CommonShare)
// Check if downloads limit or per-user limit changed - reset counts if so
shouldResetCounts := s.DownloadsLimit != body.DownloadsLimit || s.PerUserDownloadLimit != body.PerUserDownloadLimit
@@ -389,7 +418,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, err
}
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
body.Path = utils.JoinScopedIndexPath(userscope, cleanPath)
body.Path = utils.AddTrailingSlashIfNotExists(body.Path)
// validate path exists as file or folder
_, _, err = idx.GetRealPath(body.Path)
@@ -400,6 +429,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if body.ShareType == "upload" && !body.AllowCreate {
body.AllowCreate = true
}
share.ClampShareEditable(d.user.Permissions, &body.CommonShare)
body.Source = source.Path // backend source is path
s = &share.Link{
Expire: expire,
+184
View File
@@ -0,0 +1,184 @@
package http
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
const victimShareHashX79q = "victim_share_x79q_01"
func setupShareAuthUsers(t *testing.T) (owner, attacker, admin *users.User) {
t.Helper()
setupTestEnv(t)
owner = &users.User{
ID: 1,
Username: "share_owner",
Permissions: users.Permissions{Share: true, Delete: true, Create: true, Modify: true},
Scopes: []users.SourceScope{{Name: "/srv", Scope: "/"}},
}
attacker = &users.User{
ID: 2,
Username: "share_attacker",
Permissions: users.Permissions{Share: true, Delete: true, Create: true, Modify: true},
Scopes: []users.SourceScope{{Name: "/srv", Scope: "/"}},
}
noDelete := &users.User{
ID: 3,
Username: "share_no_delete",
Permissions: users.Permissions{Share: true, Delete: false, Create: true, Modify: true},
Scopes: []users.SourceScope{{Name: "/srv", Scope: "/"}},
}
admin = &users.User{
ID: 4,
Username: "share_admin",
Permissions: users.Permissions{Admin: true, Share: true, Delete: true},
Scopes: []users.SourceScope{{Name: "/srv", Scope: "/"}},
}
for _, u := range []*users.User{owner, attacker, noDelete, admin} {
if err := store.Users.Save(u, false, false); err != nil {
t.Fatalf("Save(%s): %v", u.Username, err)
}
}
gotOwner, err := store.Users.Get(owner.ID)
if err != nil {
t.Fatal(err)
}
gotAttacker, err := store.Users.Get(attacker.ID)
if err != nil {
t.Fatal(err)
}
gotAdmin, err := store.Users.Get(admin.ID)
if err != nil {
t.Fatal(err)
}
return gotOwner, gotAttacker, gotAdmin
}
func createVictimShareLink(t *testing.T, ownerID uint) {
t.Helper()
link := &share.Link{
Hash: victimShareHashX79q,
UserID: ownerID,
Version: 1,
CommonShare: share.CommonShare{
Path: "/",
Source: "/srv",
ShareType: "normal",
AllowDelete: false,
},
}
if err := store.Share.Save(link); err != nil {
t.Fatal(err)
}
}
func postShareUpdate(t *testing.T, user *users.User, hash string, allowDelete bool) (int, error) {
t.Helper()
raw, err := json.Marshal(map[string]interface{}{
"hash": hash,
"allowDelete": allowDelete,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/share", bytes.NewReader(raw))
rec := httptest.NewRecorder()
return sharePostHandler(rec, req, &requestContext{user: user})
}
func postShareCreate(t *testing.T, user *users.User, allowDelete bool) (int, error) {
t.Helper()
raw, err := json.Marshal(map[string]interface{}{
"path": "/",
"source": "srv",
"allowDelete": allowDelete,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/share", bytes.NewReader(raw))
rec := httptest.NewRecorder()
return sharePostHandler(rec, req, &requestContext{user: user})
}
func TestSharePostUpdate_RejectNonOwner(t *testing.T) {
owner, attacker, _ := setupShareAuthUsers(t)
createVictimShareLink(t, owner.ID)
status, err := postShareUpdate(t, attacker, victimShareHashX79q, true)
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
got, err := store.Share.GetByHash(victimShareHashX79q)
if err != nil {
t.Fatal(err)
}
if got.AllowDelete {
t.Fatal("non-owner update should not enable allowDelete")
}
if got.UserID != owner.ID {
t.Fatalf("UserID changed to %d, want owner %d", got.UserID, owner.ID)
}
}
func TestSharePostUpdate_AllowsOwner(t *testing.T) {
owner, _, _ := setupShareAuthUsers(t)
createVictimShareLink(t, owner.ID)
status, err := postShareUpdate(t, owner, victimShareHashX79q, true)
if status != http.StatusOK {
t.Fatalf("expected 200, got status=%d err=%v", status, err)
}
got, err := store.Share.GetByHash(victimShareHashX79q)
if err != nil {
t.Fatal(err)
}
if !got.AllowDelete {
t.Fatal("owner update should enable allowDelete")
}
}
func TestSharePostCreate_ClampsAllowDeleteWithoutUserDeletePerm(t *testing.T) {
setupTestEnv(t)
indexing.SetTestIndex("srv", t.TempDir())
t.Cleanup(indexing.ClearTestIndices)
user := &users.User{
ID: 5,
Username: "share_no_delete_create",
Permissions: users.Permissions{Share: true, Delete: false, Create: true},
Scopes: []users.SourceScope{{Name: "/srv", Scope: "/"}},
}
if err := store.Users.Save(user, false, false); err != nil {
t.Fatal(err)
}
got, err := store.Users.Get(user.ID)
if err != nil {
t.Fatal(err)
}
status, handlerErr := postShareCreate(t, got, true)
if status != http.StatusOK {
t.Fatalf("expected 200, got status=%d err=%v", status, handlerErr)
}
links, err := store.Share.GetBySourcePath("/", "/srv")
if err != nil || len(links) == 0 {
t.Fatalf("expected created share, err=%v len=%d", err, len(links))
}
created := links[len(links)-1]
if created.AllowDelete {
t.Fatal("user without delete permission should not persist allowDelete=true")
}
}
+183
View File
@@ -0,0 +1,183 @@
package http
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
func TestSharePatchHandler_AnchorsPathToOwnerScope(t *testing.T) {
setupTestEnv(t)
srvRoot := t.TempDir()
publicDir := filepath.Join(srvRoot, "public")
if err := os.MkdirAll(publicDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(publicDir, "marker.txt"), []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/srv": {Path: "/srv", Name: "srv"},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"srv": {Path: "/srv", Name: "srv"},
}
indexing.SetTestIndex("srv", srvRoot)
t.Cleanup(indexing.ClearTestIndices)
guest := &users.User{
ID: 2,
Username: "guest",
Permissions: users.Permissions{
Share: true,
},
Scopes: []users.SourceScope{
{Name: "/srv", Scope: "/public"},
},
}
if err := store.Users.Save(guest, false, false); err != nil {
t.Fatal(err)
}
initialPath := "/public/"
link := &share.Link{
Hash: "patch_scope_hash",
UserID: guest.ID,
Version: 1,
CommonShare: share.CommonShare{
Path: initialPath,
Source: "/srv",
},
}
if err := store.Share.Save(link); err != nil {
t.Fatal(err)
}
body, err := json.Marshal(map[string]string{
"hash": link.Hash,
"path": "/",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPatch, "/api/share", bytes.NewReader(body))
rec := httptest.NewRecorder()
status, handlerErr := sharePatchHandler(rec, req, &requestContext{user: guest})
if status != http.StatusOK {
t.Fatalf("sharePatchHandler status=%d err=%v", status, handlerErr)
}
updated, err := store.Share.GetByHash(link.Hash)
if err != nil {
t.Fatal(err)
}
if updated.Path == "/" {
t.Fatalf("share path escaped to source root: %q", updated.Path)
}
if updated.Path != initialPath {
t.Fatalf("share path = %q, want %q", updated.Path, initialPath)
}
}
func TestSharePatchHandler_AdminUpdateUsesOwnerScope(t *testing.T) {
setupTestEnv(t)
srvRoot := t.TempDir()
publicDir := filepath.Join(srvRoot, "public")
if err := os.MkdirAll(publicDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(publicDir, "marker.txt"), []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/srv": {Path: "/srv", Name: "srv"},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"srv": {Path: "/srv", Name: "srv"},
}
indexing.SetTestIndex("srv", srvRoot)
t.Cleanup(indexing.ClearTestIndices)
owner := &users.User{
ID: 2,
Username: "guest",
Permissions: users.Permissions{
Share: true,
},
Scopes: []users.SourceScope{
{Name: "/srv", Scope: "/public"},
},
}
if err := store.Users.Save(owner, false, false); err != nil {
t.Fatal(err)
}
admin := &users.User{
ID: 1,
Username: "admin",
Permissions: users.Permissions{
Admin: true,
Share: true,
},
Scopes: []users.SourceScope{
{Name: "/srv", Scope: "/"},
},
}
if err := store.Users.Save(admin, false, false); err != nil {
t.Fatal(err)
}
initialPath := "/public/"
link := &share.Link{
Hash: "patch_admin_scope_hash",
UserID: owner.ID,
Version: 1,
CommonShare: share.CommonShare{
Path: initialPath,
Source: "/srv",
},
}
if err := store.Share.Save(link); err != nil {
t.Fatal(err)
}
body, err := json.Marshal(map[string]string{
"hash": link.Hash,
"path": "/",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPatch, "/api/share", bytes.NewReader(body))
rec := httptest.NewRecorder()
status, handlerErr := sharePatchHandler(rec, req, &requestContext{user: admin})
if status != http.StatusOK {
t.Fatalf("sharePatchHandler status=%d err=%v", status, handlerErr)
}
updated, err := store.Share.GetByHash(link.Hash)
if err != nil {
t.Fatal(err)
}
if updated.Path == "/" {
t.Fatalf("admin update escaped to source root using admin scope: %q", updated.Path)
}
if updated.Path != initialPath {
t.Fatalf("share path = %q, want %q", updated.Path, initialPath)
}
}
+9 -9
View File
@@ -322,13 +322,13 @@ func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
}
// Save users
if err := store.Users.Save(adminUser, true, true); err != nil {
if err := store.Users.Save(adminUser, false, false); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(scopedUser, true, true); err != nil {
if err := store.Users.Save(scopedUser, false, false); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(restrictedUser, true, true); err != nil {
if err := store.Users.Save(restrictedUser, false, false); err != nil {
t.Fatal(err)
}
@@ -464,13 +464,13 @@ func TestWebDAV_WriteOperations(t *testing.T) {
}
// Save users
if err := store.Users.Save(fullAccessUser, true, true); err != nil {
if err := store.Users.Save(fullAccessUser, false, false); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(readOnlyUser, true, true); err != nil {
if err := store.Users.Save(readOnlyUser, false, false); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(scopedUser, true, true); err != nil {
if err := store.Users.Save(scopedUser, false, false); err != nil {
t.Fatal(err)
}
@@ -602,10 +602,10 @@ func TestWebDAV_AccessControl(t *testing.T) {
}
// Save users
if err := store.Users.Save(user1, true, true); err != nil {
if err := store.Users.Save(user1, false, false); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(user2, true, true); err != nil {
if err := store.Users.Save(user2, false, false); err != nil {
t.Fatal(err)
}
@@ -708,7 +708,7 @@ func TestWebDAV_IndexingStates(t *testing.T) {
},
}
if err := store.Users.Save(user, true, true); err != nil {
if err := store.Users.Save(user, false, false); err != nil {
t.Fatal(err)
}
+125 -16
View File
@@ -1,6 +1,7 @@
package indexing
import (
stderrors "errors"
"fmt"
"os"
"path/filepath"
@@ -432,6 +433,7 @@ type PathContext struct {
type FileInfoRequest struct {
IndexPath string
FollowSymlinks bool
BoundIndexPath string // index prefix for symlink containment (e.g. user scope)
ShowHidden bool
HideFileExt string // Hide files based on their extension
Expand bool // get child items for directories
@@ -439,10 +441,61 @@ type FileInfoRequest struct {
SkipExtendedAttrs bool // whether to skip extended attributes
}
// filesystemBound resolves the filesystem path for an index prefix under this source.
func (idx *Index) filesystemBound(boundIndexPath string) (string, error) {
bound := boundIndexPath
if bound == "" {
bound = "/"
}
fsPath := utils.JoinUnderSourceRoot(idx.Path, bound)
abs, err := filepath.Abs(fsPath)
if err != nil {
return "", err
}
return filepath.EvalSymlinks(abs)
}
// ensureResolvedContained rejects resolved filesystem paths outside the source or scope prefix.
func (idx *Index) ensureResolvedContained(realPath, boundIndexPath string) error {
sourceRoot, err := idx.filesystemBound("/")
if err != nil {
return err
}
if err := iteminfo.PathWithinRoot(sourceRoot, realPath); err != nil {
return errors.ErrPathEscapesScope
}
bound := boundIndexPath
if bound == "" {
bound = "/"
}
if bound != "/" {
scopeRoot, err := idx.filesystemBound(bound)
if err != nil {
return err
}
if err := iteminfo.PathWithinRoot(scopeRoot, realPath); err != nil {
return errors.ErrPathEscapesScope
}
}
return nil
}
// resolveSymlinksContained resolves symlinks and ensures the target stays within bounds.
func (idx *Index) resolveSymlinksContained(fsPath, boundIndexPath string) (string, error) {
resolved, err := filepath.EvalSymlinks(fsPath)
if err != nil {
return "", err
}
if err := idx.ensureResolvedContained(resolved, boundIndexPath); err != nil {
return "", err
}
return resolved, nil
}
// resolvePathContext resolves all path characteristics in a SINGLE stat call
// This eliminates duplicate stat/lstat calls and redundant isHidden/isSymlink checks
func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*PathContext, error) {
realPath := filepath.Join(idx.Path, indexPath)
func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool, boundIndexPath string) (*PathContext, error) {
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
// ONE filesystem stat call
fileInfo, err := os.Lstat(realPath)
@@ -454,7 +507,7 @@ func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*Pa
// Resolve symlink if requested
if isSymlink && followSymlinks {
realPath, err = filepath.EvalSymlinks(realPath)
realPath, err = idx.resolveSymlinksContained(realPath, boundIndexPath)
if err != nil {
return nil, err
}
@@ -510,7 +563,7 @@ func (idx *Index) evaluateIndexRules(ctx *PathContext, isRoutineScan bool) (isVi
// User permission checking happens in the API layer (FileInfoFaster)
func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
// 1. Resolve path context (single stat call)
ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks)
ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks, req.BoundIndexPath)
if err != nil {
return nil, err
}
@@ -598,7 +651,7 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
if !strings.HasSuffix(indexPath, "/") {
indexPath = indexPath + "/"
}
realPath := filepath.Join(idx.Path, indexPath)
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
// Open the directory
dir, err := os.Open(realPath)
@@ -636,10 +689,10 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
// GetFsInfoCore is the consolidated implementation for both GetFsInfo and GetFsInfoViewableOnly
func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileInfo, error) {
// Handle symlinks if not following them
realPath := filepath.Join(idx.Path, indexPath)
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
if opts.FollowSymlinks {
var err error
realPath, err = filepath.EvalSymlinks(realPath)
realPath, err = idx.resolveSymlinksContained(realPath, "/")
if err != nil {
return nil, err
}
@@ -995,10 +1048,24 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, indexPath strin
}
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
combined := append([]string{idx.Path}, relativePath...)
joinedPath := filepath.Join(combined...)
isDir, _ := IsDirCache.Get(joinedPath + ":isdir")
cached, ok := RealPathCache.Get(joinedPath)
return idx.getRealPathInternal("/", false, relativePath...)
}
// GetRealPathScoped resolves an index path and rejects symlink targets outside the source and bound prefix.
func (idx *Index) GetRealPathScoped(boundIndexPath string, relativePath ...string) (string, bool, error) {
return idx.getRealPathInternal(boundIndexPath, true, relativePath...)
}
func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool, relativePath ...string) (string, bool, error) {
indexPath := utils.JoinIndexPathComponents(relativePath...)
joinedPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
bound := boundIndexPath
if enforceScope && bound == "" {
bound = "/"
}
cacheKey := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", enforceScope, bound, joinedPath)
isDir, _ := IsDirCache.Get(cacheKey + ":isdir")
cached, ok := RealPathCache.Get(cacheKey)
if ok && cached != "" {
return cached, isDir, nil
}
@@ -1007,11 +1074,53 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
}
realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath)
if err == nil {
RealPathCache.Set(joinedPath, realPath)
IsDirCache.Set(joinedPath+":isdir", isDir)
if err != nil {
return realPath, isDir, err
}
return realPath, isDir, err
if enforceScope {
if err := idx.ensureResolvedContained(realPath, boundIndexPath); err != nil {
return "", false, err
}
} else if err := idx.ensureResolvedContained(realPath, "/"); err != nil {
return "", false, err
}
RealPathCache.Set(cacheKey, realPath)
IsDirCache.Set(cacheKey+":isdir", isDir)
return realPath, isDir, nil
}
func scopedIndexRel(boundIndexPath, indexPath string) string {
bound := boundIndexPath
if bound == "" {
bound = "/"
}
rel := indexPath
if bound != "/" {
rel = strings.TrimPrefix(indexPath, bound)
}
rel = strings.TrimPrefix(rel, "/")
return filepath.FromSlash(rel)
}
// OpenScopedPath opens indexPath relative to boundIndexPath using traversal-resistant APIs.
func (idx *Index) OpenScopedPath(boundIndexPath, indexPath string) (*os.File, os.FileInfo, error) {
scopeRoot, err := idx.filesystemBound(boundIndexPath)
if err != nil {
return nil, nil, err
}
file, err := iteminfo.OpenContained(scopeRoot, scopedIndexRel(boundIndexPath, indexPath))
if err != nil {
if _, _, scopeErr := idx.GetRealPathScoped(boundIndexPath, indexPath); stderrors.Is(scopeErr, errors.ErrPathEscapesScope) {
return nil, nil, scopeErr
}
return nil, nil, err
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, nil, err
}
return file, info, nil
}
func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
@@ -1038,7 +1147,7 @@ func (idx *Index) RefreshDirectory(indexPath string, recursive bool) error {
indexPath = indexPath + "/"
}
realPath := filepath.Join(idx.Path, indexPath)
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
// Check if directory exists
dirInfo, err := os.Stat(realPath)
+45
View File
@@ -105,6 +105,51 @@ func (info *FileInfo) SortItems() {
})
}
// PathWithinRoot reports whether path is equal to or under root after resolving symlinks.
// It is intended for validation only; use OpenContained for atomic file opens within a root.
func PathWithinRoot(root, path string) error {
rootAbs, err := filepath.Abs(root)
if err != nil {
return fmt.Errorf("invalid root path: %w", err)
}
rootReal, err := filepath.EvalSymlinks(rootAbs)
if err != nil {
return fmt.Errorf("could not resolve root path: %w", err)
}
pathAbs, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
pathReal, err := filepath.EvalSymlinks(pathAbs)
if err != nil {
return fmt.Errorf("could not resolve path: %w", err)
}
rel, err := filepath.Rel(rootReal, pathReal)
if err != nil {
return fmt.Errorf("path outside root: %w", err)
}
sep := string(filepath.Separator)
if rel == ".." || strings.HasPrefix(rel, ".."+sep) {
return fmt.Errorf("path escapes root")
}
return nil
}
// OpenContained opens relPath relative to rootDir without following symlinks that escape rootDir.
// Prefer this over PathWithinRoot followed by os.Open to avoid TOCTOU races.
func OpenContained(rootDir, relPath string) (*os.File, error) {
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(relPath, "/")))
if rel == "." || rel == "" || strings.HasPrefix(rel, "..") {
return nil, fmt.Errorf("path escapes root")
}
root, err := os.OpenRoot(rootDir)
if err != nil {
return nil, err
}
defer root.Close()
return root.Open(rel)
}
// ResolveSymlinks resolves symlinks in the given path and returns
// Uses Go's filepath.EvalSymlinks which properly detects circular symlinks.
func ResolveSymlinks(path string) (string, bool, error) {
@@ -0,0 +1,41 @@
package iteminfo
import (
"os"
"path/filepath"
"testing"
)
func TestOpenContained_RejectsSymlinkEscape(t *testing.T) {
root := t.TempDir()
scopeDir := filepath.Join(root, "scope")
if err := os.MkdirAll(scopeDir, 0o755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(root, "outside.txt")
if err := os.WriteFile(outside, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(scopeDir, "escape")); err != nil {
t.Fatal(err)
}
_, err := OpenContained(scopeDir, "escape")
if err == nil {
t.Fatal("expected symlink escape to be rejected")
}
}
func TestOpenContained_OpensInScopeFile(t *testing.T) {
root := t.TempDir()
inside := filepath.Join(root, "allowed.txt")
if err := os.WriteFile(inside, []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
file, err := OpenContained(root, "allowed.txt")
if err != nil {
t.Fatalf("expected in-scope open to succeed: %v", err)
}
file.Close()
}
@@ -0,0 +1,226 @@
package indexing
import (
"errors"
"os"
"path/filepath"
"testing"
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func setupSymlinkEscapeFixture(t *testing.T) (sourceRoot, userScope string) {
t.Helper()
root := t.TempDir()
sourceRoot = filepath.Join(root, "srv")
userDir := filepath.Join(sourceRoot, "user1")
if err := os.MkdirAll(userDir, 0o755); err != nil {
t.Fatal(err)
}
outsideSecret := filepath.Join(root, "secret-outside.txt")
if err := os.WriteFile(outsideSecret, []byte("ROOT_DB_PASSWORD=supersecret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideSecret, filepath.Join(userDir, "secret_link")); err != nil {
t.Fatal(err)
}
sibling := filepath.Join(root, "outside-sibling.txt")
if err := os.WriteFile(sibling, []byte("outside sibling"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(sibling, filepath.Join(userDir, "sibling_link")); err != nil {
t.Fatal(err)
}
inside := filepath.Join(userDir, "allowed.txt")
if err := os.WriteFile(inside, []byte("allowed"), 0o644); err != nil {
t.Fatal(err)
}
return sourceRoot, "/user1"
}
func symlinkTestIndex(sourceRoot string) *Index {
return &Index{Source: settings.Source{Path: sourceRoot}}
}
func TestPathWithinRoot(t *testing.T) {
root := t.TempDir()
inside := filepath.Join(root, "inner", "file.txt")
if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(inside, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
outside := filepath.Join(filepath.Dir(root), "outside.txt")
if err := os.WriteFile(outside, []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
if err := iteminfo.PathWithinRoot(root, inside); err != nil {
t.Fatalf("expected inside path to be allowed: %v", err)
}
if err := iteminfo.PathWithinRoot(root, outside); err == nil {
t.Fatal("expected outside path to be rejected")
}
}
func TestGetRealPath_RejectsEscapeOutsideSource(t *testing.T) {
sourceRoot, _ := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
_, _, err := idx.GetRealPath("/user1/secret_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
_, _, err = idx.GetRealPath("/user1/sibling_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope for sibling link, got %v", err)
}
}
func TestGetRealPathScoped_RejectsEscapeOutsideUserScope(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
insideSource := filepath.Join(sourceRoot, "secret-in-source.txt")
if err := os.WriteFile(insideSource, []byte("in source"), 0o644); err != nil {
t.Fatal(err)
}
userDir := filepath.Join(sourceRoot, "user1")
if err := os.Symlink(insideSource, filepath.Join(userDir, "in_source_link")); err != nil {
t.Fatal(err)
}
_, _, err := idx.GetRealPathScoped(userScope, "/user1/in_source_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope for in-source escape, got %v", err)
}
}
func TestGetRealPathScoped_AllowsInScopeTarget(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
userDir := filepath.Join(sourceRoot, "user1")
if err := os.Symlink("allowed.txt", filepath.Join(userDir, "good_link")); err != nil {
t.Fatal(err)
}
realPath, _, err := idx.GetRealPathScoped(userScope, "/user1/good_link")
if err != nil {
t.Fatalf("expected in-scope symlink to resolve: %v", err)
}
want := filepath.Join(userDir, "allowed.txt")
realPath, err = filepath.EvalSymlinks(realPath)
if err != nil {
t.Fatal(err)
}
want, err = filepath.EvalSymlinks(want)
if err != nil {
t.Fatal(err)
}
if realPath != want {
t.Fatalf("got real path %q, want %q", realPath, want)
}
}
func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
insideSource := filepath.Join(sourceRoot, "secret-in-source.txt")
if err := os.WriteFile(insideSource, []byte("in source"), 0o644); err != nil {
t.Fatal(err)
}
userDir := filepath.Join(sourceRoot, "user1")
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("legitimate"), 0o644); err != nil {
t.Fatal(err)
}
legacyKey := filepath.Join(sourceRoot, "user1", "foo") + ":bound:" + "/user1"
RealPathCache.Set(legacyKey, insideSource)
IsDirCache.Set(legacyKey+":isdir", false)
realPath, _, err := idx.GetRealPathScoped(userScope, "/user1/foo")
if err != nil {
t.Fatalf("scoped in-scope file should resolve: %v", err)
}
want := filepath.Join(userDir, "foo")
want, err = filepath.EvalSymlinks(want)
if err != nil {
t.Fatal(err)
}
realPath, err = filepath.EvalSymlinks(realPath)
if err != nil {
t.Fatal(err)
}
if realPath != want {
t.Fatalf("scoped lookup returned %q, want in-scope file %q", realPath, want)
}
}
func TestGetFileInfo_RejectsSymlinkEscapeWithBound(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
_, err := idx.GetFileInfo(FileInfoRequest{
IndexPath: "/user1/secret_link",
FollowSymlinks: true,
BoundIndexPath: userScope,
})
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope from GetFileInfo, got %v", err)
}
}
func TestGetFileInfo_RejectsDirectorySymlinkOutsideUserScope(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
otherDir := filepath.Join(sourceRoot, "otheruser")
if err := os.MkdirAll(otherDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(otherDir, "leaked.txt"), []byte("leaked"), 0o644); err != nil {
t.Fatal(err)
}
userDir := filepath.Join(sourceRoot, "user1")
if err := os.Symlink(otherDir, filepath.Join(userDir, "escape_dir")); err != nil {
t.Fatal(err)
}
_, err := idx.GetFileInfo(FileInfoRequest{
IndexPath: "/user1/escape_dir/",
FollowSymlinks: true,
BoundIndexPath: userScope,
Expand: true,
})
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope for directory symlink outside scope, got %v", err)
}
}
func TestOpenScopedPath_RejectsEscapeOutsideUserScope(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
_, _, err := idx.OpenScopedPath(userScope, "/user1/secret_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope from OpenScopedPath, got %v", err)
}
}
func TestOpenScopedPath_OpensInScopeFile(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
file, info, err := idx.OpenScopedPath(userScope, "/user1/allowed.txt")
if err != nil {
t.Fatalf("expected in-scope open to succeed: %v", err)
}
defer file.Close()
if info.IsDir() {
t.Fatal("expected file, got directory")
}
}
+2 -2
View File
@@ -4,10 +4,10 @@ package indexing
import (
"os"
"path/filepath"
"strings"
"syscall"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -66,7 +66,7 @@ func (idx *Index) handleFile(file os.FileInfo, indexPath string, realFilePath st
// Use provided realFilePath if available, otherwise construct it
if realFilePath == "" {
realFilePath = filepath.Join(idx.Path, indexPath)
realFilePath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
}
sys := file.Sys()
+2
View File
@@ -1,9 +1,11 @@
package main
import (
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/cmd"
)
func main() {
jwt.DecodeStrict = true
cmd.StartFilebrowser()
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { sanitizeDocxHtml } from "./docxPreview";
describe("sanitizeDocxHtml", () => {
it("strips javascript: hyperlinks", () => {
const input =
'<p><a href="javascript:alert(document.cookie)">CLICK HERE</a></p>';
const output = sanitizeDocxHtml(input);
expect(output).not.toContain("javascript:");
expect(output).toContain("CLICK HERE");
});
it("preserves normal formatting", () => {
const input = "<p>Hello <strong>world</strong></p>";
expect(sanitizeDocxHtml(input)).toBe(input);
});
it("preserves embedded data-uri images from mammoth", () => {
const input = '<p><img src="data:image/png;base64,abc" alt="chart"></p>';
const output = sanitizeDocxHtml(input);
expect(output).toContain('src="data:image/png;base64,abc"');
expect(output).toContain('alt="chart"');
});
it("strips inline event handlers", () => {
const input = '<p onclick="alert(1)">text</p>';
const output = sanitizeDocxHtml(input);
expect(output).not.toContain("onclick");
expect(output).toContain("text");
});
it("preserves https hyperlinks", () => {
const input = '<p><a href="https://example.com/doc">link</a></p>';
expect(sanitizeDocxHtml(input)).toBe(input);
});
});
+6
View File
@@ -0,0 +1,6 @@
import DOMPurify from "dompurify";
/** Sanitize mammoth DOCX HTML before v-html rendering. */
export function sanitizeDocxHtml(html: string): string {
return DOMPurify.sanitize(html);
}
+2 -1
View File
@@ -12,6 +12,7 @@ import * as mammoth from "mammoth";
import { resourcesApi } from "@/api";
import { state, mutations, getters } from "@/store";
import { url } from "@/utils";
import { sanitizeDocxHtml } from "@/utils/docxPreview";
export default defineComponent({
name: "DocxViewer",
@@ -185,7 +186,7 @@ export default defineComponent({
}
const result = await mammoth.convertToHtml({ arrayBuffer });
this.docxHtml = result.value;
this.docxHtml = sanitizeDocxHtml(result.value);
} catch (e) {
this.error = e.message || "An unknown error occurred.";
} finally {