security: fix brute-force protection, token history leak, streaming XSS, borg password persistence, and missing cache headers

Agent-Logs-Url: https://github.com/nextcloud/all-in-one/sessions/f1016d36-0771-46e0-992c-95ce22594414

Co-authored-by: szaimen <42591237+szaimen@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-04 09:56:38 +00:00
committed by GitHub
parent 99ea91c5ef
commit 3e72f06d32
4 changed files with 53 additions and 5 deletions

View File

@@ -17,17 +17,45 @@ readonly class LoginController {
) {
}
/**
* Maximum number of failed login attempts allowed within the rate-limit window.
*/
private const int MAX_FAILED_ATTEMPTS = 10;
/**
* Duration in seconds during which failed attempts are counted (and for which a lockout lasts).
*/
private const int RATE_LIMIT_WINDOW_SEC = 300;
public function TryLogin(Request $request, Response $response, array $args) : Response {
if (!$this->dockerActionManager->isLoginAllowed()) {
$response->getBody()->write("The login is blocked since Nextcloud is running.");
return $response->withHeader('Location', '.')->withStatus(422);
}
// Per-IP rate limiting: block after MAX_FAILED_ATTEMPTS failures within RATE_LIMIT_WINDOW_SEC.
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
$rateLimitKey = 'login_attempts_' . hash('sha256', $ip);
$attempts = (int)(apcu_fetch($rateLimitKey) ?: 0);
if ($attempts >= self::MAX_FAILED_ATTEMPTS) {
// Keep a delay even when blocked so the 429 itself isn't a timing oracle.
sleep(5);
$response->getBody()->write("Too many failed login attempts. Please try again later.");
return $response->withHeader('Location', '.')->withStatus(429);
}
$password = $request->getParsedBody()['password'] ?? '';
if($this->authManager->CheckCredentials($password)) {
// Clear the counter on success.
apcu_delete($rateLimitKey);
$this->authManager->SetAuthState(true);
return $response->withHeader('Location', '.')->withStatus(201);
}
// Increment the failed-attempts counter (expires after RATE_LIMIT_WINDOW_SEC seconds).
apcu_store($rateLimitKey, $attempts + 1, self::RATE_LIMIT_WINDOW_SEC);
// Punish failed auth attempts with a delay, as a very simple means against bots.
sleep(5);
@@ -39,7 +67,17 @@ readonly class LoginController {
$token = $request->getQueryParams()['token'] ?? '';
if($this->authManager->CheckToken($token)) {
$this->authManager->SetAuthState(true);
return $response->withHeader('Location', '../..')->withStatus(302);
// Return a minimal HTML page that uses JavaScript to replace the browser's
// current history entry (removing the token from it) before navigating to
// the main AIO page. This prevents the token from remaining in browser history.
$response->getBody()->write(
'<!DOCTYPE html>' .
'<html lang="en">' .
'<head><script src="../../clean-history.js"></script></head>' .
'<body></body>' .
'</html>'
);
return $response->withHeader('Content-Type', 'text/html; charset=utf-8')->withStatus(200);
}
// Punish failed auth attempts with a delay, as a very simple means against bots.