release(v2.11.2): fix PocketID OIDC token auth + harden login/WebDAV (closes #77)

This commit is contained in:
Ryan
2025-12-24 00:58:33 -05:00
committed by GitHub
parent b01d3bf903
commit 97e88d8059
14 changed files with 644 additions and 67 deletions
+34
View File
@@ -1,5 +1,39 @@
# Changelog
## Changes 12/24/2025 (v2.11.2)
`release(v2.11.2): fix PocketID OIDC token auth + harden login/WebDAV (closes #77)`
**Fixed**
- **OIDC / PocketID compatibility:** token endpoint auth now defaults to **`client_secret_basic`** when a client secret exists, and **never attempts `client_secret_*`** when the secret is missing/blank (public client mode). *(Closes #77.)*
- **WebDAV uploads:** stop buffering entire uploads into memory; uploads now stream to a temp file and then replace the target file.
- **WebDAV path safety:** improved uploads path prefix/boundary checks (prevents edge cases like `/uploads` matching `/uploads2`).
- **WebDAV metadata:** uploader no longer defaults to `Unknown` when the WebDAV user is not set.
**Security / Hardening**
- **Login rate limiting:** rate-limit tracking is now keyed by **IP + username** (instead of only IP) and stale counters are reset after the lockout window.
- **Trusted reverse proxy support:** client IP can be derived from a configured header (e.g. `X-Forwarded-For`) when `REMOTE_ADDR` is a trusted proxy.
- **Fail2ban-friendly logging:** failed logins are written to `users/fail2ban.log` with basic rotation.
**UI**
- Login screen now shows a clearer tip for definitive failures (e.g., “attempts used” and lockout messaging).
**Configuration**
- New optional env/config knobs:
- `FR_TRUSTED_PROXIES` — comma-separated IPs/CIDRs to treat as trusted proxies
- `FR_IP_HEADER` — header to trust for the real client IP (default: `X-Forwarded-For`)
- `FR_WEBDAV_MAX_UPLOAD_BYTES` — WebDAV upload size limit in bytes (`0` = unlimited)
**Misc**
- Updated sponsor list in Admin Panel.
---
## Changes 12/22/2025 (v2.11.1)
`release(v2.11.1): scope dotfile blocking to allow WebDAV dotpaths + add/revise OpenAPI annotations`
+8 -4
View File
@@ -47,12 +47,8 @@ Full list of features: [Full Feature Wiki](https://github.com/error311/FileRise/
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.9.0.png)
https://github.com/user-attachments/assets/87b06f1a-1400-4df1-bf1d-aaae88fcdfbd
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
> Check out [filerise.net](https://filerise.net) FileRise Core stays fully open-source (MIT).
@@ -167,6 +163,8 @@ docker compose up -d
| `PUID` | Optional | `99` | If running as root, remap `www-data` user to this UID (e.g. Unraids 99). |
| `PGID` | Optional | `100` | If running as root, remap `www-data` group to this GID (e.g. Unraids 100). |
| `FR_PUBLISHED_URL` | Optional | `https://example.com/files` | Public URL when behind proxies/subpaths (share links, portals, redirects). |
| `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. |
| `FR_IP_HEADER` | Optional | `X-Forwarded-For` | Header to trust for the real client IP when the proxy is trusted. |
> Full list of common env variables: [Common Environment variables](https://github.com/error311/FileRise/wiki/Common-Env-Variables)
>
@@ -223,6 +221,12 @@ Quick write test:
sudo -u www-data touch /var/www/users/.write_test && echo OK
```
### Login security notes
- Failed login attempts are throttled per **IP + username** and stored in `/var/www/users/failed_logins.json` by default.
- Failed logins are also written to `/var/www/users/fail2ban.log` (rotate at 50MB, keep 5 files).
- If youre behind a reverse proxy, set `FR_TRUSTED_PROXIES` and `FR_IP_HEADER` so rate limiting and logs use the real client IP.
### Install from release ZIP (recommended)
```bash
+21
View File
@@ -101,6 +101,27 @@ if (!defined('FR_OIDC_DEBUG')) {
define('FR_OIDC_DEBUG', false);
}
}
// Optional: trusted proxy IP resolution for rate limiting/logging
// Set FR_TRUSTED_PROXIES to a comma-separated list of IPs/CIDRs (e.g. "127.0.0.1,10.0.0.0/8").
if (!defined('FR_TRUSTED_PROXIES')) {
$envVal = getenv('FR_TRUSTED_PROXIES');
define('FR_TRUSTED_PROXIES', ($envVal !== false && $envVal !== '') ? $envVal : '');
}
// Which header to trust when REMOTE_ADDR matches FR_TRUSTED_PROXIES.
if (!defined('FR_IP_HEADER')) {
$envVal = getenv('FR_IP_HEADER');
define('FR_IP_HEADER', ($envVal !== false && $envVal !== '') ? $envVal : 'X-Forwarded-For');
}
// Optional: WebDAV max upload size in bytes (0 = unlimited)
if (!defined('FR_WEBDAV_MAX_UPLOAD_BYTES')) {
$envVal = getenv('FR_WEBDAV_MAX_UPLOAD_BYTES');
if ($envVal !== false && $envVal !== '') {
$val = (int)$envVal;
define('FR_WEBDAV_MAX_UPLOAD_BYTES', $val > 0 ? $val : 0);
} else {
define('FR_WEBDAV_MAX_UPLOAD_BYTES', 0);
}
}
// Antivirus / ClamAV (optional)
// If VIRUS_SCAN_ENABLED is set in the environment, it overrides the admin setting.
// If it is not set, we don't define the constant and the admin checkbox controls scanning.
+4 -2
View File
@@ -61,8 +61,10 @@ try {
if (defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
$tokenAuthMethod = OIDC_TOKEN_ENDPOINT_AUTH_METHOD;
}
if (!$tokenAuthMethod) {
$tokenAuthMethod = $publicClient ? 'none' : 'client_secret_basic';
if ($publicClient || $clientSecret === null || $clientSecret === '') {
$tokenAuthMethod = 'none';
} elseif (!$tokenAuthMethod) {
$tokenAuthMethod = 'client_secret_basic';
}
$loginOptions = is_array($cfg['loginOptions'] ?? null) ? $cfg['loginOptions'] : [];
+5 -1
View File
@@ -19,6 +19,10 @@
* @OA\Response(
* response=401,
* description="Unauthorized due to missing credentials or invalid credentials."
* ),
* @OA\Response(
* response=429,
* description="Too many failed login attempts."
* )
* )
*
@@ -31,4 +35,4 @@ require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
$authController = new AuthController();
$authController->loginBasic();
$authController->loginBasic();
+4 -1
View File
@@ -24,7 +24,10 @@ const DEFAULT_SUPPORTERS = [
'Matthias S',
'Emerson Beltrán',
'shucking',
'Sascha A.'
'Sascha A.',
'JBR0XN',
'Blaž Pivk',
'Rob Parker'
];
/**
+98 -33
View File
@@ -602,6 +602,25 @@ async function syncPermissionsToLocalStorage() {
// ——— main ———
let __loginInFlight = false;
function isDefinitiveLoginError(message) {
return /invalid credentials|invalid username format|too many failed login attempts/i.test(String(message || ''));
}
function handleLoginFailureTip(message) {
const msg = String(message || '');
if (/too many failed login attempts/i.test(msg)) {
if (typeof window.__frShowLoginLockoutTip === 'function') {
window.__frShowLoginLockoutTip();
}
return;
}
if (/invalid credentials/i.test(msg)) {
if (typeof window.__frRecordLoginFailure === 'function') {
window.__frRecordLoginFailure();
}
}
}
async function submitLogin(data) {
if (__loginInFlight) return;
__loginInFlight = true;
@@ -629,6 +648,9 @@ async function submitLogin(data) {
// TOTP requested?
if (await sniffTOTP(res, body)) {
if (typeof window.__frResetLoginFailure === 'function') {
window.__frResetLoginFailure();
}
try { await primeCsrfStrict(); } catch (e) { }
window.pendingTOTP = true;
try {
@@ -640,6 +662,9 @@ async function submitLogin(data) {
// Full success (no TOTP)
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
if (typeof window.__frResetLoginFailure === 'function') {
window.__frResetLoginFailure();
}
await syncPermissionsToLocalStorage();
return afterLogin();
@@ -647,46 +672,19 @@ async function submitLogin(data) {
// Cookie set but non-JSON body — double check session
if (!body && await isAuthedNow()) {
if (typeof window.__frResetLoginFailure === 'function') {
window.__frResetLoginFailure();
}
await syncPermissionsToLocalStorage();
return afterLogin();
}
// Attempt #2 — form fallback
res = await fetchWithCsrf('/api/auth/auth.php', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: toFormBody(payload)
});
body = await safeJson(res);
if (await sniffTOTP(res, body)) {
try { await primeCsrfStrict(); } catch (e) { }
window.pendingTOTP = true;
try {
const auth = await import(withBase('/js/auth.js?v={{APP_QVER}}'));
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
} catch (e) { }
return;
}
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
await syncPermissionsToLocalStorage();
return afterLogin();
}
if (!body && await isAuthedNow()) {
await syncPermissionsToLocalStorage();
return afterLogin();
}
// Rate limit still respected
if (body?.error && /Too many failed login attempts/i.test(body.error)) {
if (typeof window.__frShowLoginLockoutTip === 'function') {
window.__frShowLoginLockoutTip();
}
showToast(body.error);
const btn = document.querySelector("#authForm button[type='submit']");
if (btn) {
@@ -699,6 +697,73 @@ async function submitLogin(data) {
return;
}
if (body?.error && isDefinitiveLoginError(body.error)) {
handleLoginFailureTip(body.error);
showToast('Login failed' + (body?.error ? `: ${body.error}` : ''));
return;
}
// Attempt #2 — form fallback
res = await fetchWithCsrf('/api/auth/auth.php', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: toFormBody(payload)
});
body = await safeJson(res);
if (await sniffTOTP(res, body)) {
if (typeof window.__frResetLoginFailure === 'function') {
window.__frResetLoginFailure();
}
try { await primeCsrfStrict(); } catch (e) { }
window.pendingTOTP = true;
try {
const auth = await import(withBase('/js/auth.js?v={{APP_QVER}}'));
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
} catch (e) { }
return;
}
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
if (typeof window.__frResetLoginFailure === 'function') {
window.__frResetLoginFailure();
}
await syncPermissionsToLocalStorage();
return afterLogin();
}
if (!body && await isAuthedNow()) {
if (typeof window.__frResetLoginFailure === 'function') {
window.__frResetLoginFailure();
}
await syncPermissionsToLocalStorage();
return afterLogin();
}
// Rate limit still respected
if (body?.error && /Too many failed login attempts/i.test(body.error)) {
if (typeof window.__frShowLoginLockoutTip === 'function') {
window.__frShowLoginLockoutTip();
}
showToast(body.error);
const btn = document.querySelector("#authForm button[type='submit']");
if (btn) {
btn.disabled = true;
setTimeout(() => {
btn.disabled = false;
showToast("You can now try logging in again.");
}, 30 * 60 * 1000);
}
return;
}
if (body?.error) {
handleLoginFailureTip(body.error);
}
showToast('Login failed' + (body?.error ? `: ${body.error}` : ''));
} catch (e) {
+126 -4
View File
@@ -106,6 +106,64 @@ function showLoginTip(message) {
tip.style.display = 'block';
}
const LOGIN_FAIL_STORAGE_KEY = 'fr_login_fail_state';
const LOGIN_FAIL_WINDOW_MS = 30 * 60 * 1000;
const LOGIN_FAIL_MAX = 5;
function readLoginFailState(now = Date.now()) {
try {
const raw = sessionStorage.getItem(LOGIN_FAIL_STORAGE_KEY);
if (!raw) return { count: 0, last: 0 };
const parsed = JSON.parse(raw);
const count = Number(parsed.count) || 0;
const last = Number(parsed.last) || 0;
if (!last || (now - last) > LOGIN_FAIL_WINDOW_MS) {
return { count: 0, last: 0 };
}
return { count, last };
} catch (e) {
return { count: 0, last: 0 };
}
}
function writeLoginFailState(state) {
try {
sessionStorage.setItem(LOGIN_FAIL_STORAGE_KEY, JSON.stringify(state));
} catch (e) { }
}
function resetLoginFailState() {
try {
sessionStorage.removeItem(LOGIN_FAIL_STORAGE_KEY);
} catch (e) { }
}
function showLoginFailTip(count) {
if (count >= LOGIN_FAIL_MAX) {
showLoginTip('Too many failed login attempts. Please try again later.');
return;
}
showLoginTip(`Failed to log in: ${count} of ${LOGIN_FAIL_MAX} attempts used.`);
}
function recordLoginFailure() {
const now = Date.now();
const state = readLoginFailState(now);
const count = Math.min((state.count || 0) + 1, LOGIN_FAIL_MAX);
writeLoginFailState({ count, last: now });
showLoginFailTip(count);
return count;
}
function showLoginLockoutTip() {
showLoginTip('Too many failed login attempts. Please try again later.');
}
window.showLoginTip = showLoginTip;
window.__frRecordLoginFailure = recordLoginFailure;
window.__frResetLoginFailure = resetLoginFailState;
window.__frShowLoginLockoutTip = showLoginLockoutTip;
async function hideOverlaySmoothly(overlay) {
if (!overlay) return;
try { await document.fonts?.ready; } catch (e) { }
@@ -522,6 +580,21 @@ function bindDarkMode() {
} catch (e) {
window.__FR_PRO_SEARCH_CFG__ = { enabled: false, defaultLimit: 50, lockedByEnv: false };
}
try {
const audit = (cfg && cfg.proAudit && typeof cfg.proAudit === 'object') ? cfg.proAudit : {};
const isPro = window.__FR_IS_PRO === true;
const levelRaw = (typeof audit.level === 'string') ? audit.level : 'standard';
const level = (levelRaw === 'standard' || levelRaw === 'verbose') ? levelRaw : 'standard';
window.__FR_PRO_AUDIT_CFG__ = {
enabled: isPro && !!audit.enabled,
level,
maxFileMb: Number(audit.maxFileMb || 200),
maxFiles: Number(audit.maxFiles || 10),
available: !!audit.available,
};
} catch (e) {
window.__FR_PRO_AUDIT_CFG__ = { enabled: false, level: 'standard', maxFileMb: 200, maxFiles: 10, available: false };
}
// Expose a simple boolean for ClamAV scanning
if (cfg && cfg.clamav && typeof cfg.clamav.scanUploads !== 'undefined') {
@@ -951,6 +1024,23 @@ function bindDarkMode() {
} catch (e) { }
return false;
}
function isDefinitiveLoginError(message) {
return /invalid credentials|invalid username format|too many failed login attempts/i.test(String(message || ''));
}
function handleLoginFailureTip(message) {
const msg = String(message || '');
if (/too many failed login attempts/i.test(msg)) {
if (typeof window.__frShowLoginLockoutTip === 'function') {
window.__frShowLoginLockoutTip();
}
return;
}
if (/invalid credentials/i.test(msg)) {
if (typeof window.__frRecordLoginFailure === 'function') {
window.__frRecordLoginFailure();
}
}
}
async function openTotpNow() {
// refresh CSRF for the upcoming /totp_verify call
@@ -1011,9 +1101,26 @@ function bindDarkMode() {
const j = await r.clone().json().catch(() => ({}));
// TOTP step-up?
if (looksLikeTOTP(r, j)) { await openTotpNow(); return; }
if (looksLikeTOTP(r, j)) {
if (typeof window.__frResetLoginFailure === 'function') {
window.__frResetLoginFailure();
}
await openTotpNow();
return;
}
if (j && (j.authenticated || j.success || j.status === 'ok' || j.result === 'ok')) return afterLogin();
if (j && (j.authenticated || j.success || j.status === 'ok' || j.result === 'ok')) {
if (typeof window.__frResetLoginFailure === 'function') {
window.__frResetLoginFailure();
}
return afterLogin();
}
if (j && j.error && isDefinitiveLoginError(j.error)) {
handleLoginFailureTip(j.error);
alert('Login failed');
return;
}
} catch (e) { }
// fallback form
@@ -1028,9 +1135,24 @@ function bindDarkMode() {
const j2 = await r2.clone().json().catch(() => ({}));
// TOTP step-up on fallback too
if (looksLikeTOTP(r2, j2)) { await openTotpNow(); return; }
if (looksLikeTOTP(r2, j2)) {
if (typeof window.__frResetLoginFailure === 'function') {
window.__frResetLoginFailure();
}
await openTotpNow();
return;
}
if (j2 && (j2.authenticated || j2.success || j2.status === 'ok' || j2.result === 'ok')) return afterLogin();
if (j2 && (j2.authenticated || j2.success || j2.status === 'ok' || j2.result === 'ok')) {
if (typeof window.__frResetLoginFailure === 'function') {
window.__frResetLoginFailure();
}
return afterLogin();
}
if (j2 && j2.error) {
handleLoginFailureTip(j2.error);
}
} catch (e) { }
alert('Login failed');
});
+64 -13
View File
@@ -145,9 +145,11 @@ class AuthController
if (defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
$tokenAuthMethod = OIDC_TOKEN_ENDPOINT_AUTH_METHOD;
}
// Default token endpoint auth: none for public clients, basic for confidential.
if (!$tokenAuthMethod) {
$tokenAuthMethod = $clientSecret ? 'client_secret_basic' : 'none';
// Never send client_secret_* when no secret is available (public client or empty secret).
if ($clientSecret === null) {
$tokenAuthMethod = 'none';
} elseif (!$tokenAuthMethod) {
$tokenAuthMethod = 'client_secret_basic';
}
$this->logOidcDebug('Building OIDC client', [
@@ -170,7 +172,7 @@ class AuthController
$oidc->setCodeChallengeMethod('S256');
}
// client_secret_post with Authelia using config.php
// Apply explicit token endpoint auth method when configured.
if (method_exists($oidc, 'setTokenEndpointAuthMethod') && $tokenAuthMethod) {
$oidc->setTokenEndpointAuthMethod($tokenAuthMethod);
}
@@ -391,14 +393,24 @@ class AuthController
}
// rate-limit
$ip = $_SERVER['REMOTE_ADDR'];
$ip = AuthModel::getClientIp($_SERVER);
$key = AuthModel::getFailedLoginKey($ip, $username);
$attemptsFile = USERS_DIR . 'failed_logins.json';
$failed = AuthModel::loadFailedAttempts($attemptsFile);
$now = time();
if (isset($failed[$key])) {
$lastAttempt = (int)($failed[$key]['last_attempt'] ?? 0);
if ($lastAttempt <= 0 || ($now - $lastAttempt) >= 30 * 60) {
$failed[$key] = ['count' => 0, 'last_attempt' => 0];
}
}
if (
isset($failed[$ip]) &&
$failed[$ip]['count'] >= 5 &&
time() - $failed[$ip]['last_attempt'] < 30 * 60
isset($failed[$key]) &&
($failed[$key]['count'] ?? 0) >= 5 &&
($failed[$key]['last_attempt'] ?? 0) > 0 &&
$now - $failed[$key]['last_attempt'] < 30 * 60
) {
AuthModel::logFailedLogin($ip, $username, 'lockout', $_SERVER['HTTP_USER_AGENT'] ?? '');
http_response_code(429);
echo json_encode(['error' => 'Too many failed login attempts. Please try again later.']);
exit();
@@ -407,11 +419,12 @@ class AuthController
$user = AuthModel::authenticate($username, $password);
if ($user === false) {
// record failure
$failed[$ip] = [
'count' => ($failed[$ip]['count'] ?? 0) + 1,
'last_attempt' => time()
$failed[$key] = [
'count' => ($failed[$key]['count'] ?? 0) + 1,
'last_attempt' => $now
];
AuthModel::saveFailedAttempts($attemptsFile, $failed);
AuthModel::logFailedLogin($ip, $username, 'invalid_credentials', $_SERVER['HTTP_USER_AGENT'] ?? '');
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
exit();
@@ -427,8 +440,8 @@ class AuthController
}
// otherwise clear rate-limit & finish
if (isset($failed[$ip])) {
unset($failed[$ip]);
if (isset($failed[$key])) {
unset($failed[$key]);
AuthModel::saveFailedAttempts($attemptsFile, $failed);
}
$this->finalizeLogin($username, $rememberMe);
@@ -644,6 +657,30 @@ class AuthController
exit();
}
// rate-limit
$ip = AuthModel::getClientIp($_SERVER);
$key = AuthModel::getFailedLoginKey($ip, $username);
$attemptsFile = USERS_DIR . 'failed_logins.json';
$failed = AuthModel::loadFailedAttempts($attemptsFile);
$now = time();
if (isset($failed[$key])) {
$lastAttempt = (int)($failed[$key]['last_attempt'] ?? 0);
if ($lastAttempt <= 0 || ($now - $lastAttempt) >= 30 * 60) {
$failed[$key] = ['count' => 0, 'last_attempt' => 0];
}
}
if (
isset($failed[$key]) &&
($failed[$key]['count'] ?? 0) >= 5 &&
($failed[$key]['last_attempt'] ?? 0) > 0 &&
$now - $failed[$key]['last_attempt'] < 30 * 60
) {
AuthModel::logFailedLogin($ip, $username, 'lockout', $_SERVER['HTTP_USER_AGENT'] ?? '');
header('HTTP/1.1 429 Too Many Requests');
echo json_encode(['error' => 'Too many failed login attempts. Please try again later.']);
exit();
}
// Attempt authentication.
$role = AuthModel::authenticate($username, $password);
if ($role !== false) {
@@ -653,14 +690,28 @@ class AuthController
// If TOTP is required, store pending values and redirect to prompt for TOTP.
$_SESSION['pending_login_user'] = $username;
$_SESSION['pending_login_secret'] = $secret;
if (isset($failed[$key])) {
unset($failed[$key]);
AuthModel::saveFailedAttempts($attemptsFile, $failed);
}
header("Location: " . fr_with_base_path("/index.html?totp_required=1"));
exit();
}
if (isset($failed[$key])) {
unset($failed[$key]);
AuthModel::saveFailedAttempts($attemptsFile, $failed);
}
$this->finishBrowserLogin($username);
}
// Invalid credentials; prompt again.
$failed[$key] = [
'count' => ($failed[$key]['count'] ?? 0) + 1,
'last_attempt' => $now
];
AuthModel::saveFailedAttempts($attemptsFile, $failed);
AuthModel::logFailedLogin($ip, $username, 'invalid_credentials', $_SERVER['HTTP_USER_AGENT'] ?? '');
header('WWW-Authenticate: Basic realm="FileRise Login"');
header('HTTP/1.0 401 Unauthorized');
echo 'Invalid credentials';
+1 -1
View File
@@ -314,7 +314,7 @@ class AdminModel
foreach ($required as $k) {
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, redirectUri" . ($publicClient ? '' : ', clientSecret') . ")."];
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, redirectUri" . ($publicClient ? '' : ', clientSecret') . "). If you want a blank secret, enable Public Client or disable OIDC login."];
}
}
+214
View File
@@ -7,6 +7,8 @@ require_once PROJECT_ROOT . '/src/models/AdminModel.php';
class AuthModel
{
private const FAIL2BAN_LOG_MAX_BYTES = 50 * 1024 * 1024;
private const FAIL2BAN_LOG_MAX_FILES = 5;
public static function isOidcDemoteAllowed(): bool
{
@@ -297,6 +299,218 @@ class AuthModel
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX);
}
public static function getFailedLoginKey(string $ip, string $username): string
{
$ip = trim($ip);
$user = trim($username);
if ($user !== '') {
$user = strtolower($user);
$ipKey = $ip !== '' ? $ip : 'unknown';
return $ipKey . '|' . $user;
}
return $ip !== '' ? $ip : 'unknown';
}
public static function getClientIp(array $server = null): string
{
$server = $server ?? $_SERVER;
$remote = trim((string)($server['REMOTE_ADDR'] ?? ''));
$trusted = self::getTrustedProxies();
if ($remote !== '' && $trusted && self::isTrustedProxy($remote, $trusted)) {
$headerName = defined('FR_IP_HEADER') ? (string)FR_IP_HEADER : 'X-Forwarded-For';
$headerKey = self::normalizeHeaderKey($headerName);
$headerVal = trim((string)($server[$headerKey] ?? ''));
if ($headerVal !== '') {
$candidate = $headerVal;
if (strpos($candidate, ',') !== false) {
$parts = explode(',', $candidate);
$candidate = trim($parts[0]);
}
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
return $candidate;
}
}
}
if ($remote !== '' && filter_var($remote, FILTER_VALIDATE_IP)) {
return $remote;
}
return $remote !== '' ? $remote : 'unknown';
}
protected static function sanitizeLogValue(string $value, int $maxLen = 120): string
{
$value = str_replace(["\r", "\n", "\t"], ' ', $value);
$value = str_replace('"', "'", $value);
$value = trim($value);
if ($maxLen > 0 && strlen($value) > $maxLen) {
$value = substr($value, 0, $maxLen);
}
return $value;
}
public static function logFailedLogin(string $ip, string $username, string $reason, string $userAgent = ''): void
{
$logFile = USERS_DIR . 'fail2ban.log';
$ts = date('Y-m-d H:i:s');
$ip = self::sanitizeLogValue($ip, 64);
$username = self::sanitizeLogValue($username, 80);
$reason = self::sanitizeLogValue($reason, 60);
$ua = $userAgent !== '' ? $userAgent : ($_SERVER['HTTP_USER_AGENT'] ?? '');
$ua = self::sanitizeLogValue($ua, 200);
$line = $ts
. ' filerise_auth failed_login ip=' . ($ip !== '' ? $ip : 'unknown')
. ' user=' . ($username !== '' ? $username : 'unknown')
. ' reason=' . ($reason !== '' ? $reason : 'unknown');
if ($ua !== '') {
$line .= ' ua="' . $ua . '"';
}
$line .= "\n";
self::rotateFail2banLog($logFile);
@file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
}
protected static function rotateFail2banLog(string $logFile): void
{
$maxBytes = self::FAIL2BAN_LOG_MAX_BYTES;
$maxFiles = self::FAIL2BAN_LOG_MAX_FILES;
if ($maxBytes <= 0 || $maxFiles <= 1) {
return;
}
if (!file_exists($logFile)) {
return;
}
$size = @filesize($logFile);
if ($size === false || $size < $maxBytes) {
return;
}
$maxRotated = $maxFiles - 1;
for ($i = $maxRotated; $i >= 1; $i--) {
$src = $logFile . '.' . $i;
if ($i === $maxRotated) {
if (file_exists($src)) {
@unlink($src);
}
continue;
}
$dst = $logFile . '.' . ($i + 1);
if (file_exists($src)) {
@rename($src, $dst);
}
}
@rename($logFile, $logFile . '.1');
}
protected static function getTrustedProxies(): array
{
$raw = '';
if (defined('FR_TRUSTED_PROXIES')) {
$raw = FR_TRUSTED_PROXIES;
} else {
$env = getenv('FR_TRUSTED_PROXIES');
if ($env !== false && $env !== '') {
$raw = $env;
}
}
if (is_array($raw)) {
return $raw;
}
if (!is_string($raw) || trim($raw) === '') {
return [];
}
$parts = array_map('trim', explode(',', $raw));
return array_values(array_filter($parts, fn($part) => $part !== ''));
}
protected static function normalizeHeaderKey(string $header): string
{
$key = strtoupper(str_replace('-', '_', trim($header)));
if ($key === 'REMOTE_ADDR') {
return $key;
}
if (strpos($key, 'HTTP_') !== 0) {
$key = 'HTTP_' . $key;
}
return $key;
}
protected static function isTrustedProxy(string $ip, array $trusted): bool
{
foreach ($trusted as $entry) {
$entry = trim((string)$entry);
if ($entry === '') {
continue;
}
if (strpos($entry, '/') === false) {
if ($ip === $entry) {
return true;
}
continue;
}
if (self::ipInCidr($ip, $entry)) {
return true;
}
}
return false;
}
protected static function ipInCidr(string $ip, string $cidr): bool
{
$cidr = trim($cidr);
if ($cidr === '' || strpos($cidr, '/') === false) {
return false;
}
[$subnet, $maskRaw] = explode('/', $cidr, 2);
$subnet = trim($subnet);
$mask = (int)trim($maskRaw);
if (!filter_var($ip, FILTER_VALIDATE_IP) || !filter_var($subnet, FILTER_VALIDATE_IP)) {
return false;
}
if (strpos($ip, ':') !== false || strpos($subnet, ':') !== false) {
if ($mask < 0 || $mask > 128) {
return false;
}
$ipBin = inet_pton($ip);
$netBin = inet_pton($subnet);
if ($ipBin === false || $netBin === false) {
return false;
}
$bytes = intdiv($mask, 8);
$bits = $mask % 8;
if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($netBin, 0, $bytes)) {
return false;
}
if ($bits > 0) {
$maskByte = (~((1 << (8 - $bits)) - 1)) & 0xFF;
if ((ord($ipBin[$bytes]) & $maskByte) !== (ord($netBin[$bytes]) & $maskByte)) {
return false;
}
}
return true;
}
if ($mask < 0 || $mask > 32) {
return false;
}
$ipLong = ip2long($ip);
$netLong = ip2long($subnet);
if ($ipLong === false || $netLong === false) {
return false;
}
$maskLong = $mask === 0 ? 0 : (-1 << (32 - $mask));
return (($ipLong & $maskLong) === ($netLong & $maskLong));
}
/**
* Retrieves a user's TOTP secret from the users file.
*
+2 -2
View File
@@ -6,11 +6,11 @@ namespace FileRise\WebDAV;
* Singleton holder for the current WebDAV username.
*/
class CurrentUser {
private static string $user = 'Unknown';
private static string $user = '';
public static function set(string $u): void {
self::$user = $u;
}
public static function get(): string {
return self::$user;
}
}
}
+5 -2
View File
@@ -181,11 +181,10 @@ class FileRiseDirectory implements ICollection, INode {
// Write directly to FS, then ensure metadata via FileRiseFile::put()
$full = $this->path . DIRECTORY_SEPARATOR . $name;
$content = is_resource($data) ? stream_get_contents($data) : (string)$data;
// Let FileRiseFile handle metadata & overwrite semantics
$fileNode = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
$fileNode->put($content);
$fileNode->put($data);
return $fileNode;
}
@@ -229,6 +228,10 @@ class FileRiseDirectory implements ICollection, INode {
$real = realpath($absPath) ?: $absPath;
if (stripos($real, $realBase) !== 0) return 'root';
if (strlen($real) > strlen($realBase)) {
$next = $real[strlen($realBase)] ?? '';
if ($next !== '/' && $next !== '\\') return 'root';
}
$rel = ltrim(str_replace('\\','/', substr($real, strlen($realBase))), '/');
return ($rel === '' ? 'root' : $rel);
}
+58 -4
View File
@@ -122,10 +122,64 @@ class FileRiseFile implements IFile, INode {
}
// write + metadata (unchanged)
file_put_contents(
$this->path,
is_resource($data) ? stream_get_contents($data) : (string)$data
);
$maxBytes = defined('FR_WEBDAV_MAX_UPLOAD_BYTES') ? (int)FR_WEBDAV_MAX_UPLOAD_BYTES : 0;
if ($maxBytes < 0) {
$maxBytes = 0;
}
if (is_resource($data)) {
$dir = dirname($this->path);
$tmp = $dir . DIRECTORY_SEPARATOR . '.' . basename($this->path) . '.tmp-' . bin2hex(random_bytes(8));
$out = @fopen($tmp, 'wb');
if ($out === false) {
throw new Forbidden('Unable to write file');
}
$written = 0;
while (!feof($data)) {
$chunk = fread($data, 1024 * 1024);
if ($chunk === false) {
fclose($out);
@unlink($tmp);
throw new Forbidden('Unable to write file');
}
if ($chunk === '') {
continue;
}
$len = strlen($chunk);
if ($maxBytes > 0 && ($written + $len) > $maxBytes) {
fclose($out);
@unlink($tmp);
throw new Forbidden('Upload exceeds maximum size');
}
$offset = 0;
while ($offset < $len) {
$wrote = fwrite($out, substr($chunk, $offset));
if ($wrote === false || $wrote === 0) {
fclose($out);
@unlink($tmp);
throw new Forbidden('Unable to write file');
}
$offset += $wrote;
}
$written += $len;
}
fclose($out);
if (!@rename($tmp, $this->path)) {
if ($exists) {
@unlink($this->path);
}
if (!@rename($tmp, $this->path)) {
@unlink($tmp);
throw new Forbidden('Unable to write file');
}
}
} else {
$payload = (string)$data;
if ($maxBytes > 0 && strlen($payload) > $maxBytes) {
throw new Forbidden('Upload exceeds maximum size');
}
file_put_contents($this->path, $payload);
}
$this->updateMetadata($folderKey, $fileName);
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
AuditHook::log($exists ? 'file.edit' : 'file.upload', [