Compare commits

...
33 Commits
Author SHA1 Message Date
Graham Steffaniak 6412ffcca7 updated docker ffmpeg v1.5.7 (#3069) 2026-10-04 11:16:56 -05:00
Graham SteffaniakandCursor 0ae4035c80 Fix TOTP re-enrollment MFA bypass (GHSA-qx86-4v5r-26g5). (#3068)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 10:51:54 -05:00
Graham Steffaniak 2ce9fb535c updated dependencies v1.5.7-stable (#3066) 2026-10-04 10:04:35 -05:00
Graham Steffaniak 5d9b4df2a2 fix GHSA-55mw-cwg7-m8f5 (#2904) 2026-09-04 16:27:23 -05:00
Graham Steffaniak 443f78ea9a address loading spinner issue (#2903) 2026-09-04 15:31:04 -05:00
Graham Steffaniak e68ef5ca4e backport share save fix (#2902) 2026-09-04 14:22:10 -05:00
Graham Steffaniak f1a7e19fdc fix onlyoffice csp regression 2026-08-30 11:26:16 -05:00
Graham Steffaniak 33a6fd3915 fix: serve subtitle content on public video shares (#2864) 2026-08-29 09:12:19 -05:00
Graham Steffaniak 044d08f2f1 fix: restore upload chunk size 0 to disable chunking (#2734) (#2863) 2026-08-29 09:02:18 -05:00
Graham Steffaniak e071c8df8a updated ffmpeg and dependencies (#2855) 2026-08-28 13:34:13 -05:00
Graham Steffaniak 83393afb31 fix GHSA-vvm6-jwrf-hgmg (#2852) 2026-08-28 13:13:54 -05:00
Graham Steffaniak 47b2c44886 fixed install app bug (#2849) 2026-08-26 15:27:02 -05:00
Graham Steffaniak f7e51f11b8 Merge branch 'staging/v1.5.3' of github.com:gtsteffaniak/filebrowser into staging/v1.5.3 2026-08-21 20:29:22 -05:00
Graham Steffaniak ec4947e634 Merge final v1.5.3 (#2833) 2026-08-21 20:09:39 -05:00
Graham Steffaniak 88ee6424bf Merge branch 'staging/v1.5.3' of github.com:gtsteffaniak/filebrowser into staging/v1.5.3 2026-08-21 20:07:02 -05:00
Graham Steffaniak 67c2908413 fix windows slash regression (#2831) 2026-08-21 20:01:02 -05:00
Graham Steffaniak 78084d5b5a fix windows slash regression 2026-08-21 19:46:05 -05:00
Graham Steffaniak 053571efe2 fix onlyoffice share with passwords (#2830) 2026-08-21 19:18:23 -05:00
Graham Steffaniak ec2b6df30e Fix single file share download (#2829) 2026-08-21 18:57:09 -05:00
Graham Steffaniak 45161017cc Merge branch 'staging/v1.5.2' of github.com:gtsteffaniak/filebrowser into staging/v1.5.2 2026-08-14 19:19:48 -05:00
Graham Steffaniak fcc30ab42d finalize changelog for v1.5.2-stable (#2798) 2026-08-14 19:17:28 -05:00
Graham Steffaniak bbb56b54af updated changelog 2026-08-14 19:07:24 -05:00
Graham Steffaniak 8bc1def989 fix for GHSA-x79q-5hqm-x839 (#2797) 2026-08-14 18:57:06 -05:00
Graham Steffaniak 3e9c4325d5 v1.5.2-stable GHSA-cw65-p35p-633w fix (#2791) 2026-08-14 18:52:24 -05:00
Graham Steffaniak acc3008472 fix GHSA-8m35-wcjh-95q7 (#2792) 2026-08-14 18:51:40 -05:00
Graham Steffaniak 8ed3efbfc9 backport security fix (#2790) 2026-08-14 18:50:13 -05:00
Graham Steffaniak 4655f8985c updated with share bug fix for stable (#2789) 2026-08-14 18:48:13 -05:00
Graham Steffaniak 0f0531cbd7 fix GHSA-rqqq-wv83-rp74 (#2793) 2026-08-14 18:44:51 -05:00
Graham Steffaniak 4126edeb07 fix GHSA-mgqf-5mf5-prfj (#2795) 2026-08-14 18:38:32 -05:00
Graham Steffaniak ca2e1689e0 fix GHSA-9wm6-jcjh-3m8c (#2796) 2026-08-14 18:28:22 -05:00
Graham Steffaniak 6a5ed61751 fix GHSA-4wmj-rq3c-m65v (#2794) 2026-08-14 18:26:36 -05:00
Graham Steffaniak 6a36684892 updated gitignore 2026-08-14 17:54:42 -05:00
Graham Steffaniak 30f21afded fixed webdav api token creation bug (#2740) 2026-08-07 10:40:49 -05:00
72 changed files with 4010 additions and 2327 deletions

No files matched your search

-2
View File
@@ -80,8 +80,6 @@ jobs:
dockerfile: ./_docker/Dockerfile.playwright-no-config
- name: settings
dockerfile: ./_docker/Dockerfile.playwright-settings
- name: screenshots
dockerfile: ./_docker/Dockerfile.playwright-screenshots
- name: previews
dockerfile: ./_docker/Dockerfile.playwright-previews
- name: jwt
-12
View File
@@ -15,18 +15,6 @@ jobs:
go-version: 'stable'
- working-directory: backend
run: go test -race -v ./... -timeout 30s
lint-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- uses: golangci/golangci-lint-action@v7
with:
version: 'v2.1.6'
working-directory: backend
install-mode: goinstall
format-backend:
runs-on: ubuntu-latest
steps:
+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
+63
View File
@@ -2,6 +2,69 @@
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.7
**Security**:
- [High] TOTP re-enrollment via `POST /api/auth/otp/generate` and `POST /api/auth/otp/verify` no longer allows anonymous callers to replace an existing second factor using only the account password; reset or replace requires an authenticated self or admin session (first-time enrollment without MFA unchanged) (GHSA-qx86-4v5r-26g5) -- thanks @tao0845.
**Notes**:
- [docker] upgraded ffmpeg version from 9.0.1 to 9.0.2
## v1.5.6
**Security**:
- [Moderate] public metadata api returns file content to anonymous share visitors, ignoring the share's download limit and file-viewer setting (GHSA-55mw-cwg7-m8f5) -- thanks @kta1kri
**BugFixes**:
- Saving an existing share clears its password, even with no changes made (#2898)
- Fixed infinite loading spinner on `/` and `/login` after CSP security hardening (#2886, #2890)
## v1.5.5
**BugFixes**:
- OnlyOffice editor failed to load after the CSP security fix; allow configured `integrations.office.url` and `integrations.office.internalUrl` origins in `script-src` ([#2874](https://github.com/gtsteffaniak/filebrowser/issues/2874)).
## v1.5.4-stable
**Security**:
- [High] Stored XSS via HTML preview: `<script>` is no longer preserved in srcdoc, the preview iframe is opaque-origin (no `allow-same-origin`), the session cookie is HttpOnly, and the SPA shell sends a `script-src` CSP inherited by srcdoc frames (GHSA-vvm6-jwrf-hgmg) -- thanks @qrn12580
**New Features**:
- Added option to globally disable the "Install App" message via `frontend.disablePWAInstall`
**Notes**:
- [docker] upgraded ffmpeg version 8.1.2 to 9.0.1
**BugFixes**:
- "install app" message reappears after being cleared on device.
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
- Restored upload chunk size `0` to disable chunking as documented ([#2202](https://github.com/gtsteffaniak/filebrowser/issues/2202)); workaround for iOS 26 multi-chunk upload stalls ([#2734](https://github.com/gtsteffaniak/filebrowser/issues/2734)).
## v1.5.3-stable
**BugFixes**:
- windows backslash inserted into directory URLs causing malformed paths and path escapes from parent (#2815) (#2816)
- root download of a shared directory returns HTTP 500 (#2807) (#2810) (#2821) (#2818)
- OnlyOffice is inaccessible if share has optional password (#2811)
## 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**:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:8.1.2-decode AS ffmpeg
FROM ghcr.io/gtsteffaniak/ffmpeg:9.0.2-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
+24 -8
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 {
@@ -366,7 +382,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
}
// Process text content for non-video, non-audio files
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
if opts.Content && info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(info.RealPath)
if err != nil {
logger.Debugf("could not get content for file: "+info.RealPath, info.Name, err)
@@ -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")
+1
View File
@@ -291,6 +291,7 @@ type Frontend struct {
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
LoginButtonText string `json:"loginButtonText"` // text to display on the login button
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
DisablePWAInstall bool `json:"disablePWAInstall"` // disable the PWA install button in the sidebar
}
type StylingConfig struct {
+20
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"golang.org/x/crypto/bcrypt"
)
@@ -72,3 +73,22 @@ func CSPNonce() (string, error) {
}
return base64.StdEncoding.EncodeToString(b), nil
}
// CSPNonceFromData returns an existing cspNonce from map template data, or generates
// and stores one when data is a map[string]interface{}. For other data types it
// generates a fresh nonce without modifying data.
func CSPNonceFromData(data interface{}) (string, error) {
m, ok := data.(map[string]interface{})
if !ok {
return CSPNonce()
}
if nonce, ok := m["cspNonce"].(string); ok && nonce != "" {
return nonce, nil
}
nonce, err := CSPNonce()
if err != nil {
return "", fmt.Errorf("csp nonce: %w", err)
}
m["cspNonce"] = nonce
return nonce, nil
}
+53
View File
@@ -0,0 +1,53 @@
package utils
import "testing"
func TestCSPNonceFromData_ReusesExistingNonce(t *testing.T) {
data := map[string]interface{}{
"cspNonce": "existing-nonce",
}
nonce, err := CSPNonceFromData(data)
if err != nil {
t.Fatalf("CSPNonceFromData: %v", err)
}
if nonce != "existing-nonce" {
t.Fatalf("nonce = %q, want existing-nonce", nonce)
}
}
func TestCSPNonceFromData_GeneratesAndStoresMissingNonce(t *testing.T) {
data := map[string]interface{}{}
nonce, err := CSPNonceFromData(data)
if err != nil {
t.Fatalf("CSPNonceFromData: %v", err)
}
if nonce == "" {
t.Fatal("expected generated nonce")
}
stored, ok := data["cspNonce"].(string)
if !ok || stored != nonce {
t.Fatalf("stored nonce = %q, want %q", stored, nonce)
}
reused, err := CSPNonceFromData(data)
if err != nil {
t.Fatalf("CSPNonceFromData reuse: %v", err)
}
if reused != nonce {
t.Fatalf("reused nonce = %q, want %q", reused, nonce)
}
}
func TestCSPNonceFromData_NonMapGeneratesWithoutMutation(t *testing.T) {
type pageData struct {
Title string
}
data := pageData{Title: "test"}
nonce, err := CSPNonceFromData(data)
if err != nil {
t.Fatalf("CSPNonceFromData: %v", err)
}
if nonce == "" {
t.Fatal("expected generated nonce for non-map data")
}
}
+11 -10
View File
@@ -10,7 +10,7 @@ import (
"hash"
"io"
"os"
"path/filepath"
"path"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
@@ -57,8 +57,8 @@ type FileOptions struct {
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
ShowHidden bool // whether to show hidden files (true = show, false = hide)
ShowPinnedItems bool // whether to show pinned items
ShareHash string // when set, pinned items are loaded from this share link
ShowPinnedItems bool // whether to show pinned items
ShareHash string // when set, pinned items are loaded from this share link
HideFileExt string // Hide files based on extensions (eg: '.lrc' or '.srt')
FollowSymlinks bool // whether to follow symlinks
Only string // whether to only get files or folders
@@ -69,22 +69,23 @@ type FileOptions struct {
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
// Rule 1: Do Not Use User Input in File Paths (without validation)
func SanitizeUserPath(userPath string) (string, error) {
clean := filepath.Clean(userPath)
userPath = strings.ReplaceAll(userPath, `\`, `/`)
clean := path.Clean(userPath)
// Split the path into segments to check for path traversal attempts
// We check if ".." appears as a complete path segment (not just in a filename)
segments := strings.Split(clean, string(filepath.Separator))
// Split the path into segments to check for path traversal attempts.
// We check if ".." appears as a complete path segment (not just in a filename).
segments := strings.Split(clean, "/")
for _, segment := range segments {
// Check if any segment is exactly ".." (path traversal attempt)
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
// Check if any segment is exactly ".." (path traversal attempt).
// This catches any ".." that path.Clean couldn't resolve (i.e., escape attempts).
if segment == ".." {
return "", fmt.Errorf("invalid path: path traversal detected")
}
}
if clean == "." {
return "", fmt.Errorf("invalid path: path must standard index path")
return "", fmt.Errorf("invalid path: path must be a standard index path")
}
return clean, nil
+45
View File
@@ -0,0 +1,45 @@
package utils
import (
"strings"
"testing"
)
func TestSanitizeUserPath_indexPaths(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{"root slash", "/", "/", false},
{"nested", "/foo/bar", "/foo/bar", false},
{"traversal segment", "..", "", true},
{"resolved traversal", "/../secret", "/secret", false},
{"empty", "", "", true},
{"backslash traversal", `..\secret`, "", true},
{"backslash double traversal", `..\..\secret`, "", true},
{"backslash path normalized", `\foo\bar`, "/foo/bar", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SanitizeUserPath(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("SanitizeUserPath(%q) expected error", tt.input)
}
return
}
if err != nil {
t.Fatalf("SanitizeUserPath(%q) unexpected error: %v", tt.input, err)
}
if strings.Contains(got, `\`) {
t.Errorf("SanitizeUserPath(%q) = %q contains backslash", tt.input, got)
}
if got != tt.want {
t.Errorf("SanitizeUserPath(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
+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")
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ type CommonShare struct {
type CreateBody struct {
CommonShare
Hash string `json:"hash,omitempty"`
Password string `json:"password"`
Password *string `json:"password"`
Expires string `json:"expires"`
Unit string `json:"unit"`
}
+131 -124
View File
@@ -1,137 +1,139 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.25.0
go 1.27.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.20.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.28.2
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.4
github.com/go-playground/validator/v10 v10.30.5
github.com/go-webauthn/webauthn v0.18.2
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.49
github.com/mattn/go-sqlite3 v1.14.52
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.54.0
golang.org/x/image v0.44.0
golang.org/x/mod v0.38.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.48.0
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/mod v0.41.0
golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sys v0.48.0
golang.org/x/time v0.16.0
golang.org/x/tools v0.51.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.55.0
modernc.org/sqlite v1.60.1
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
charm.land/lipgloss/v2 v2.0.3 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
codeberg.org/chavacava/garif v0.2.1 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/golib v0.6.0 // indirect
github.com/4meepo/tagalign v1.4.3 // indirect
github.com/Abirdcfly/dupword v0.1.7 // indirect
dev.gaijin.team/go/exhaustruct/v5 v5.2.0 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
github.com/4meepo/tagalign v1.4.4 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
github.com/Antonboom/errname v1.1.2 // indirect
github.com/Antonboom/nilnil v1.1.2 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.65.3 // indirect
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.6 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bep/helpers v0.12.0 // indirect
github.com/bits-and-blooms/bitset v1.25.0 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.8.0 // indirect
github.com/bombsimon/wsl/v5 v5.9.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/butuzov/mirror v1.3.3 // indirect
github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261001125412-878653296cfd // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/daixiang0/gci v0.14.0 // indirect
github.com/dave/dst v0.28.0 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.8.2 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/ebitengine/purego v0.11.1 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/firefart/nonamedreturns v1.0.8 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/ghostiam/protogetter v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-critic/go-critic v0.15.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/spec v0.22.9 // indirect
github.com/go-openapi/swag/conv v0.28.0 // indirect
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
github.com/go-openapi/swag/loading v0.28.0 // indirect
github.com/go-openapi/swag/pools v0.28.0 // indirect
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
github.com/go-openapi/swag/loading v0.29.2 // indirect
github.com/go-openapi/swag/pools v0.29.2 // indirect
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
github.com/go-playground/locales v0.14.2 // indirect
github.com/go-playground/universal-translator v0.18.2 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
@@ -140,25 +142,27 @@ require (
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/godoc-lint/godoc-lint v0.11.4 // indirect
github.com/gofrs/flock v0.13.1 // indirect
github.com/gohugoio/hashstructure v1.1.0 // indirect
github.com/gohugoio/hugo v0.167.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260713102120-857a528af641 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.12.2 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
github.com/golangci/golines v0.16.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect
github.com/golangci/rowserrcheck v0.0.0-20261001155922-f772fe6900b3 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-tpm v0.9.8 // indirect
@@ -171,108 +175,103 @@ require (
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.10.0 // indirect
github.com/jgautheron/goconst v1.11.0 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kisielk/errcheck v1.20.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.8.0 // indirect
github.com/ldez/gomoddirectives v0.10.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.7.2 // indirect
github.com/ldez/tagliatelle v0.8.0 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.6.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mgechev/revive v1.17.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/nishanths/exhaustive v0.14.0 // indirect
github.com/nishanths/predeclared v0.3.0 // indirect
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.72.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/raeperd/recvcheck v0.3.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.26.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/securego/gosec/v2 v2.29.0 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/sourcegraph/go-diff v0.9.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
github.com/timonwong/loggercheck v0.12.1 // indirect
github.com/tinylib/msgp v1.6.5 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.4.2 // indirect
github.com/uudashr/iface v1.5.3 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xo/terminfo v1.2.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
@@ -280,25 +279,25 @@ require (
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.augendre.info/fatcontext v0.10.1 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.74.4 // indirect
honnef.co/go/tools v0.8.1 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
modernc.org/memory v1.12.1 // indirect
mvdan.cc/gofumpt v0.12.0 // indirect
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
tool (
@@ -306,3 +305,11 @@ tool (
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
github.com/swaggo/swag/cmd/swag
)
exclude (
github.com/alecthomas/go-check-sumtype v0.5.0
github.com/gobwas/glob v1.0.0
github.com/gtsteffaniak/go-ffmpeg v1.0.0
github.com/gtsteffaniak/go-ffmpeg v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.2.0
)
+324 -722
View File
File diff suppressed because it is too large. Load diff
+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
+18 -24
View File
@@ -252,22 +252,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
logger.Errorf("Failed to revoke token on logout: %v", err)
}
// Clear the authentication cookie by setting it to expire in the past
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: "",
Domain: strings.Split(host, ":")[0],
Path: "/",
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0), // Expire immediately
MaxAge: -1, // Delete cookie
}
http.SetCookie(w, cookie)
http.SetCookie(w, sessionCookie(r, "", time.Unix(0, 0), -1, http.SameSiteStrictMode))
logoutUrl := fmt.Sprintf("%vlogin", config.Server.BaseURL) // Default fallback
if d.user != nil && d.user.LoginMethod == users.LoginMethodProxy {
@@ -435,20 +420,29 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
return 200, nil
}
// setSessionCookie - sets the authentication token as an HTTP cookie
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
const sessionCookieName = "filebrowser_quantum_jwt"
// sessionCookie builds the JWT cookie. HttpOnly prevents srcdoc XSS from reading
// document.cookie; Secure is set when the request is HTTPS (including via proxy).
func sessionCookie(r *http.Request, token string, expiresTime time.Time, maxAge int, sameSite http.SameSite) *http.Cookie {
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
return &http.Cookie{
Name: sessionCookieName,
Value: token,
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
Domain: strings.Split(host, ":")[0],
Path: "/",
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
SameSite: sameSite,
HttpOnly: true,
Secure: getScheme(r) == "https",
Expires: expiresTime,
MaxAge: maxAge,
}
http.SetCookie(w, cookie)
}
// setSessionCookie - sets the authentication token as an HTTP cookie
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
http.SetCookie(w, sessionCookie(r, token, expiresTime, 0, http.SameSiteStrictMode))
}
+39 -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,42 @@ 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 +252,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 +264,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 +277,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())
}
+1
View File
@@ -191,6 +191,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("GET /media/metadata", withUser(metadataHandler))
api.HandleFunc("GET /media/lyrics", withUser(lyricsHandler))
publicApi.HandleFunc("GET /media/metadata", withHashFile(publicMetadataHandler))
publicApi.HandleFunc("GET /media/subtitles", withHashFile(publicSubtitlesHandler))
publicApi.HandleFunc("GET /media/lyrics", withHashFile(publicLyricsHandler))
// ========================================
+76
View File
@@ -262,3 +262,79 @@ func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
return renderJSON(w, r, map[string]any{"lyrics": lyrics})
}
// publicSubtitlesHandler is the share-link variant of subtitlesHandler.
// @Summary Get subtitle content (public share)
// @Tags Shares
// @Produce text/plain
// @Param hash query string true "Share hash"
// @Param path query string false "Path within the share"
// @Param name query string true "Subtitle track name"
// @Param embedded query bool false "Whether this is an embedded stream"
// @Success 200 {string} string "Raw subtitle content"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Not found"
// @Router /public/api/media/subtitles [get]
func publicSubtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
}
name := r.URL.Query().Get("name")
embedded := r.URL.Query().Get("embedded") == "true"
if name == "" {
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
}
sourceCfg, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: d.IndexPath,
Source: sourceCfg.Name,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
ShowHidden: d.share.ShowHidden,
HideFileExt: d.user.HideFileExt,
FollowSymlinks: false,
SkipExtendedAttrs: false,
}, store.Access, d.shareUser, store.Share)
if err != nil {
return errToStatus(err), err
}
if !strings.HasPrefix(fileInfo.Type, "video") {
return http.StatusNotFound, fmt.Errorf("file is not a video")
}
track := findSubtitleTrack(fileInfo.Subtitles, name, embedded)
if track == nil {
return http.StatusNotFound, fmt.Errorf("subtitle track '%s' not found", name)
}
var content string
if !embedded {
subtitlePath := filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
content, err = utils.GetSubtitleSidecarContent(subtitlePath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
}
} else {
if track.Index == nil {
return http.StatusNotFound, fmt.Errorf("embedded subtitle track '%s' not found", name)
}
content, err = ffmpeg.ExtractSubtitleContent(fileInfo.RealPath, *track.Index)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("Cache-Control", "no-store")
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
return http.StatusOK, nil
}
+18 -6
View File
@@ -8,7 +8,6 @@ import (
"net"
"net/http"
"net/url"
"path/filepath"
"runtime"
"slices"
"strings"
@@ -58,7 +57,6 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if err != nil && inputPath != "" {
return http.StatusBadRequest, err
}
path = filepath.ToSlash(path)
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
@@ -123,7 +121,8 @@ func withHashFileHelper(fn handleFunc) handleFunc {
// skip file fetch for certain apis
if (r.Method == "POST" && strings.Contains(r.URL.Path, "/resources")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) {
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/subtitles")) {
return fn(w, r, data)
}
file, err := FileInfoFasterFunc(utils.FileOptions{
@@ -201,7 +200,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 +212,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)
+219 -3
View File
@@ -1,6 +1,7 @@
package http
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -80,10 +81,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 +145,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 +315,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)
}
@@ -270,6 +427,65 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
}
}
func TestWithHashFileHelper_SkipsPrefetchForMediaSubtitles(t *testing.T) {
setupTestEnv(t)
dummyUser := &users.User{
ID: 1,
Username: "testuser",
Scopes: []users.SourceScope{
{Name: "srv", Scope: "/"},
},
}
if err := store.Users.Save(dummyUser, false, false); err != nil {
t.Fatal("failed to save dummy user:", err)
}
shareLink := &share.Link{
Hash: "subtitle_share_hash",
UserID: 1,
CommonShare: share.CommonShare{
Source: "/srv",
Path: "/",
},
}
if err := store.Share.Save(shareLink); err != nil {
t.Fatal("failed to save share:", err)
}
fileInfoCalled := false
originalFileInfoFaster := FileInfoFasterFunc
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
fileInfoCalled = true
return nil, fmt.Errorf("FileInfoFasterFunc should not be called for /media/subtitles")
}
handlerCalled := false
handler := withHashFileHelper(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
handlerCalled = true
return http.StatusOK, nil
})
if err := store.Settings.Save(&settings.Settings{Auth: settings.Auth{Key: "key"}}); err != nil {
t.Fatal("failed to save settings:", err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=subtitle_share_hash&path=/video.mkv&name=sub.srt", http.NoBody)
status, err := handler(recorder, req, &requestContext{})
if status != http.StatusOK {
t.Fatalf("expected status %d, got %d (err=%v)", http.StatusOK, status, err)
}
if !handlerCalled {
t.Fatal("expected wrapped handler to run")
}
if fileInfoCalled {
t.Fatal("FileInfoFasterFunc should not be called for GET /media/subtitles")
}
}
// Helper function to create a new HTTP request with optional parameters
func newTestRequest(t *testing.T, hash, token, password string, headers map[string]string) *http.Request {
req := newHTTPRequest(t, hash, func(r *http.Request) {
+1 -17
View File
@@ -408,23 +408,7 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
// This allows backend to identify expired sessions and provide better user feedback
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
// Set the authentication token as an HTTP cookie
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
Path: "/",
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
Expires: expiresTime,
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
// Secure: true, // Enable this in production with HTTPS
}
http.SetCookie(w, cookie)
http.SetCookie(w, sessionCookie(r, tokenString, expiresTime, 0, http.SameSiteLaxMode))
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
+24 -9
View File
@@ -205,11 +205,16 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
// Send initial log event with detailed path information
sendOnlyOfficeLogEvent(logContext, "INFO", "config", fmt.Sprintf("OnlyOffice session started for document: %s ", path))
shareToken := ""
if d.share != nil {
shareToken = d.share.Token
}
// Build download URL that OnlyOffice server will use
downloadURL := buildOnlyOfficeDownloadURL(r, source, providedPath, d.fileInfo.Hash, d.token)
downloadURL := buildOnlyOfficeDownloadURL(r, source, providedPath, d.fileInfo.Hash, d.token, shareToken)
// Build callback URL for OnlyOffice to notify us of changes
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.fileInfo.Hash, d.token)
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.fileInfo.Hash, d.token, shareToken)
// Build OnlyOffice client configuration
clientConfig := map[string]interface{}{
@@ -255,7 +260,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
}
// buildOnlyOfficeDownloadURL constructs the download URL that OnlyOffice server will use to fetch the file
func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token string) string {
func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, authToken, shareToken string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
var baseURL string
if config.Server.InternalUrl != "" {
@@ -290,16 +295,23 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
escapedPath := url.QueryEscape(path)
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s&source=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token, url.QueryEscape(source))
strings.TrimSuffix(baseURL, "/"), escapedPath, authToken, url.QueryEscape(source))
if hash != "" {
downloadURL = fmt.Sprintf("%s/public/api/resources/download?file=%s&auth=%s&hash=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token, hash)
params := url.Values{}
params.Set("file", path)
params.Set("auth", authToken)
params.Set("hash", hash)
if shareToken != "" {
params.Set("token", shareToken)
}
downloadURL = fmt.Sprintf("%s/public/api/resources/download?%s",
strings.TrimSuffix(baseURL, "/"), params.Encode())
}
return downloadURL
}
// buildOnlyOfficeCallbackURL constructs the callback URL that OnlyOffice server will use to notify us of changes
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token string) string {
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, authToken, shareToken string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
var baseURL string
if config.Server.InternalUrl != "" {
@@ -338,7 +350,10 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
params := url.Values{}
params.Set("hash", hash)
params.Set("path", path) // This should be the path relative to the share, not the full filesystem path
params.Set("auth", token)
params.Set("auth", authToken)
if shareToken != "" {
params.Set("token", shareToken)
}
callbackURL = fmt.Sprintf("%s/public/api/office/callback?%s",
strings.TrimSuffix(baseURL, "/"), params.Encode())
@@ -347,7 +362,7 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
params := url.Values{}
params.Set("source", source)
params.Set("path", path)
params.Set("auth", token)
params.Set("auth", authToken)
callbackURL = fmt.Sprintf("%s/api/office/callback?%s",
strings.TrimSuffix(baseURL, "/"), params.Encode())
+82
View File
@@ -2,7 +2,9 @@ package http
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
@@ -14,6 +16,86 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func TestBuildOnlyOfficeShareURLs(t *testing.T) {
setupTestEnv(t)
origBaseURL := config.Server.BaseURL
origInternalURL := config.Server.InternalUrl
t.Cleanup(func() {
config.Server.BaseURL = origBaseURL
config.Server.InternalUrl = origInternalURL
})
config.Server.BaseURL = "/files/"
config.Server.InternalUrl = ""
req := httptestNewRequest(t, "http://example.com/files/public/api/office/config?hash=abc")
t.Run("share download URL includes share token", func(t *testing.T) {
got := buildOnlyOfficeDownloadURL(req, "source", "/doc.docx", "shareHash", "jwt-token", "share-download-token")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse download URL: %v", err)
}
q := parsed.Query()
if q.Get("hash") != "shareHash" {
t.Errorf("hash = %q, want shareHash", q.Get("hash"))
}
if q.Get("auth") != "jwt-token" {
t.Errorf("auth = %q, want jwt-token", q.Get("auth"))
}
if q.Get("token") != "share-download-token" {
t.Errorf("token = %q, want share-download-token", q.Get("token"))
}
if q.Get("file") != "/doc.docx" {
t.Errorf("file = %q, want /doc.docx", q.Get("file"))
}
})
t.Run("share download URL omits token when empty", func(t *testing.T) {
got := buildOnlyOfficeDownloadURL(req, "source", "/doc.docx", "shareHash", "jwt-token", "")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse download URL: %v", err)
}
if parsed.Query().Get("token") != "" {
t.Errorf("expected no token param, got %q", parsed.Query().Get("token"))
}
})
t.Run("non-share download URL unchanged", func(t *testing.T) {
got := buildOnlyOfficeDownloadURL(req, "my-source", "/doc.docx", "", "jwt-token", "share-download-token")
if strings.Contains(got, "token=") {
t.Errorf("non-share URL should not include share token: %q", got)
}
if !strings.Contains(got, "source=my-source") {
t.Errorf("expected source in URL, got %q", got)
}
})
t.Run("share callback URL includes share token", func(t *testing.T) {
got := buildOnlyOfficeCallbackURL(req, "source", "/doc.docx", "shareHash", "jwt-token", "share-download-token")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse callback URL: %v", err)
}
q := parsed.Query()
if q.Get("token") != "share-download-token" {
t.Errorf("token = %q, want share-download-token", q.Get("token"))
}
if q.Get("auth") != "jwt-token" {
t.Errorf("auth = %q, want jwt-token", q.Get("auth"))
}
})
}
func httptestNewRequest(t *testing.T, rawURL string) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodGet, rawURL, http.NoBody)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
return req
}
func TestResolveOnlyOfficeDownloadURL(t *testing.T) {
orig := config.Integrations.OnlyOffice
t.Cleanup(func() {
+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)
}
}
+118
View File
@@ -0,0 +1,118 @@
package http
import (
"net/http"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
func TestSessionCookie_HttpOnlyAndSecureOnHTTPS(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Forwarded-Proto", "https")
req.Host = "example.com"
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
if !c.HttpOnly {
t.Fatal("expected HttpOnly session cookie")
}
if !c.Secure {
t.Fatal("expected Secure session cookie over HTTPS")
}
if c.Name != sessionCookieName {
t.Fatalf("cookie name: got %q", c.Name)
}
}
func TestSessionCookie_NotSecureOnHTTP(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "http://localhost/", nil)
if err != nil {
t.Fatal(err)
}
req.Host = "localhost"
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
if !c.HttpOnly {
t.Fatal("expected HttpOnly session cookie")
}
if c.Secure {
t.Fatal("did not expect Secure cookie on plain HTTP")
}
}
func TestSpaContentSecurityPolicy_BlocksUntrustedScripts(t *testing.T) {
prev := settings.Config.Integrations.OnlyOffice
settings.Config.Integrations.OnlyOffice = settings.OnlyOffice{}
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
csp := spaContentSecurityPolicy("testnonce")
if csp != "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com" {
t.Fatalf("unexpected CSP: %s", csp)
}
if strings.Contains(csp, "'unsafe-inline'") {
t.Fatal("script-src must not allow unsafe-inline (srcdoc XSS)")
}
if strings.Contains(csp, "default-src") {
t.Fatal("CSP should only restrict script-src to avoid breaking frames, blobs, and external assets")
}
}
func TestSpaContentSecurityPolicy_IncludesOnlyOfficeOrigins(t *testing.T) {
prev := settings.Config.Integrations.OnlyOffice
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
tests := []struct {
name string
oo settings.OnlyOffice
want string
}{
{
name: "url only",
oo: settings.OnlyOffice{Url: "http://localhost:9052"},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
},
{
name: "internal url only",
oo: settings.OnlyOffice{InternalUrl: "http://onlyoffice-internal:80"},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://onlyoffice-internal:80",
},
{
name: "both urls",
oo: settings.OnlyOffice{
Url: "https://office.example.com",
InternalUrl: "http://onlyoffice-internal",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com https://office.example.com http://onlyoffice-internal",
},
{
name: "duplicate origins",
oo: settings.OnlyOffice{
Url: "http://localhost:9052",
InternalUrl: "http://localhost:9052",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
},
{
name: "invalid urls ignored",
oo: settings.OnlyOffice{
Url: "not-a-url",
InternalUrl: "://bad",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings.Config.Integrations.OnlyOffice = tt.oo
if got := spaContentSecurityPolicy("testnonce"); got != tt.want {
t.Fatalf("unexpected CSP:\ngot: %s\nwant: %s", got, tt.want)
}
})
}
}
+64 -9
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
}
@@ -295,7 +319,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
expire = time.Now().Add(add).Unix()
}
hash, status, err := getSharePasswordHash(body)
hash, status, err := sharePasswordFromRequest(body.Password)
if err != nil {
return status, err
}
@@ -320,12 +344,18 @@ 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
s.Expire = expire
s.PasswordHash = stringHash
s.Token = token
if err = applySharePasswordUpdate(s, body.Password, stringHash, token); err != nil {
return http.StatusInternalServerError, err
}
// Preserve immutable fields for updates. Path and Source should not change on edits.
// If the request attempts to provide empty values (or any values) for these,
// keep the existing ones from the stored share.
@@ -389,7 +419,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 +430,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,
@@ -662,12 +693,12 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return renderJSON(w, r, commonShare)
}
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
if body.Password == "" {
func getSharePasswordHash(plaintextPassword string) (data []byte, statuscode int, err error) {
if plaintextPassword == "" {
return nil, 0, nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), bcrypt.DefaultCost)
if err != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password")
}
@@ -675,6 +706,30 @@ func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, e
return hash, 0, nil
}
// sharePasswordFromRequest hashes a password for new shares. Nil or empty means no password.
func sharePasswordFromRequest(password *string) ([]byte, int, error) {
if password == nil || *password == "" {
return nil, 0, nil
}
return getSharePasswordHash(*password)
}
// applySharePasswordUpdate sets or preserves password credentials on share update.
// Nil password omits the field (keep existing); empty string clears; non-empty replaces.
func applySharePasswordUpdate(link *share.Link, password *string, hashedPassword, token string) error {
if password == nil {
return nil
}
if *password == "" {
link.PasswordHash = ""
link.Token = ""
return nil
}
link.PasswordHash = hashedPassword
link.Token = token
return nil
}
func generateShortUUID() (string, error) {
// Generate 16 random bytes (128 bits of entropy)
bytes := make([]byte, 16)
+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")
}
}
+148
View File
@@ -0,0 +1,148 @@
package http
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
)
func TestApplySharePasswordUpdatePreservesWhenOmitted(t *testing.T) {
link := &share.Link{
PasswordHash: "hashed",
Token: "tok",
}
if err := applySharePasswordUpdate(link, nil, "", ""); err != nil {
t.Fatal(err)
}
if link.PasswordHash != "hashed" || link.Token != "tok" {
t.Fatalf("expected preserved secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
}
}
func TestApplySharePasswordUpdateClearsWhenEmpty(t *testing.T) {
link := &share.Link{
PasswordHash: "hashed",
Token: "tok",
}
empty := ""
if err := applySharePasswordUpdate(link, &empty, "", ""); err != nil {
t.Fatal(err)
}
if link.PasswordHash != "" || link.Token != "" {
t.Fatalf("expected cleared secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
}
}
func TestApplySharePasswordUpdateReplacesWhenProvided(t *testing.T) {
link := &share.Link{
PasswordHash: "old",
Token: "oldtok",
}
next := "new-password"
if err := applySharePasswordUpdate(link, &next, "newhash", "newtok"); err != nil {
t.Fatal(err)
}
if link.PasswordHash != "newhash" || link.Token != "newtok" {
t.Fatalf("expected replaced secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
}
}
func TestSharePostUpdate_PreservesPasswordWhenOmitted(t *testing.T) {
owner, _, _ := setupShareAuthUsers(t)
const hash = "password_preserve_hash"
link := &share.Link{
Hash: hash,
UserID: owner.ID,
Version: 1,
PasswordHash: "existing-hash",
Token: "existing-token",
CommonShare: share.CommonShare{
Path: "/",
Source: "/srv",
ShareType: "normal",
Title: "before",
},
}
if err := store.Share.Save(link); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(map[string]interface{}{
"hash": hash,
"title": "after",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/share", bytes.NewReader(raw))
rec := httptest.NewRecorder()
status, handlerErr := sharePostHandler(rec, req, &requestContext{user: owner})
if status != http.StatusOK {
t.Fatalf("expected 200, got status=%d err=%v", status, handlerErr)
}
got, err := store.Share.GetByHash(hash)
if err != nil {
t.Fatal(err)
}
if got.PasswordHash != "existing-hash" {
t.Fatalf("PasswordHash = %q, want existing-hash", got.PasswordHash)
}
if got.Token != "existing-token" {
t.Fatalf("Token = %q, want existing-token", got.Token)
}
if got.Title != "after" {
t.Fatalf("Title = %q, want after", got.Title)
}
}
func TestSharePostUpdate_ClearsPasswordWhenEmptyString(t *testing.T) {
owner, _, _ := setupShareAuthUsers(t)
const hash = "password_clear_hash"
link := &share.Link{
Hash: hash,
UserID: owner.ID,
Version: 1,
PasswordHash: "existing-hash",
Token: "existing-token",
CommonShare: share.CommonShare{
Path: "/",
Source: "/srv",
ShareType: "normal",
},
}
if err := store.Share.Save(link); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(map[string]interface{}{
"hash": hash,
"password": "",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/share", bytes.NewReader(raw))
rec := httptest.NewRecorder()
status, handlerErr := sharePostHandler(rec, req, &requestContext{user: owner})
if status != http.StatusOK {
t.Fatalf("expected 200, got status=%d err=%v", status, handlerErr)
}
got, err := store.Share.GetByHash(hash)
if err != nil {
t.Fatal(err)
}
if got.PasswordHash != "" || got.Token != "" {
t.Fatalf("expected cleared password, got hash=%q token=%q", got.PasswordHash, got.Token)
}
}
+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)
}
}
+65 -1
View File
@@ -2,6 +2,7 @@ package http
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
@@ -42,7 +43,13 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return fmt.Errorf("error reloading template: %w", err)
}
}
// Set headers
nonce, err := utils.CSPNonceFromData(data)
if err != nil {
return err
}
// Inherited by srcdoc preview frames: nonce'd SPA scripts run; untrusted preview scripts do not.
w.Header().Set("Content-Security-Policy", spaContentSecurityPolicy(nonce))
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("X-Accel-Expires", "0")
@@ -51,6 +58,51 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return templates.ExecuteTemplate(w, name, data)
}
// spaContentSecurityPolicy restricts only scripts. srcdoc preview frames inherit
// this header, blocking inline scripts without limiting frames, images, or API calls.
func spaContentSecurityPolicy(nonce string) string {
policy := fmt.Sprintf(
"script-src 'self' 'nonce-%s' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
nonce,
)
for _, origin := range onlyOfficeScriptSrcOrigins() {
policy += " " + origin
}
return policy
}
func onlyOfficeScriptSrcOrigins() []string {
oo := settings.Config.Integrations.OnlyOffice
candidates := []string{
cspOriginFromURL(oo.Url),
cspOriginFromURL(oo.InternalUrl),
}
var origins []string
seen := make(map[string]struct{}, len(candidates))
for _, origin := range candidates {
if origin == "" {
continue
}
if _, ok := seen[origin]; ok {
continue
}
seen[origin] = struct{}{}
origins = append(origins, origin)
}
return origins
}
func cspOriginFromURL(raw string) string {
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
w.Header().Set("Content-Type", contentType)
userSelectedTheme := ""
@@ -245,8 +297,20 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"loginButtonText": config.Frontend.LoginButtonText,
"passkeyAvailable": config.Auth.Methods.PasskeyAuth.Enabled,
"passkeyLoginButtonText": config.Auth.Methods.PasskeyAuth.LoginButtonText,
"disablePWAInstall": config.Frontend.DisablePWAInstall,
}
cspNonce, err := utils.CSPNonce()
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("csp nonce: %w", err)
}
globalVars, ok := data["globalVars"].(map[string]interface{})
if !ok {
return http.StatusInternalServerError, errors.New("unable to load global variables")
}
globalVars["cspNonce"] = cspNonce
data["cspNonce"] = cspNonce
// Marshal each variable to JSON strings for direct template usage
globalVarsJSON, err := json.Marshal(data["globalVars"])
if err != nil {
+47 -20
View File
@@ -7,6 +7,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
@@ -22,14 +23,17 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/otp/generate [post]
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
err := checkPassword(r, d)
if err != nil {
return http.StatusUnauthorized, err
}
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
targetUsername := r.URL.Query().Get("username")
user, getErr := store.Users.Get(targetUsername)
if getErr != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
}
if status, err := requireOtpEnrollmentAuthorized(user, d); err != nil {
return status, err
}
if err := checkOtpActorPassword(r, d, user.Username); err != nil {
return http.StatusUnauthorized, err
}
url, err := auth.GenerateOtpForUser(user, store.Users)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
@@ -59,16 +63,18 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if code == "" {
return http.StatusUnauthorized, fmt.Errorf("code is required")
}
err := checkPassword(r, d)
if err != nil {
return http.StatusUnauthorized, err
}
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
targetUsername := r.URL.Query().Get("username")
user, getErr := store.Users.Get(targetUsername)
if getErr != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
}
err = auth.VerifyTotpCode(user, code, store.Users)
if err != nil {
if status, err := requireOtpEnrollmentAuthorized(user, d); err != nil {
return status, err
}
if err := checkOtpActorPassword(r, d, user.Username); err != nil {
return http.StatusUnauthorized, err
}
if err := auth.VerifyTotpCode(user, code, store.Users); err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
}
response := HttpResponse{
@@ -79,13 +85,35 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return renderJSON(w, r, response)
}
func checkPassword(r *http.Request, d *requestContext) error {
providedPassword := r.Header.Get("X-Password")
username := r.URL.Query().Get("username")
if d.user.Permissions.Admin {
username = d.user.Username
func userConfiguredMFA(u *users.User) bool {
if u == nil {
return false
}
if u.HasPasskeyMFA() {
return true
}
return u.TOTPSecret != ""
}
func requireOtpEnrollmentAuthorized(target *users.User, d *requestContext) (int, error) {
if !userConfiguredMFA(target) {
return 0, nil
}
if d.user == nil || d.user.Username == "" || d.user.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("authentication required to reset two-factor authentication")
}
if d.user.Permissions.Admin || d.user.Username == target.Username {
return 0, nil
}
return http.StatusForbidden, fmt.Errorf("not authorized to reset two-factor authentication for this user")
}
func checkOtpActorPassword(r *http.Request, d *requestContext, targetUsername string) error {
providedPassword := r.Header.Get("X-Password")
actorUsername := targetUsername
if d.user != nil && d.user.Permissions.Admin && d.user.Username != targetUsername {
actorUsername = d.user.Username
}
// url decode
providedPassword, err := url.QueryUnescape(providedPassword)
if err != nil {
return fmt.Errorf("invalid password encoding: %v", err)
@@ -93,14 +121,13 @@ func checkPassword(r *http.Request, d *requestContext) error {
if providedPassword == "" {
return fmt.Errorf("password is required")
}
user, getErr := store.Users.Get(username)
user, getErr := store.Users.Get(actorUsername)
var passwordHash string
if getErr != nil {
passwordHash = utils.InvalidPasswordHash
} else {
passwordHash = user.Password
}
// always run checkPwd to prevent timing attacks
err = utils.CheckPwd(providedPassword, passwordHash)
if err != nil {
return fmt.Errorf("invalid password or user not found")
+55 -6
View File
@@ -23,7 +23,11 @@ func otpRequest(username, password, code string) *http.Request {
return req
}
func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
func anonymousContext() *requestContext {
return &requestContext{user: &users.User{Username: "anonymous"}}
}
func TestVerifyOTP_CachedSecretTakesPrecedenceWhenAuthenticatedSelf(t *testing.T) {
setupTestEnv(t)
oldSecret := "SOMEOLDSECRET234"
@@ -39,15 +43,19 @@ func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
}
t.Cleanup(func() { auth.TotpCache.Delete(user.Username) })
d := &requestContext{user: user}
reloaded, reloadErr := store.Users.Get(user.Username)
if reloadErr != nil {
t.Fatalf("failed to reload user: %v", reloadErr)
}
d := &requestContext{user: reloaded}
rec := httptest.NewRecorder()
if _, err := generateOTPHandler(rec, otpRequest(user.Username, "testPass", ""), d); err != nil {
t.Fatalf("genration failed: %v", err)
if _, genErr := generateOTPHandler(rec, otpRequest(user.Username, "testPass", ""), d); genErr != nil {
t.Fatalf("generation failed: %v", genErr)
}
var resp map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
t.Fatalf("failed to decode response: %v", unmarshalErr)
}
u, err := url.Parse(resp["url"])
if err != nil {
@@ -83,6 +91,47 @@ func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
}
}
func TestRequireOtpEnrollmentAuthorized(t *testing.T) {
withMFA := &users.User{
Username: "victim",
TOTPSecret: "SOMEOLDSECRET234",
}
status, err := requireOtpEnrollmentAuthorized(withMFA, anonymousContext())
if err == nil {
t.Fatal("expected anonymous MFA reset to be rejected")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got %d err=%v", status, err)
}
noMFA := &users.User{Username: "new"}
if status, err = requireOtpEnrollmentAuthorized(noMFA, anonymousContext()); err != nil {
t.Fatalf("expected first-time enrollment to be allowed: status=%d err=%v", status, err)
}
self := &users.User{Username: "victim"}
if status, err = requireOtpEnrollmentAuthorized(withMFA, &requestContext{user: self}); err != nil {
t.Fatalf("expected self reset to be allowed: status=%d err=%v", status, err)
}
admin := &users.User{
Username: "admin",
Permissions: users.Permissions{Admin: true},
}
if status, err = requireOtpEnrollmentAuthorized(withMFA, &requestContext{user: admin}); err != nil {
t.Fatalf("expected admin reset to be allowed: status=%d err=%v", status, err)
}
other := &users.User{Username: "other"}
status, err = requireOtpEnrollmentAuthorized(withMFA, &requestContext{user: other})
if err == nil {
t.Fatal("expected non-admin other user to be rejected")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got %d err=%v", status, err)
}
}
func TestLogin_EnforcedOtp(t *testing.T) {
setupTestEnv(t)
config.Auth.Key = "test-key"
+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)
}
+108 -16
View File
@@ -432,6 +432,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 +440,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 +506,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 +562,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 +650,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 +688,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 +1047,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 +1073,37 @@ 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
}
// OpenScopedPath opens indexPath relative to boundIndexPath using traversal-resistant APIs.
func (idx *Index) OpenScopedPath(boundIndexPath, indexPath string) (*os.File, os.FileInfo, error) {
realPath, _, err := idx.GetRealPathScoped(boundIndexPath, indexPath)
if err != nil {
return nil, nil, err
}
file, err := os.Open(realPath)
if err != nil {
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 +1130,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()
}
+69
View File
@@ -3777,6 +3777,71 @@ const docTemplate = `{
}
}
},
"/public/api/media/subtitles": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"Shares"
],
"summary": "Get subtitle content (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
},
{
"type": "string",
"description": "Subtitle track name",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream",
"name": "embedded",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content",
"schema": {
"type": "string"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -5761,6 +5826,10 @@ const docTemplate = `{
"description": "disable the nav buttons in the sidebar",
"type": "boolean"
},
"disablePWAInstall": {
"description": "disable the PWA install button in the sidebar",
"type": "boolean"
},
"disableUsedPercentage": {
"description": "disable used percentage for the sources in the sidebar",
"type": "boolean"
+69
View File
@@ -3766,6 +3766,71 @@
}
}
},
"/public/api/media/subtitles": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"Shares"
],
"summary": "Get subtitle content (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
},
{
"type": "string",
"description": "Subtitle track name",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream",
"name": "embedded",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content",
"schema": {
"type": "string"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -5750,6 +5815,10 @@
"description": "disable the nav buttons in the sidebar",
"type": "boolean"
},
"disablePWAInstall": {
"description": "disable the PWA install button in the sidebar",
"type": "boolean"
},
"disableUsedPercentage": {
"description": "disable used percentage for the sources in the sidebar",
"type": "boolean"
+46
View File
@@ -767,6 +767,9 @@ definitions:
disableNavButtons:
description: disable the nav buttons in the sidebar
type: boolean
disablePWAInstall:
description: disable the PWA install button in the sidebar
type: boolean
disableUsedPercentage:
description: disable used percentage for the sources in the sidebar
type: boolean
@@ -5016,6 +5019,49 @@ paths:
summary: Directory with media metadata (public share)
tags:
- Shares
/public/api/media/subtitles:
get:
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share
in: query
name: path
type: string
- description: Subtitle track name
in: query
name: name
required: true
type: string
- description: Whether this is an embedded stream
in: query
name: embedded
type: boolean
produces:
- text/plain
responses:
"200":
description: Raw subtitle content
schema:
type: string
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
summary: Get subtitle content (public share)
tags:
- Shares
/public/api/resources:
get:
consumes:
+691 -1183
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -65,7 +65,7 @@
"vite": "^6.2.0",
"vite-plugin-checker": "^0.10.3",
"vite-plugin-compression2": "^1.0.0",
"vitest": "^4.1.8",
"vitest": "^5.0.0",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^2.0.7"
}
+1
View File
@@ -159,6 +159,7 @@ frontend:
loginIcon: "" # path to an image file for the login page icon
loginButtonText: "" # text to display on the login button
oidcLoginButtonText: "" # text to display on the OIDC login button
disablePWAInstall: false # disable the PWA install button in the sidebar
userDefaults:
sidebar: # New organized structure
disableQuickToggles: false # disable the quick toggles in the sidebar
+3 -3
View File
@@ -49,9 +49,9 @@
<link rel="canonical" href="https://github.com/quantumx-apps">
<link rel="canonical" href="https://filebrowserquantum.com">
<!-- Injected variables come from backend server -->
<script>
window.globalVars = {{ .globalVars }};
<!-- Injected variables come from backend server (JSON block is not subject to script-src CSP) -->
<script id="app-config" type="application/json">{{ .globalVars }}</script>
<script nonce="{{ .cspNonce }}">
window.__pwaDeferredPrompt = null;
window.addEventListener("beforeinstallprompt", function (event) {
event.preventDefault();
+26 -7
View File
@@ -3,16 +3,35 @@ import { state } from "@/store";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { adjustedData, fetchURL } from "./utils";
// GET /api/media/subtitles
// GET /api/media/subtitles or /public/api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const apiPath = getApiPath('media/subtitles', {
source: source,
path: path,
const hash = state.shareInfo?.hash || null
const baseParams = {
path,
name: subtitleName,
embedded: embedded.toString()
})
const res = await fetchURL(apiPath)
...(embedded && { embedded: 'true' }),
}
let res
if (hash) {
const apiPath = getPublicApiPath('media/subtitles', {
hash,
...baseParams,
...(state.shareInfo.token && { token: state.shareInfo.token }),
})
const sharePassword = localStorage.getItem(`sharepass:${hash}`) || ''
res = await fetchURL(apiPath, {
headers: { 'X-SHARE-PASSWORD': sharePassword },
})
} else {
const apiPath = getApiPath('media/subtitles', {
source,
...baseParams,
})
res = await fetchURL(apiPath)
}
const content = await res.text()
return content
} catch (err) {
+12 -2
View File
@@ -1,4 +1,5 @@
import { notify } from '@/notify'
import { state } from '@/store'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { fetchURL } from './utils'
@@ -8,7 +9,16 @@ export async function getConfig(req) {
const params = {
path: req.path,
...(req.hash && { hash: req.hash }),
...(req.source && { source: req.source })
...(req.source && { source: req.source }),
...(req.hash && state.shareInfo?.token && { token: state.shareInfo.token }),
}
const headers = {}
if (req.hash) {
const sharePassword = localStorage.getItem(`sharepass:${req.hash}`)
if (sharePassword) {
headers['X-SHARE-PASSWORD'] = sharePassword
}
}
let apiPath
@@ -18,7 +28,7 @@ export async function getConfig(req) {
apiPath = getApiPath('office/config', params)
}
const res = await fetchURL(apiPath)
const res = await fetchURL(apiPath, { headers })
return await res.json()
} catch (err) {
notify.showError(err.message || 'Error fetching OnlyOffice configuration')
+7 -4
View File
@@ -57,7 +57,7 @@ export default {
resizeStartX: 0,
resizeStartWidth: 0,
previousSidebarSize: null, // Remember the previous width when switching from desktop to mobile.
pwaInstallDismissed: sessionStorage.getItem("pwaInstallDismissed") === "true",
pwaInstallDismissed: localStorage.getItem("pwaInstallDismissed") === "true",
};
},
mounted() {
@@ -121,7 +121,7 @@ export default {
);
},
showPwaInstall() {
return installAvailable.value && !this.pwaInstallDismissed && !this.isSettings;
return installAvailable.value && !this.pwaInstallDismissed && !this.isSettings && !globalVars.disablePWAInstall;
},
},
methods: {
@@ -178,11 +178,14 @@ export default {
mutations.setSeenUpdate(globalVars.updateAvailable);
},
async installPwa() {
await promptInstall();
const accepted = await promptInstall();
if (accepted) {
this.dismissPwaInstall();
}
},
dismissPwaInstall() {
this.pwaInstallDismissed = true;
sessionStorage.setItem("pwaInstallDismissed", "true");
localStorage.setItem("pwaInstallDismissed", "true");
},
},
};
+1 -1
View File
@@ -686,7 +686,7 @@
"maxConcurrentUpload": "Max concurrent uploads",
"maxConcurrentUploadHelp": "The maximum number of concurrent uploads. Higher values typically perform better, but this depends on many factors.",
"uploadChunkSizeMb": "Upload chunk size in MB",
"uploadChunkSizeMbHelp": "Typically 5 to 50 MB, smaller is better for unstable or restricted connections. Setting to 0 will disable upload chunking.",
"uploadChunkSizeMbHelp": "Typically 5 to 50 MB, smaller is better for unstable or restricted connections. Setting to 0 will disable upload chunking. On iOS 26+ devices, if multi-chunk uploads stall after the first chunk, set this to 0 or larger than your largest file.",
"downloadChunkSizeMb": "Download chunk size in MB",
"downloadChunkSizeMbHelp": "Chunk size for chunked downloads. Files larger than this size will be downloaded in chunks to prevent timeout issues (e.g., Cloudflare 524 errors). Setting to 0 disables chunked downloads.",
"clearAll": "Clear completed should remove error, pause, and conflict",
+9 -23
View File
@@ -14,19 +14,11 @@ export async function validateLogin(isPublicRoute = false) {
});
if (res.status !== 200) {
// A 401 from the non-public self check means our session cookie (JWT) is no
// longer valid — typically it expired. Clear it and redirect to login so the
// user can re-authenticate, instead of leaving the stale cookie in place
// (which otherwise leaves the app stuck on reload). Only do this for a real,
// non-public session: public routes legitimately 401 for anonymous share
// visitors, and we skip it when there is no session cookie to clear.
if (
res.status === 401 &&
!isPublicRoute &&
document.cookie
.split(";")
.some((c) => c.trim().startsWith("filebrowser_quantum_jwt="))
) {
// A 401 from the non-public self check means our session is no longer valid —
// typically the HttpOnly JWT cookie expired. Redirect to login when the app
// still considers the user logged in (public routes legitimately 401 for
// anonymous share visitors).
if (res.status === 401 && !isPublicRoute && getters.isLoggedIn()) {
sessionExpired();
}
throw new Error(`{"status":${res.status},"message":"${await res.text()}"}`);
@@ -88,8 +80,7 @@ export async function logout(redirectUrl) {
if (redirectUrl) {
destination = redirectUrl;
}
// Backend clears the cookie, but frontend does it as fail-safe cleanup
document.cookie = "filebrowser_quantum_jwt=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
// Backend clears the HttpOnly session cookie.
void mutations.setCurrentUser(null);
// No need to clear state.jwt - cookie is the source of truth
// Add a small delay to ensure cookie deletion completes before redirect
@@ -106,15 +97,10 @@ export async function logout(redirectUrl) {
}
}
// Handle an authenticated request that came back 401 because the session cookie
// (JWT) is no longer valid — typically it expired while the tab was idle. Unlike
// logout(), this does NOT call the server (which would itself 401 on an expired
// token and leave the stale cookie in place); it clears the cookie locally and
// redirects to the login page so the user can re-authenticate, instead of being
// stuck on a raw "token is expired" error.
// Handle an authenticated request that came back 401 because the session is no
// longer valid — typically it expired while the tab was idle. Unlike logout(),
// this does NOT call the server; it clears client state and redirects to login.
export function sessionExpired() {
document.cookie =
"filebrowser_quantum_jwt=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
void mutations.setCurrentUser(null);
// Avoid a redirect loop if we're already on the login page.
if (window.location.pathname.endsWith("/login")) {
+4 -10
View File
@@ -18,8 +18,6 @@ vi.mock('@/utils/url.js', () => ({
import { validateLogin } from './auth.js';
const COOKIE = 'filebrowser_quantum_jwt';
function respond(status, body = '') {
global.fetch = vi.fn().mockResolvedValue({
status,
@@ -31,7 +29,7 @@ function respond(status, body = '') {
describe('validateLogin session-expiry handling', () => {
beforeEach(() => {
storeMock.mutations.setCurrentUser.mockClear();
document.cookie = `${COOKIE}=stale; path=/`;
storeMock.getters.isLoggedIn.mockReturnValue(true);
// Stub navigation via Vitest's global-stub API (jsdom doesn't implement
// real navigation). pathname+search feed the redirect; href captures it.
vi.stubGlobal('location', { pathname: '/files/', search: '?a=1', href: '' });
@@ -41,12 +39,10 @@ describe('validateLogin session-expiry handling', () => {
vi.unstubAllGlobals();
});
it('clears the cookie + user and redirects (with encoded return path) on a non-public 401 with a session cookie', async () => {
it('clears user state and redirects (with encoded return path) on a non-public 401 while logged in', async () => {
respond(401, 'token is expired');
await expect(validateLogin(false)).rejects.toThrow();
expect(storeMock.mutations.setCurrentUser).toHaveBeenCalledWith(null);
expect(document.cookie.includes(`${COOKIE}=stale`)).toBe(false);
// Full redirect contract: login route + correctly encoded current path+query.
expect(window.location.href).toBe(
`/login?redirect=${encodeURIComponent('/files/?a=1')}`
);
@@ -56,12 +52,11 @@ describe('validateLogin session-expiry handling', () => {
respond(401, 'unauthorized');
await expect(validateLogin(true)).rejects.toThrow();
expect(storeMock.mutations.setCurrentUser).not.toHaveBeenCalledWith(null);
expect(document.cookie.includes(`${COOKIE}=stale`)).toBe(true);
expect(window.location.href).toBe('');
});
it('does NOT log out or redirect on a non-public 401 when there is NO session cookie', async () => {
document.cookie = `${COOKIE}=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/`;
it('does NOT log out or redirect on a non-public 401 when the user is NOT logged in', async () => {
storeMock.getters.isLoggedIn.mockReturnValue(false);
respond(401, 'unauthorized');
await expect(validateLogin(false)).rejects.toThrow();
expect(storeMock.mutations.setCurrentUser).not.toHaveBeenCalledWith(null);
@@ -72,7 +67,6 @@ describe('validateLogin session-expiry handling', () => {
respond(500, 'server error');
await expect(validateLogin(false)).rejects.toThrow();
expect(storeMock.mutations.setCurrentUser).not.toHaveBeenCalledWith(null);
expect(document.cookie.includes(`${COOKIE}=stale`)).toBe(true);
expect(window.location.href).toBe('');
});
});
+12 -1
View File
@@ -1,7 +1,18 @@
import i18n from '@/i18n';
import { getIconClass } from './material-symbols';
const globalVars = window.globalVars;
function readAppConfig() {
const el = document.getElementById('app-config');
if (el?.textContent) {
return JSON.parse(el.textContent);
}
return window.globalVars;
}
const globalVars = readAppConfig();
if (typeof window !== 'undefined') {
window.globalVars = globalVars;
}
const origin = window.location.origin;
const settings = [
+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);
}
+11 -3
View File
@@ -94,7 +94,7 @@ describe("rewriteCssContent", () => {
});
describe("buildHtmlPreview", () => {
it("rewrites linked assets and preserves scripts in sandboxed srcdoc", () => {
it("rewrites linked assets and strips script tags from srcdoc", () => {
const html = `<!DOCTYPE html>
<html>
<head>
@@ -109,13 +109,21 @@ describe("buildHtmlPreview", () => {
const { srcdoc } = buildHtmlPreview(html, "/pages/index.html", "src");
expect(srcdoc).toContain(encodeURIComponent("/pages/theme.css"));
expect(srcdoc).toContain(encodeURIComponent("/pages/app.js"));
expect(srcdoc).not.toContain("<script");
expect(srcdoc).not.toContain("app.js");
expect(srcdoc).toContain(encodeURIComponent("/pages/photo.jpg"));
expect(srcdoc).toContain(encodeURIComponent("/pages/other.html"));
expect(srcdoc).toContain("<script");
expect(srcdoc).not.toContain("javascript:");
});
it("strips inline and external scripts from uploaded HTML", () => {
const html = `<!DOCTYPE html><html><head><script>alert(document.cookie)</script></head><body><script src="https://evil.example/x.js"></script><p>ok</p></body></html>`;
const { srcdoc } = buildHtmlPreview(html, "/index.html", "src");
expect(srcdoc).not.toContain("<script");
expect(srcdoc).not.toContain("evil.example");
expect(srcdoc).toContain("<p>ok</p>");
});
it("strips dangerous protocols from attributes", () => {
const html = `<html><body><a href="javascript:alert(1)">bad</a></body></html>`;
const { srcdoc } = buildHtmlPreview(html, "/index.html", "src");
+2 -3
View File
@@ -6,8 +6,8 @@ import { resolveRelativePath } from "@/utils/url";
export const HTML_SANITIZE_CONFIG = {
USE_PROFILES: { html: true, svg: true, svgFilters: true },
WHOLE_DOCUMENT: true,
ADD_TAGS: ["link", "script"],
FORBID_TAGS: ["iframe", "object", "embed", "form", "base", "meta"],
ADD_TAGS: ["link"],
FORBID_TAGS: ["iframe", "object", "embed", "form", "base", "meta", "script"],
FORBID_ATTR: ["target", "formaction"],
};
@@ -29,7 +29,6 @@ const REWRITABLE_LINK_RELS = new Set([
]);
const RESOURCE_ATTRIBUTES: Array<[string, string]> = [
["script", "src"],
["link", "href"],
["img", "src"],
["video", "src"],
+7 -12
View File
@@ -334,15 +334,12 @@ class UploadManager {
this.hadActiveUploads = true; // Mark that we've had active uploads
upload.status = "uploading";
// Get chunk size in MB, default to 5 if not set or if 0
let chunkSizeMb = state.user.fileLoading?.uploadChunkSizeMb ?? 5;
if (chunkSizeMb === 0) {
chunkSizeMb = 5;
}
// Default to 5 MB when unset; explicit 0 disables chunking (single-shot upload).
const chunkSizeMb = state.user.fileLoading?.uploadChunkSizeMb ?? 5;
const chunkSize = chunkSizeMb * 1024 * 1024;
// Use non-chunked upload if file size is less than chunk size
if (upload.size < chunkSize) {
// Single-shot upload when chunking is disabled or file is smaller than chunk size.
if (chunkSize === 0 || upload.size < chunkSize) {
const progress = (percent) => {
this.updateProgress(upload, percent);
};
@@ -465,10 +462,7 @@ class UploadManager {
}
chunkSizeBytes() {
let chunkSizeMb = state.user.fileLoading?.uploadChunkSizeMb ?? 5;
if (chunkSizeMb === 0) {
chunkSizeMb = 5;
}
const chunkSizeMb = state.user.fileLoading?.uploadChunkSizeMb ?? 5;
return chunkSizeMb * 1024 * 1024;
}
@@ -494,7 +488,8 @@ class UploadManager {
if (upload?.status !== "uploading" || !upload.xhr) {
return;
}
if (upload.type !== "directory" && upload.size >= this.chunkSizeBytes()) {
const chunkBytes = this.chunkSizeBytes();
if (upload.type !== "directory" && chunkBytes > 0 && upload.size >= chunkBytes) {
try {
if (getters.isShare()) {
await resourcesApi.signalUploadPause(null, upload.path, state.shareInfo.hash);
+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 {
+1 -1
View File
@@ -6,7 +6,7 @@
:key="req.path"
class="html-content"
:srcdoc="htmlPreview.srcdoc"
sandbox="allow-scripts allow-popups allow-same-origin"
sandbox="allow-scripts allow-popups"
referrerpolicy="no-referrer"
title="HTML preview"
></iframe>
+3 -1
View File
@@ -29,7 +29,9 @@ setup:
cd frontend && npm i
update:
cd backend && go get -u ./... && go mod tidy
cd backend && go get -u ./... \
&& go get -u tool \
&& go mod tidy
cd frontend && npm update
build: build-frontend build-backend