Skip to content

Commit f7114ca

Browse files
authored
release(v3.16.0): security hardening
- security(auth): require trusted proxy source validation for proxy-header login - security(webdav): block password-only WebDAV login for TOTP-enabled accounts - security(extract): apply blocked upload filename policy before archive extraction - security(setup): keep first-run setup closed after initial admin creation - security(auth): resolve remember-me admin status from the current user role - security(upload): reject encoded path separators before upload writes
1 parent 444461f commit f7114ca

20 files changed

Lines changed: 755 additions & 56 deletions

CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,50 @@
11
# Changelog
22

3+
## Changes 06/16/2026 (v3.16.0)
4+
5+
`release(v3.16.0): security hardening`
6+
7+
**Commit message**
8+
9+
```text
10+
release(v3.16.0): security hardening
11+
12+
- security(auth): require trusted proxy source validation for proxy-header login
13+
- security(webdav): block password-only WebDAV login for TOTP-enabled accounts
14+
- security(extract): apply blocked upload filename policy before archive extraction
15+
- security(setup): keep first-run setup closed after initial admin creation
16+
- security(auth): resolve remember-me admin status from the current user role
17+
- security(upload): reject encoded path separators before upload writes
18+
```
19+
20+
**Fixed**
21+
22+
- **Proxy-header login hardening**
23+
- Proxy-header login now accepts the configured identity header only from sources listed in `FR_TRUSTED_PROXIES`.
24+
- If you already use proxy-header login, set `FR_TRUSTED_PROXIES` to the reverse proxy IP or CIDR before upgrading; otherwise FileRise will ignore the identity header and users will not be auto-authenticated.
25+
26+
- **WebDAV MFA hardening**
27+
- WebDAV no longer accepts password-only Basic authentication for accounts that have TOTP enabled.
28+
- Users who need WebDAV access should use an account without TOTP until a separate app-password flow is available.
29+
30+
- **Archive extraction hardening**
31+
- Archive extraction now applies the blocked upload filename policy before files are written to disk.
32+
- Mixed archives can still extract allowed files while blocked file types are skipped and reported as warnings.
33+
34+
- **First-run setup hardening**
35+
- FileRise now writes a setup-complete marker after initial admin creation and also creates it automatically for existing installs with users.
36+
- If `users.txt` later becomes empty, first-run setup remains closed and requires out-of-band recovery.
37+
38+
- **Remember-me role hardening**
39+
- Remember-me auto-login now resolves admin status from the current user record instead of trusting role data stored with the token.
40+
- Rotated and newly issued remember-me tokens no longer store the admin flag.
41+
42+
- **Upload filename hardening**
43+
- Upload handling now rejects encoded path separators before resolving the destination path.
44+
- Normal filenames and allowed folder upload paths continue to work.
45+
46+
---
47+
348
## Changes 06/11/2026 (v3.15.0)
449

550
`release(v3.15.0): shared-folder boundary hardening`

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ export PERSISTENT_TOKENS_KEY="$(openssl rand -hex 32)"
260260
| `PGID` | Optional | `100` | If running as root, remap `www-data` group to this GID (e.g. Unraid’s 100). |
261261
| `FR_PUBLISHED_URL` | Optional | `https://example.com/files` | Public URL when behind proxies/subpaths (share links, portals, redirects). |
262262
| `FR_BASE_PATH` | Optional | `/files` | Force a subpath when the proxy strips the prefix (overrides auto-detect). |
263-
| `FR_TRUSTED_PROXIES` | Optional | `127.0.0.1,10.0.0.0/8` | Comma-separated IPs/CIDRs for trusted proxies; only these can supply the client IP header. |
263+
| `FR_TRUSTED_PROXIES` | Optional | `127.0.0.1,10.0.0.0/8` | Comma-separated IPs/CIDRs for trusted proxies; only these can supply the client IP header or proxy-auth identity header. |
264264
| `FR_IP_HEADER` | Optional | `X-Forwarded-For` | Header to trust for the real client IP when the proxy is trusted. |
265265

