mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
33
Commits
main
...
v1.5.7-stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6412ffcca7 | ||
|
|
0ae4035c80 | ||
|
|
2ce9fb535c | ||
|
|
5d9b4df2a2 | ||
|
|
443f78ea9a | ||
|
|
e68ef5ca4e | ||
|
|
f1a7e19fdc | ||
|
|
33a6fd3915 | ||
|
|
044d08f2f1 | ||
|
|
e071c8df8a | ||
|
|
83393afb31 | ||
|
|
47b2c44886 | ||
|
|
f7e51f11b8 | ||
|
|
ec4947e634 | ||
|
|
88ee6424bf | ||
|
|
67c2908413 | ||
|
|
78084d5b5a | ||
|
|
053571efe2 | ||
|
|
ec2b6df30e | ||
|
|
45161017cc | ||
|
|
fcc30ab42d | ||
|
|
bbb56b54af | ||
|
|
8bc1def989 | ||
|
|
3e9c4325d5 | ||
|
|
acc3008472 | ||
|
|
8ed3efbfc9 | ||
|
|
4655f8985c | ||
|
|
0f0531cbd7 | ||
|
|
4126edeb07 | ||
|
|
ca2e1689e0 | ||
|
|
6a5ed61751 | ||
|
|
6a36684892 | ||
|
|
30f21afded |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+6
-2
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
// ========================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+691
-1183
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user