feat(auth): throttle login, register, password reset and Subsonic auth failures (M462 #4976)
test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s

Every password-shaped check was mounted bare, so guessing was limited only
by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them:

- login: 10 failures per account and 50 per address per 15 min, checked
  before the user lookup and bcrypt; 429 with Retry-After. A success clears
  the account's count but not the address's.
- unknown usernames run a dummy bcrypt compare, so timing no longer says
  which accounts exist.
- register: 10 per address per hour; forgot-password: 5 per address and 3
  per email per hour (applied whether or not the email matches); reset: 20
  failed tokens per address per 15 min.
- Subsonic /rest: same limits as login, counting only wrong credentials,
  since clients authenticate on every request.

Web login, register, reset and forgot-password screens say how long to
wait; web and Android carry copy for the rate_limited code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 08:28:29 -04:00
co-authored by Claude Opus 5.5
parent 516413f4ca
commit 3bfddd0862
20 changed files with 528 additions and 16 deletions
+21
View File
@@ -2,8 +2,25 @@ export type ApiError = {
code: string;
message: string;
status: number;
/** Seconds until a 429'd request may be retried, from Retry-After. */
retryAfter?: number;
};
/**
* The sign-in, register and reset screens' wording for a throttled attempt,
* or null when err isn't one. Rounds up to whole minutes: the server's
* windows are minutes long, and "try again in 1 second" after a lockout
* would be untrue the moment it was read.
*/
export function rateLimitMessage(err: unknown): string | null {
const apiErr = err as ApiError | undefined;
if (apiErr?.status !== 429) return null;
const secs = apiErr.retryAfter;
if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.';
const mins = Math.ceil(secs / 60);
return `Too many attempts. Try again in ${mins} minute${mins === 1 ? '' : 's'}.`;
}
export type User = {
id: string;
username: string;
@@ -45,6 +62,10 @@ export async function apiFetch(path: string, init?: RequestInit): Promise<unknow
message = env.message ?? res.statusText;
}
const err: ApiError = { code, message, status: res.status };
if (res.status === 429) {
const retryAfter = Number(res.headers.get('Retry-After'));
if (Number.isFinite(retryAfter) && retryAfter > 0) err.retryAfter = retryAfter;
}
throw err;
}
return body;
+1
View File
@@ -4,6 +4,7 @@
"forbidden": "You don't have permission to do that.",
"not_authorized": "You don't have permission to do that.",
"invalid_credentials": "Wrong username or password.",
"rate_limited": "Too many attempts. Wait a few minutes and try again.",
"wrong_password": "Current password is incorrect.",
"password_too_short": "Password must be at least 8 characters.",
"username_invalid": "That username isn't valid.",
+15 -6
View File
@@ -1,23 +1,29 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { forgotPassword } from '$lib/auth/store.svelte';
import { rateLimitMessage } from '$lib/api/client';
let email = $state('');
let submitted = $state(false);
let submitting = $state(false);
let throttled = $state<string | null>(null);
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
submitting = true;
throttled = null;
try {
await forgotPassword(email);
} catch {
// Swallow — the page always shows the same success message
// regardless of outcome, to mirror the server's no-enumeration
// posture. A failed call must not surface as an unhandled
// rejection in the browser console.
} finally {
submitted = true;
} catch (err) {
// A throttled request says so: the server applies the limit whether
// or not the email is registered, so it reveals nothing, and a
// "check your inbox" for a mail that was never sent would mislead.
// Every other failure is swallowed and shows the same success
// message, mirroring the server's no-enumeration posture.
throttled = rateLimitMessage(err);
submitted = throttled === null;
} finally {
submitting = false;
}
}
@@ -53,6 +59,9 @@
>
{submitting ? 'Sending…' : 'Send reset link'}
</button>
{#if throttled}
<p class="text-sm text-danger" role="alert">{throttled}</p>
{/if}
</form>
{:else}
<p class="text-sm text-text-primary">
+5 -2
View File
@@ -3,7 +3,7 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { login } from '$lib/auth/store.svelte';
import type { ApiError } from '$lib/api/client';
import { rateLimitMessage, type ApiError } from '$lib/api/client';
let username = $state('');
let password = $state('');
@@ -30,7 +30,10 @@
goto(dest, { replaceState: true });
} catch (err) {
const apiErr = err as ApiError;
if (apiErr?.status === 401 && apiErr?.code === 'invalid_credentials') {
const throttled = rateLimitMessage(err);
if (throttled) {
error = throttled;
} else if (apiErr?.status === 401 && apiErr?.code === 'invalid_credentials') {
error = 'Invalid username or password.';
password = '';
} else {
+2 -1
View File
@@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { register } from '$lib/auth/store.svelte';
import { errCode } from '$lib/api/errors';
import { rateLimitMessage } from '$lib/api/client';
let username = $state('');
let password = $state('');
@@ -47,7 +48,7 @@
});
await goto('/', { replaceState: true });
} catch (err: unknown) {
error = errorMessageFor(errCode(err));
error = rateLimitMessage(err) ?? errorMessageFor(errCode(err));
} finally {
submitting = false;
}
@@ -4,6 +4,7 @@
import { pageTitle } from '$lib/branding';
import { resetPassword } from '$lib/auth/store.svelte';
import { errCode } from '$lib/api/errors';
import { rateLimitMessage } from '$lib/api/client';
let newPassword = $state('');
let confirmPassword = $state('');
@@ -29,7 +30,10 @@
await goto('/login?reset=ok', { replaceState: true });
} catch (e: unknown) {
const code = errCode(e);
if (code === 'invalid_token') {
const throttled = rateLimitMessage(e);
if (throttled) {
error = throttled;
} else if (code === 'invalid_token') {
error = 'This reset link is invalid, expired, or already used. Request a new one.';
} else if (code === 'password_too_short') {
error = 'Password must be at least 8 characters.';