266266
> Full list of common env variables: [Common Environment variables](https://github.com/error311/FileRise/wiki/Common-Env-Variables)
@@ -354,7 +354,7 @@ Notes:
354354
- Persist `/var/www/metadata` so the generated persistent tokens key survives container recreation.
355355
- Either set your own strong `PERSISTENT_TOKENS_KEY` or let a pristine install generate one and then back up `metadata/persistent_tokens.key`.
356356
- Use HTTPS and set `SECURE="true"` when behind TLS/reverse proxy.
357-
- If behind a proxy, set `FR_TRUSTED_PROXIES` and `FR_IP_HEADER`.
357+
- If behind a proxy, set `FR_TRUSTED_PROXIES` and `FR_IP_HEADER`; proxy-header login also requires `FR_TRUSTED_PROXIES`.
358358
- Set `FR_PUBLISHED_URL` (and `FR_BASE_PATH` if needed) so share links are correct.
359359
- Block direct HTTP access to `/uploads` (serve only `public/` and deny access to `/uploads`, `/users`, `/metadata`).
360360

config/config.php

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ function fr_forget_authenticated_user(bool $clearRememberCookie = false): void
457457
$_SESSION["folderOnly"] = $perms['folderOnly'] ?? false;
458458
$_SESSION["readOnly"] = $perms['readOnly'] ?? false;
459459
$_SESSION["disableUpload"] = $perms['disableUpload'] ?? false;
460-
$_SESSION["isAdmin"] = !empty($payload["isAdmin"]);
460+
$_SESSION["isAdmin"] = (\FileRise\Domain\AuthModel::getUserRole($payload["username"]) === '1');
461461

462462
if (!empty($payload['token']) && !empty($payload['expiry'])) {
463463
setcookie('remember_me_token', $payload['token'], (int)$payload['expiry'], '/', '', $secure, true);
@@ -501,24 +501,32 @@ function fr_forget_authenticated_user(bool $clearRememberCookie = false): void
501501
if (AUTH_BYPASS) {
502502
$hdrKey = AUTH_HEADER; // e.g. "HTTP_X_REMOTE_USER"
503503
if (!empty($_SERVER[$hdrKey])) {
504-
// regenerate once per session
505-
if (empty($_SESSION['authenticated'])) {
506-
session_regenerate_id(true);
507-
}
504+
if (!\FileRise\Domain\AuthModel::isRequestFromTrustedProxy()) {
505+
$remote = preg_replace('/[^A-Fa-f0-9:\.]/', '', (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
506+
if ($remote === '') {
507+
$remote = 'unknown';
508+
}
509+
error_log("FileRise: ignored proxy-auth header from untrusted source {$remote}; set FR_TRUSTED_PROXIES for proxy-header login.");
510+
} else {
511+
// regenerate once per session
512+
if (empty($_SESSION['authenticated'])) {
513+
session_regenerate_id(true);
514+
}
508515

509-
$username = $_SERVER[$hdrKey];
510-
$_SESSION['authenticated'] = true;
511-
$_SESSION['username'] = $username;
516+
$username = $_SERVER[$hdrKey];
517+
$_SESSION['authenticated'] = true;
518+
$_SESSION['username'] = $username;
512519

513-
// ◾ lookup actual role instead of forcing admin
514-
$role = \FileRise\Domain\AuthModel::getUserRole($username);
515-
$_SESSION['isAdmin'] = ($role === '1');
520+
// ◾ lookup actual role instead of forcing admin
521+
$role = \FileRise\Domain\AuthModel::getUserRole($username);
522+
$_SESSION['isAdmin'] = ($role === '1');
516523

517-
// carry over any folder/read/upload perms
518-
$perms = loadUserPermissions($username) ?: [];
519-
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
520-
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
521-
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
524+
// carry over any folder/read/upload perms
525+
$perms = loadUserPermissions($username) ?: [];
526+
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
527+
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
528+
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
529+
}
522530
}
523531
}
524532

docs/wiki/oidc sso.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,4 @@ Example:
8080
## Proxy auth headers (advanced)
8181

8282
If your reverse proxy authenticates users, you can disable form login and trust a header (default `X-Remote-User`) via **Admin → Login options**.
83+
Set `FR_TRUSTED_PROXIES` to the reverse proxy IP or CIDR before enabling this mode; FileRise only accepts the proxy-auth identity header from trusted proxy sources.

docs/wiki/webdav.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ curl -u username:password -X PROPFIND -H "Depth: 1" "https://your-server/webdav.
4848

4949
- URL encode spaces with `%20`.
5050
- Use HTTPS in production.
51+
- Accounts with TOTP enabled cannot use password-only WebDAV Basic authentication.
5152
- WebDAV uploads can be capped via `FR_WEBDAV_MAX_UPLOAD_BYTES`.
5253

5354
---

docs/wiki/webdav2.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ Set `BasicAuthLevel` to `2`, then restart the `WebClient` service.
5252

5353
- If FileRise is hosted under a subpath (e.g. `/files`), use:
5454
- `https://your-server/files/webdav.php/`
55-
- Folder-only users are scoped to their folder in WebDAV.
55+
- WebDAV uses the same folder ACLs as the web UI.
56+
- Accounts with TOTP enabled cannot use password-only WebDAV Basic authentication.
5657
- WebDAV uploads can be capped with `FR_WEBDAV_MAX_UPLOAD_BYTES`.
5758

5859
---

public/js/adminPanel.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7753,7 +7753,7 @@ export function openAdminPanel() {
77537753
<small class="text-muted d-block mt-1">
77547754
${tf(
77557755
"proxy_only_login_help",
7756-
"When enabled, FileRise trusts the reverse proxy header and disables the login form, HTTP Basic and OIDC."
7756+
"When enabled, FileRise trusts the reverse proxy header from FR_TRUSTED_PROXIES and disables the login form, HTTP Basic and OIDC."
77577757
)}
77587758
</small>
77597759
</div>

public/webdav.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242
// ─── 3) HTTP-Basic backend (delegates to your AuthModel) ────────────────────
4343
$authBackend = new BasicCallBack(function(string $user, string $pass) {
44-
return \FileRise\Domain\AuthModel::authenticate($user, $pass) !== false;
44+
return \FileRise\Domain\AuthModel::authenticateWebDav($user, $pass);
4545
});
4646
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
4747

src/FileRise/Domain/AuthModel.php

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,19 @@ public static function authenticate(string $username, string $password)
282282
return false;
283283
}
284284

285+
public static function authenticateWebDav(string $username, string $password): bool
286+
{
287+
$user = self::authenticate($username, $password);
288+
if ($user === false) {
289+
return false;
290+
}
291+
if (!empty($user['totp_secret'])) {
292+
error_log('FileRise: denied WebDAV password-only login for TOTP-enabled user ' . self::sanitizeLogValue($username, 80));
293+
return false;
294+
}
295+
return true;
296+
}
297+
285298
/**
286299
* Loads failed login attempts from a file.
287300
*
@@ -442,6 +455,18 @@ protected static function getTrustedProxies(): array
442455
return array_values(array_filter($parts, fn($part) => $part !== ''));
443456
}
444457

458+
public static function isRequestFromTrustedProxy(?array $server = null): bool
459+
{
460+
$server = $server ?? $_SERVER;
461+
$remote = trim((string)($server['REMOTE_ADDR'] ?? ''));
462+
if ($remote === '' || !filter_var($remote, FILTER_VALIDATE_IP)) {
463+
return false;
464+
}
465+
466+
$trusted = self::getTrustedProxies();
467+
return $trusted !== [] && self::isTrustedProxy($remote, $trusted);
468+
}
469+
445470
protected static function normalizeHeaderKey(string $header): string
446471
{
447472
$key = strtoupper(str_replace('-', '_', trim($header)));
@@ -572,7 +597,7 @@ public static function loadFolderPermission(string $username): bool
572597
* Validate a remember-me token and return its stored payload.
573598
*
574599
* @param string $token
575-
* @return array|null Returns ['username'=>…, 'expiry'=>…, 'isAdmin'=>…] or null if invalid/expired.
600+
* @return array|null Returns ['username'=>…, 'expiry'=>…] or null if invalid/expired.
576601
*/
577602
public static function validateRememberToken(string $token): ?array
578603
{
@@ -654,15 +679,13 @@ public static function consumeRememberToken(string $token): ?array
654679
}
655680

656681
$expiry = (int)$payload['expiry'];
657-
$isAdmin = !empty($payload['isAdmin']);
658682

659683
$newToken = bin2hex(random_bytes(32));
660684
$newHash = self::rememberTokenHash($newToken);
661685

662686
$all[$newHash] = [
663687
'username' => $username,
664-
'expiry' => $expiry,
665-
'isAdmin' => $isAdmin
688+
'expiry' => $expiry
666689
];
667690

668691
unset($all[$hash]);
@@ -675,7 +698,6 @@ public static function consumeRememberToken(string $token): ?array
675698
return [
676699
'username' => $username,
677700
'expiry' => $expiry,
678-
'isAdmin' => $isAdmin,
679701
'token' => $newToken
680702
];
681703
}
@@ -684,7 +706,7 @@ public static function consumeRememberToken(string $token): ?array
684706
* Issue a new remember-me token and store it hashed on disk.
685707
*
686708
* @param string $username
687-
* @param bool $isAdmin
709+
* @param bool $isAdmin Kept for call-site compatibility; roles are resolved from users.txt on use.
688710
* @param int|null $expiry
689711
* @return array{token:string,expiry:int}
690712
*/
@@ -696,8 +718,7 @@ public static function issueRememberToken(string $username, bool $isAdmin, ?int
696718
$all = self::loadRememberTokenStore();
697719
$all[self::rememberTokenHash($token)] = [
698720
'username' => $username,
699-
'expiry' => $expiry,
700-
'isAdmin' => $isAdmin
721+
'expiry' => $expiry
701722
];
702723
self::saveRememberTokenStore($all);
703724

src/FileRise/Domain/FileModel.php

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1789,6 +1789,14 @@ public static function extractZipArchive($folder, $files)
17891789
return false;
17901790
};
17911791

1792+
$isAllowedArchiveFile = function (string $entry): bool {
1793+
$e = trim(str_replace('\\', '/', $entry), '/');
1794+
if ($e === '' || str_ends_with($e, '/')) {
1795+
return false;
1796+
}
1797+
return UploadNamePolicy::isAllowedForWrite(basename($e));
1798+
};
1799+
17921800
// Generalized metadata stamper: writes to the specified folder's metadata.json
17931801
$stampMeta = function (string $folderStr, string $basename) use (&$getMeta, &$putMeta, $actor, $now) {
17941802
$meta = $getMeta($folderStr);
@@ -2259,6 +2267,7 @@ public static function extractZipArchive($folder, $files)
22592267
$unsafe = false;
22602268
$unsafeReason = '';
22612269
$skippedSymlinks = 0;
2270+
$skippedBlocked = 0;
22622271
$totalUncompressed = 0;
22632272
$fileCount = 0;
22642273
$allowedEntries = []; // names to extract (files and/or directories)
@@ -2302,6 +2311,10 @@ public static function extractZipArchive($folder, $files)
23022311

23032312
// Track limits only for files we're going to extract
23042313
if (!$isDir) {
2314+
if (!$isAllowedArchiveFile($name)) {
2315+
$skippedBlocked++;
2316+
continue;
2317+
}
23052318
$fileCount++;
23062319
$sz = isset($stat['size']) ? (int)$stat['size'] : 0;
23072320
$totalUncompressed += $sz;
@@ -2334,7 +2347,9 @@ public static function extractZipArchive($folder, $files)
23342347
if (empty($allowedEntries)) {
23352348
$zip->close();
23362349
// Treat as success (nothing visible to extract), but informatively note it
2337-
if ($skippedSymlinks > 0) {
2350+
if ($skippedBlocked > 0) {
2351+
$errors[] = "$archiveBase contained only blocked file types.";
2352+
} elseif ($skippedSymlinks > 0) {
23382353
$errors[] = "$archiveBase contained only symlink entries.";
23392354
} else {
23402355
$errors[] = "$archiveBase contained only hidden or unsupported entries.";
@@ -2362,6 +2377,9 @@ public static function extractZipArchive($folder, $files)
23622377
if ($skippedSymlinks > 0) {
23632378
$warnings[] = "$archiveBase: skipped {$skippedSymlinks} symlink entr" . ($skippedSymlinks === 1 ? 'y' : 'ies') . ".";
23642379
}
2380+
if ($skippedBlocked > 0) {
2381+
$warnings[] = "$archiveBase: skipped {$skippedBlocked} blocked file type" . ($skippedBlocked === 1 ? '' : 's') . ".";
2382+
}
23652383
continue;
23662384
}
23672385

@@ -2391,6 +2409,7 @@ public static function extractZipArchive($folder, $files)
23912409
$unsafe = false;
23922410
$unsafeReason = '';
23932411
$skippedSymlinks = 0;
2412+
$skippedBlocked = 0;
23942413
$totalUncompressed = 0;
23952414
$fileCount = 0;
23962415
$allowedEntries = [];
@@ -2416,6 +2435,7 @@ public static function extractZipArchive($folder, $files)
24162435
&$unsafe,
24172436
&$unsafeReason,
24182437
&$skippedSymlinks,
2438+
&$skippedBlocked,
24192439
&$totalUncompressed,
24202440
&$fileCount,
24212441
$isUnsafeEntryPath,
@@ -2424,7 +2444,8 @@ public static function extractZipArchive($folder, $files)
24242444
$SKIP_DOTFILES,
24252445
$MAX_UNZIP_BYTES,
24262446
$MAX_UNZIP_FILES,
2427-
$formatBytes
2447+
$formatBytes,
2448+
$isAllowedArchiveFile
24282449
) {
24292450
if ($curPath === null) {
24302451
return;
@@ -2471,8 +2492,11 @@ public static function extractZipArchive($folder, $files)
24712492
return;
24722493
}
24732494

2474-
$allowedEntries[] = $name;
24752495
if (!$isDir) {
2496+
if (!$isAllowedArchiveFile($name)) {
2497+
$skippedBlocked++;
2498+
return;
2499+
}
24762500
$fileCount++;
24772501
$totalUncompressed += $size;
24782502
if ($fileCount > $MAX_UNZIP_FILES) {
@@ -2492,6 +2516,7 @@ public static function extractZipArchive($folder, $files)
24922516
$allowedFiles[] = $name;
24932517
$expectedSizes[$name] = $size;
24942518
}
2519+
$allowedEntries[] = $name;
24952520
};
24962521

24972522
foreach ($listOut as $line) {
@@ -2580,7 +2605,9 @@ public static function extractZipArchive($folder, $files)
25802605
}
25812606

25822607
if (empty($allowedEntries)) {
2583-
if ($skippedSymlinks > 0) {
2608+
if ($skippedBlocked > 0) {
2609+
$errors[] = "$archiveBase contained only blocked file types.";
2610+
} elseif ($skippedSymlinks > 0) {
25842611
$errors[] = "$archiveBase contained only symlink entries.";
25852612
} else {
25862613
$errors[] = "$archiveBase contained only hidden or unsupported entries.";
@@ -2752,6 +2779,9 @@ public static function extractZipArchive($folder, $files)
27522779
if ($skippedSymlinks > 0) {
27532780
$warnings[] = "$archiveBase: skipped {$skippedSymlinks} symlink entr" . ($skippedSymlinks === 1 ? 'y' : 'ies') . ".";
27542781
}
2782+
if ($skippedBlocked > 0) {
2783+
$warnings[] = "$archiveBase: skipped {$skippedBlocked} blocked file type" . ($skippedBlocked === 1 ? '' : 's') . ".";
2784+
}
27552785
if (!$usedUnar && $extractDetail !== '') {
27562786
$warnings[] = "$archiveBase: " . $extractDetail;
27572787
}

0 commit comments

Comments
 (0)