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
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user