From 7d094a6732bbdddb5e6c4ca54da614f764de2ead Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 12:03:04 +0100 Subject: [PATCH] fix(auth): treat SMTP failures like success on enumeration-sensitive calls Refs #37 Co-Authored-By: Claude Sonnet 5.5 --- .../auth/data/auth_failure_mapper.dart | 22 ++++++-- .../features/auth/domain/resend_origin.dart | 42 ++++++++++------ .../auth/data/auth_failure_mapper_test.dart | 44 ++++++++++++++-- .../auth/domain/auth_domain_test.dart | 50 +++++++++++++++++++ .../check_email/check_email_screen_test.dart | 19 +++++++ .../check_email/resend_controller_test.dart | 13 +++++ .../forgot_password_controller_test.dart | 10 ++++ .../forgot_password_screen_test.dart | 8 +++ .../presentation/login/login_screen_test.dart | 25 ++++++++++ .../register/register_controller_test.dart | 13 ++++- .../register/register_screen_test.dart | 14 ++++++ docs/auth-konfiguration.md | 2 + docs/specs/1-auth-registrierung-anmeldung.md | 11 +++- 13 files changed, 248 insertions(+), 25 deletions(-) diff --git a/app/lib/features/auth/data/auth_failure_mapper.dart b/app/lib/features/auth/data/auth_failure_mapper.dart index 6b2398b..e5a7707 100644 --- a/app/lib/features/auth/data/auth_failure_mapper.dart +++ b/app/lib/features/auth/data/auth_failure_mapper.dart @@ -30,9 +30,20 @@ AuthFailure mapAuthError(Object error, AuthOperation operation) { } if (error is AuthRetryableFetchException) { if (operation == AuthOperation.signUp && error.statusCode == '500') { - return AuthFailure.registrationRejected( - reason: registrationRejectionReasonFrom(error.message), - ); + final reason = registrationRejectionReasonFrom(error.message); + // A 500 without a known trigger message is not an input problem (SMTP + // outage, F13): it must not be told apart from success. + return reason == RegistrationRejectionReason.unknown + ? const AuthFailure.unexpected() + : AuthFailure.registrationRejected(reason: reason); + } + // Enumeration-sensitive calls: a server-side failure (HTTP 5xx, e.g. SMTP + // outage, `unexpected_failure`) differs between known and unknown + // addresses. It must not look like "offline", the screens treat it like + // success (Spec #1 F13). Only answers with a status count: a transport + // error has none and says nothing about the account. + if (error.statusCode != null && _isEnumerationSensitive(operation)) { + return const AuthFailure.unexpected(); } // Netzwerkfehler (kein Status) und sonstige 5xx (Gateway, Ausfall). return const AuthFailure.network(); @@ -49,6 +60,11 @@ AuthFailure mapAuthError(Object error, AuthOperation operation) { return const AuthFailure.unexpected(); } +bool _isEnumerationSensitive(AuthOperation operation) => + operation == AuthOperation.signUp || + operation == AuthOperation.resend || + operation == AuthOperation.requestPasswordReset; + AuthFailure _mapApiError(AuthException error, AuthOperation operation) { switch (error.code) { case 'invalid_credentials': diff --git a/app/lib/features/auth/domain/resend_origin.dart b/app/lib/features/auth/domain/resend_origin.dart index 6a29c2a..b9f6340 100644 --- a/app/lib/features/auth/domain/resend_origin.dart +++ b/app/lib/features/auth/domain/resend_origin.dart @@ -9,22 +9,34 @@ enum ResendOrigin { login, } -/// Wendet die Regel aus F12 für „E-Mail erneut senden“ an. +/// Wendet die Regel aus F12/F13 für „E-Mail erneut senden“ an. /// -/// `null` heißt: wie Erfolg behandeln. Aus dem LoginScreen wird -/// `emailSendLimited` zum sichtbaren Drosselungshinweis ([RateLimited]). +/// `null` heißt: wie Erfolg behandeln. Sichtbar bleiben nur Eingabe- und +/// Transportfehler sowie die kontounabhängige IP-Drosselung (Allowlist); +/// alles andere (auch `unexpected`, z. B. SMTP-Ausfall) ist neutral, weil es +/// zwischen bekannter und unbekannter Adresse unterscheiden kann. Aus dem +/// LoginScreen wird `emailSendLimited` zum sichtbaren Drosselungshinweis +/// ([RateLimited]). AuthFailure? resendFailureFor(AuthFailure failure, ResendOrigin origin) { - if (failure is EmailSendLimited) { - return switch (origin) { - ResendOrigin.checkEmail => null, - ResendOrigin.login => const AuthFailure.rateLimited(), - }; - } - return failure; + return switch (failure) { + EmailSendLimited() => + origin == ResendOrigin.login ? const AuthFailure.rateLimited() : null, + InvalidEmail() || RateLimited() || NetworkFailure() => failure, + _ => null, + }; } -/// Registrierung und Passwort-Reset (freie Adresseingabe): `emailSendLimited` -/// zählt wie Erfolg (F12, KTO-01 Kriterium 4). Liefert `null` für „wie -/// Erfolg“, sonst den Fehler. -AuthFailure? openEmailInputFailureFor(AuthFailure failure) => - failure is EmailSendLimited ? null : failure; +/// Registrierung und Passwort-Reset (freie Adresseingabe, F12/F13): Sichtbar +/// bleiben nur Eingabefehler (`invalidEmail`, `weakPassword`, +/// `registrationRejected` mit bekanntem Grund), Transportfehler und die +/// kontounabhängige IP-Drosselung (Allowlist). Alles andere, insbesondere +/// `emailSendLimited` und `unexpected` (SMTP-Ausfall), zählt wie Erfolg +/// (KTO-01 Kriterium 4). Liefert `null` für „wie Erfolg“, sonst den Fehler. +AuthFailure? openEmailInputFailureFor(AuthFailure failure) => switch (failure) { + InvalidEmail() || + WeakPassword() || + RegistrationRejected() || + RateLimited() || + NetworkFailure() => failure, + _ => null, +}; diff --git a/app/test/features/auth/data/auth_failure_mapper_test.dart b/app/test/features/auth/data/auth_failure_mapper_test.dart index c018e03..bab19ab 100644 --- a/app/test/features/auth/data/auth_failure_mapper_test.dart +++ b/app/test/features/auth/data/auth_failure_mapper_test.dart @@ -168,16 +168,50 @@ void main() { ); }); - test('HTTP 500 on signUp with unknown body keeps registrationRejected', () { + test('F13: HTTP 500 on signUp with unknown body (SMTP) -> unexpected', () { expect( mapAuthError( - AuthRetryableFetchException(message: 'oops', statusCode: '500'), + AuthRetryableFetchException( + message: '{"code":"unexpected_failure","msg":"Error sending"}', + statusCode: '500', + ), AuthOperation.signUp, ), - const AuthFailure.registrationRejected(), + const AuthFailure.unexpected(), ); }); + test('F13: HTTP 5xx on reset and resend -> unexpected, not network', () { + for (final op in [ + AuthOperation.requestPasswordReset, + AuthOperation.resend, + ]) { + for (final status in ['500', '502', '504']) { + expect( + mapAuthError( + AuthRetryableFetchException(message: 'x', statusCode: status), + op, + ), + const AuthFailure.unexpected(), + reason: '$op $status', + ); + } + } + }); + + test('F13: a transport error without status stays network', () { + for (final op in [ + AuthOperation.signUp, + AuthOperation.requestPasswordReset, + AuthOperation.resend, + ]) { + expect( + mapAuthError(AuthRetryableFetchException(message: 'x'), op), + const AuthFailure.network(), + ); + } + }); + test('trigger messages map to reasons', () { expect( registrationRejectionReasonFrom('age_confirmation_required'), @@ -203,7 +237,7 @@ void main() { ); }); - test('other 5xx on signUp (gateway) -> network', () { + test('F13: other 5xx on signUp (gateway) -> unexpected (neutral)', () { expect( mapAuthError( AuthRetryableFetchException( @@ -212,7 +246,7 @@ void main() { ), AuthOperation.signUp, ), - const AuthFailure.network(), + const AuthFailure.unexpected(), ); }); diff --git a/app/test/features/auth/domain/auth_domain_test.dart b/app/test/features/auth/domain/auth_domain_test.dart index d7b118f..53ccd85 100644 --- a/app/test/features/auth/domain/auth_domain_test.dart +++ b/app/test/features/auth/domain/auth_domain_test.dart @@ -18,6 +18,28 @@ void main() { ); }); + test('F13: server-side failures (SMTP outage) count as success', () { + for (final origin in ResendOrigin.values) { + expect( + resendFailureFor(const AuthFailure.unexpected(), origin), + isNull, + ); + expect( + resendFailureFor(const AuthFailure.emailNotConfirmed(), origin), + isNull, + ); + } + }); + + test('input errors stay visible', () { + for (final origin in ResendOrigin.values) { + expect( + resendFailureFor(const AuthFailure.invalidEmail(), origin), + const AuthFailure.invalidEmail(), + ); + } + }); + test('other failures pass through unchanged', () { for (final origin in ResendOrigin.values) { expect( @@ -40,6 +62,34 @@ void main() { ); }); + test( + 'F13: unexpected (SMTP outage) and unknown failures count as success', + () { + for (final failure in [ + const AuthFailure.unexpected(), + const AuthFailure.emailNotConfirmed(), + const AuthFailure.invalidCredentials(), + const AuthFailure.linkInvalidOrExpired(), + ]) { + expect(openEmailInputFailureFor(failure), isNull, reason: '$failure'); + } + }, + ); + + test('F13 allowlist: input, transport and IP throttling stay visible', () { + for (final failure in [ + const AuthFailure.invalidEmail(), + const AuthFailure.weakPassword([WeakPasswordReason.length]), + const AuthFailure.registrationRejected( + reason: RegistrationRejectionReason.displayName, + ), + const AuthFailure.network(), + const AuthFailure.rateLimited(), + ]) { + expect(openEmailInputFailureFor(failure), failure); + } + }); + test('rateLimited stays visible', () { expect( openEmailInputFailureFor(const AuthFailure.rateLimited()), diff --git a/app/test/features/auth/presentation/check_email/check_email_screen_test.dart b/app/test/features/auth/presentation/check_email/check_email_screen_test.dart index 01411d8..720c82e 100644 --- a/app/test/features/auth/presentation/check_email/check_email_screen_test.dart +++ b/app/test/features/auth/presentation/check_email/check_email_screen_test.dart @@ -99,6 +99,25 @@ void main() { expect(limited, contains(_de.authCheckEmailResent)); }); + testWidgets('F13: an SMTP failure (unexpected) gives the same feedback', ( + tester, + ) async { + await open(tester); + await tapResend(tester); + final success = visibleTexts(tester); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => failed(const AuthFailure.unexpected())); + await open(tester); + await tapResend(tester); + final smtp = visibleTexts(tester); + + expect(smtp, success); + expect(smtp, isNot(contains(_de.authErrorUnexpected))); + }); + testWidgets('IP rate limit shows the rate-limit hint', (tester) async { when(() => repository.resendConfirmation(any())) .thenAnswer((_) async => failed(const AuthFailure.rateLimited())); diff --git a/app/test/features/auth/presentation/check_email/resend_controller_test.dart b/app/test/features/auth/presentation/check_email/resend_controller_test.dart index d9d0325..a8f20f2 100644 --- a/app/test/features/auth/presentation/check_email/resend_controller_test.dart +++ b/app/test/features/auth/presentation/check_email/resend_controller_test.dart @@ -74,6 +74,19 @@ void main() { }); }); + group('F13: SMTP outage (unexpected) counts as sent', () { + for (final origin in ResendOrigin.values) { + test('from $origin', () async { + stub(failed(const AuthFailure.unexpected())); + + await notifier().resend('anna@example.com', origin: origin); + + expect(state().sent, isTrue); + expect(state().failure, isNull); + }); + } + }); + group('KTO-01 Kriterium 4, 6 (F12): emailSendLimited', () { test('from the check-email screen counts as sent', () async { stub(failed(const AuthFailure.emailSendLimited())); diff --git a/app/test/features/auth/presentation/forgot_password/forgot_password_controller_test.dart b/app/test/features/auth/presentation/forgot_password/forgot_password_controller_test.dart index b2c1210..e6cf6b9 100644 --- a/app/test/features/auth/presentation/forgot_password/forgot_password_controller_test.dart +++ b/app/test/features/auth/presentation/forgot_password/forgot_password_controller_test.dart @@ -89,6 +89,16 @@ void main() { expect(state().shownEmailError, isFalse); }); + test('F13: unexpected (SMTP outage, HTTP 5xx) counts as success', () async { + stubReset(failed(const AuthFailure.unexpected())); + notifier().emailChanged('anna@example.com'); + + expect(await notifier().submit(), isTrue); + + expect(state().bannerFailure, isNull); + expect(state().shownEmailError, isFalse); + }); + test('the IP rate limit is a visible failure', () async { stubReset(failed(const AuthFailure.rateLimited())); notifier().emailChanged('anna@example.com'); diff --git a/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart b/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart index 0fda660..b0d6141 100644 --- a/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart +++ b/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart @@ -173,8 +173,16 @@ void main() { 'bekannt@example.com', ); + // F13: SMTP outage for an existing account (HTTP 500). + final smtp = await pageFor( + failed(const AuthFailure.unexpected()), + 'bekannt@example.com', + ); + expect(unknown, known); expect(limited, known); + expect(smtp, known); + expect(smtp, isNot(contains(_de.authErrorUnexpected))); expect(known, contains(_de.authCheckEmailBodyReset)); expect(known, contains(_de.authCheckEmailDelayHint)); expect(known, isNot(contains(_de.authErrorRateLimited))); diff --git a/app/test/features/auth/presentation/login/login_screen_test.dart b/app/test/features/auth/presentation/login/login_screen_test.dart index e49bae5..cb634e2 100644 --- a/app/test/features/auth/presentation/login/login_screen_test.dart +++ b/app/test/features/auth/presentation/login/login_screen_test.dart @@ -388,6 +388,31 @@ void main() { expect(find.byKey(const Key('login_resent_banner')), findsNothing); }); + testWidgets('F13: an SMTP failure on resend shows the success feedback', ( + tester, + ) async { + await open(tester); + await fill(tester); + await submit(tester); + await tester.tap(find.text(_de.authActionResendConfirmation)); + await tester.pumpAndSettle(); + final success = visibleTexts(tester); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => failed(const AuthFailure.unexpected())); + await open(tester); + await fill(tester); + await submit(tester); + await tester.tap(find.text(_de.authActionResendConfirmation)); + await tester.pumpAndSettle(); + + expect(visibleTexts(tester), success); + expect(find.byKey(const Key('login_resent_banner')), findsOneWidget); + expect(find.text(_de.authErrorUnexpected), findsNothing); + }); + testWidgets('the resend action is locked while sending', (tester) async { final semanticsHandle = tester.ensureSemantics(); final completer = Completer>(); diff --git a/app/test/features/auth/presentation/register/register_controller_test.dart b/app/test/features/auth/presentation/register/register_controller_test.dart index 1eb6ed0..32725e6 100644 --- a/app/test/features/auth/presentation/register/register_controller_test.dart +++ b/app/test/features/auth/presentation/register/register_controller_test.dart @@ -374,10 +374,21 @@ void main() { }); }); + group('F13: SMTP outage looks like success', () { + test('unexpected (HTTP 5xx) continues like a 200', () async { + when(() => repository.signUp(any())) + .thenAnswer((_) async => failed(const AuthFailure.unexpected())); + final container = makeContainer(); + fillValid(notifier(container)); + + expect(await notifier(container).submit(), isTrue); + expect(stateOf(container).bannerFailure, isNull); + }); + }); + group('Zustände Kriterium 2, 3: failures keep the input', () { for (final failure in [ const AuthFailure.network(), - const AuthFailure.unexpected(), const AuthFailure.registrationRejected(), ]) { test('${failure.runtimeType} becomes a banner', () async { diff --git a/app/test/features/auth/presentation/register/register_screen_test.dart b/app/test/features/auth/presentation/register/register_screen_test.dart index 95026c1..89db8f2 100644 --- a/app/test/features/auth/presentation/register/register_screen_test.dart +++ b/app/test/features/auth/presentation/register/register_screen_test.dart @@ -322,6 +322,20 @@ void main() { expect(limited, isNot(contains(_de.authErrorRateLimited))); expect(limited, contains(_de.authCheckEmailDelayHint)); }); + + testWidgets('F13: an SMTP failure (unexpected) shows the same texts', ( + tester, + ) async { + final success = await textsAfterSubmit(tester, ok()); + final smtp = await textsAfterSubmit( + tester, + failed(const AuthFailure.unexpected()), + ); + + expect(smtp, success); + expect(smtp, isNot(contains(_de.authErrorUnexpected))); + expect(smtp.join(' '), isNot(contains('example.com'))); + }); }); group('Zustände Kriterium 2, 3: loading, errors, offline', () { diff --git a/docs/auth-konfiguration.md b/docs/auth-konfiguration.md index 62701c0..0f4c6af 100644 --- a/docs/auth-konfiguration.md +++ b/docs/auth-konfiguration.md @@ -159,6 +159,7 @@ Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken: - [ ] Mindestabstand 60 s zwischen Mails je Nutzer (AUTH-CFG-08) und Link-Gültigkeit 1 h (AUTH-CFG-07) auf Staging geprüft. - [ ] `GOTRUE_RATE_LIMIT_EMAIL_SENT=30` gesetzt und Monitoring auf 429 `over_email_send_rate_limit` aktiv (AUTH-CFG-09, R3). - [ ] Nach Passwortänderung kommt keine Benachrichtigungs-Mail (AUTH-CFG-19). +- [ ] **Monitoring/Alarm auf SMTP-Fehler** (Release-Blocker, D1, Issue #37/F13): GoTrue-Logs mit `unexpected_failure`/HTTP 500 bei `/recover`, `/resend` und `/signup` alarmieren. Die App zeigt bei einem SMTP-Ausfall bewusst die neutrale „Prüfe dein Postfach“-Seite (Konten-Schutz); Nutzer bekommen keinen Hinweis und keine Mail, nur der Betrieb sieht den Ausfall. Die App loggt zusätzlich Operation, Fehlerart, Status und Code (ohne E-Mail). - [ ] SMTP-Anbieter mit AVV und Absender-Domain entschieden und eingerichtet (AUTH-CFG-22, F7). - [ ] SPA-Rewrite: `/auth/confirm` wird von der Web-App ausgeliefert. - [ ] Rechtstexte-URLs und `TERMS_VERSION` final (R11); Bundle-/Application-ID final (R15). @@ -169,6 +170,7 @@ Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken: - **Keine Wartezeit-Angabe:** Die 429-Antwort trägt kein `Retry-After`; die App nennt keine Dauer. - **Drosselung nur je IP**, nicht je Konto. - **Mail-Limit instanzweit** (30/h): Angreifer können Mailausfälle auslösen. Während der Sperre ist die Existenz eines Kontos über 429 vs. 200 erkennbar (R3, akzeptiert F12). +- **SMTP-Ausfall:** Auf API-Ebene unterscheiden sich bekannte und unbekannte Adressen (500 vs. 200, Spike #37). Die App zeigt neutral (F13, R16); Restrisiko rohe API akzeptiert. - **Enumeration auf API-Ebene:** Signup mit unbestätigter Adresse gibt den echten Nutzer zurück, mit bestätigter einen erfundenen (R6). Die App zeigt in beiden Fällen denselben Bildschirm. - **Abgemeldete Geräte nach Reset:** Access-Tokens gelten bis zu 60 min weiter (R8, F1). - **Abgebrochene Recovery** bleibt nach einem Reload als normale Anmeldung bestehen (R12). diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md index e6dccc2..fa52159 100644 --- a/docs/specs/1-auth-registrierung-anmeldung.md +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -166,6 +166,7 @@ Alle Werte stehen identisch in `supabase/config.toml` (lokal/CI) und als GoTrue- | `otp_expired`, `invalid` / 403 bei `verifyOTP` | `AuthFailure.linkInvalidOrExpired` | „Der Link ist abgelaufen oder wurde schon benutzt.“ + Aktion „Neuen Link anfordern“ | | `email_address_invalid`, `validation_failed` | `AuthFailure.invalidEmail` | am E-Mail-Feld | | HTTP 500 bei `signUp` (Trigger; Body `{"code":"P0001","message":""}`, Header `X-Sb-Error-Code: unexpected_failure`) | `AuthFailure.registrationRejected` | allgemein: „Registrierung nicht möglich. Bitte prüfe deine Angaben.“ (Im Normalfall verhindert der Client das.) **Mapping-Regel:** maßgeblich ist der HTTP-Status 500 beim `signUp`, **nicht** `code == 'P0001'` (welches Feld `AuthException.code` in `supabase_flutter` füllt, ist offen; vermutlich `unexpected_failure`). Die Trigger-Meldung (`display_name_invalid`, `age_confirmation_required`, `terms_acceptance_required`, `terms_version_required`) steht in `AuthException.message` und darf nur fürs Log und für Zusatzhinweise genutzt werden; das Format ist nicht zugesichert. A1 prüft, welche Felder der Client tatsächlich befüllt; T3 sichert das Body-Format mit einem API-Test ab. | +| HTTP 5xx **mit Status** bei `signUp` (ohne bekannte Trigger-Meldung), `resetPasswordForEmail`, `resend` (z. B. SMTP-Ausfall, `unexpected_failure`; **F13**) | `AuthFailure.unexpected` | **Kein Fehler sichtbar (F13):** Register-, ForgotPassword- und Resend-Controller behandeln ihn wie Erfolg (neutraler CheckEmailScreen bzw. Resend-Bestätigung, auch im LoginScreen). Das Repository loggt Operation, Fehlerart, Status und Code (ohne E-Mail). Sichtbar bleiben nur eine **Allowlist**: `invalidEmail`, `weakPassword`, `registrationRejected` (bekannter Grund), `network` (ohne Status) und `rateLimited`. | | `AuthRetryableFetchException`, `SocketException`, `ClientException`, Timeout | `AuthFailure.network` | Offline-Hinweis „Keine Verbindung. Für diesen Schritt brauchst du Internet.“ | | sonst | `AuthFailure.unexpected` | allgemein + „Erneut versuchen“; Details nur ins Log (ohne E-Mail und Passwort) | @@ -427,7 +428,7 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge |---|---|---|---| | R1 | Supabase-Standardwerte unterscheiden sich je nach Quelle (Binary, CLI, Docker) und Version | Tests sind grün, Produktion verhält sich anders (Krit. 7, 8) | Alle Werte pinnen (E8); B0 hat CLI 3.0.0 und Image `v2.197.0` verifiziert; Image pinnen (D1) und bei jedem GoTrue-Update B0 (c), (d), (f) wiederholen; Release-Checkliste | | R2 | **Drosselung (Krit. 7) wirkt nur mit `GOTRUE_RATE_LIMIT_HEADER`** (B0, A2). Ohne die Einstellung gibt es gar keine IP-Drosselung; auch mit ihr werden Requests **ohne** den Header nicht gedrosselt. Fehlt der Proxy oder ist GoTrue direkt erreichbar, ist die Drosselung umgehbar; hängt der Proxy den Header nur an, fälschen Clients ihre IP. Die 429-Antwort trägt kein `Retry-After`. Lokal (CLI) gibt es keine Drosselung. | Passwort-Raten ungebremst (Krit. 7), oder Umgehung durch gefälschte IPs | AUTH-CFG-13 Pflicht; D1: Proxy überschreibt den Header, GoTrue nur über den Proxy erreichbar; CI-Test gegen Extra-Container (D2, T3, F11); Release-Checkliste am echten Gateway (31 Fehllogins, gefälschter Header wird überschrieben). App-Text nennt keine Dauer. | -| R3 | `email_sent` ist **instanzweit** (B0 (d) bestätigt, 30 Mails/h) und wirkt nur über das Env, in der lokalen CLI nicht | Ab der 31. Mail pro Stunde gehen keine Mails mehr raus; Angreifer können das billig auslösen. **Seitenkanal:** Während der Sperre antwortet `/recover` für bestehende Adressen 429, für unbekannte 200 (Signup abgeleitet umgekehrt, siehe 4.3); über die rohe API wird die Existenz eines Kontos erkennbar. Nebenwirkung der Gegenmaßnahme: Nutzer erfahren in der App nicht, dass ihre Mail wegen der Sperre verzögert ist oder ausbleibt. | 30/h reicht laut Chris zum Start (F4). **F12 (Chris, 2026-11-02):** In der App verdeckt: `over_email_send_rate_limit` bei Registrierung und Reset führt zum selben neutralen CheckEmailScreen wie 200, „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt ebenso neutral (4.4); nur nach Login mit richtigem Passwort (Krit. 6) erscheint der Drosselungshinweis. Krit. 4 bleibt in der App erfüllt; der neutrale Zusatz „kann einige Minuten dauern“ steht immer da. Auf API-Ebene akzeptiert. Der Angriff erzeugt vor allem Ausfälle. Monitoring auf 429 `over_email_send_rate_limit`, Wert bei Bedarf erhöhen; Proxy-Lösung erst bei Bedarf. Nicht automatisiert getestet (lokal wirkungslos). | +| R3 | `email_sent` ist **instanzweit** (B0 (d) bestätigt, 30 Mails/h) und wirkt nur über das Env, in der lokalen CLI nicht | Ab der 31. Mail pro Stunde gehen keine Mails mehr raus; Angreifer können das billig auslösen. **Seitenkanal:** Während der Sperre antwortet `/recover` für bestehende Adressen 429, für unbekannte 200 (Signup abgeleitet umgekehrt, siehe 4.3); über die rohe API wird die Existenz eines Kontos erkennbar. Nebenwirkung der Gegenmaßnahme: Nutzer erfahren in der App nicht, dass ihre Mail wegen der Sperre verzögert ist oder ausbleibt. | 30/h reicht laut Chris zum Start (F4). **F12 (Chris, 2026-11-02):** In der App verdeckt: `over_email_send_rate_limit` bei Registrierung und Reset führt zum selben neutralen CheckEmailScreen wie 200, „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt ebenso neutral (4.4); nur nach Login mit richtigem Passwort (Krit. 6) erscheint der Drosselungshinweis. Krit. 4 bleibt in der App erfüllt; der neutrale Zusatz „kann einige Minuten dauern“ steht immer da. Auf API-Ebene akzeptiert. Der Angriff erzeugt vor allem Ausfälle. Monitoring auf 429 `over_email_send_rate_limit`, Wert bei Bedarf erhöhen; Proxy-Lösung erst bei Bedarf. Nicht automatisiert getestet (lokal wirkungslos). **F13 (SMTP-Ausfall):** gleiches Muster, siehe R16. | | R4 | GoTrue hat keinen Schalter gegen Magic Link/OTP (`/otp`) | Mails außerhalb von Krit. 11, Konten ohne Passwort | Gateway-Sperre (AUTH-CFG-20, D1). B0 (A7): `/otp` legt mit gültigen Metadaten Konten an, der Trigger schützt also nicht. Lokal nicht absicherbar, deshalb nur dokumentiert | | R5 | Self-hosted GoTrue lädt Mailvorlagen per URL; bei Fehlern still auf die englische Standardvorlage | Krit. 11 verletzt, ohne dass es auffällt | Vorlagen-URL im Health-Check, Release-Checkliste: Testmail de/en auf Staging | | R6 | Enumeration auf API-Ebene: (a) Signup mit unbestätigter Adresse gibt den echten Nutzer zurück, mit bestätigter Adresse einen erfundenen (B0 bestätigt). (b) **Fehlkonfiguration:** Mit `GOTRUE_SMS_AUTOCONFIRM=true` (CLI-Standard, Self-Hosting-Vorlage `ENABLE_PHONE_AUTOCONFIRM=true`) liefert Signup mit bestätigter Adresse 422 `user_already_exists` (B0, A1). (c) Seitenkanal bei erschöpftem Mail-Limit, siehe R3. | (a) Mit Skripten lässt sich die Existenz unbestätigter Konten erkennen. (b) Existenz bestätigter Konten offen lesbar, Krit. 4 auf API-Ebene gebrochen. | (a) Supabase-Standardverhalten; die App verrät nichts (Krit. 4 bezieht sich auf das Erleben in der App). Bei Bedarf später Captcha oder eigener Proxy. (b) AUTH-CFG-24 in `config.toml` und Env; API-Regressionstest (7.1 Krit. 4); Release-Checkliste. (c) in der App verdeckt (neutraler Screen bei Registrierung, Reset und Resend auf dem CheckEmailScreen, 4.4), auf API-Ebene akzeptiert (F12, R3). | @@ -440,6 +441,7 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge | R13 | Drosselungstests leeren IP-Buckets für bis zu 5 min | Nachfolgende Login-Tests scheitern zufällig | Weitgehend entschärft: Drosselungstests laufen gegen den Extra-Container (D2) mit eigenen, im Speicher gehaltenen Buckets; die CLI drosselt ohnehin nicht. Im Projekt `throttle` nutzt jeder Test eine eigene zufällige `X-Forwarded-For`-IP (simuliert den Proxy, gewollt). Gegen die gemeinsame CLI-Instanz werden **keine** gefälschten `X-Forwarded-For`-Header verwendet. | | R14 | Ohne HIBP prüft nur der Client die Liste häufiger Passwörter (F2) | Über die rohe API lassen sich häufige Passwörter mit mindestens 10 Zeichen setzen | Bewusst akzeptiert; der Server erzwingt die Mindestlänge 10, die Drosselung (AUTH-CFG-10) bremst Rateversuche | | R15 | Bundle-/Application-ID ist ein Platzhalter (F8) | Nach der ersten Store-Veröffentlichung nicht mehr änderbar | Vor dem Store-Release festlegen (Release-Checkliste) | +| R16 | **SMTP-Ausfall verrät Konten auf API-Ebene** (Spike #37/#38, GoTrue v2.197.0): `/recover` bekannt 500 `unexpected_failure` / unbekannt 200; `/resend` unbestätigt 500 / unbekannt 200; `/signup` neu 500, bestätigt 200. Kein Zeit-Seitenkanal (< 60 ms). Offen: SMTP, der die Verbindung annimmt, aber nicht antwortet (Timeout). | Konten-Enumeration über die rohe API; in der App durch F13 verdeckt. Nebenwirkung: Bei echtem SMTP-Ausfall erfahren Nutzer nichts und warten auf eine Mail. | **F13 (Chris, 2026-11-03, Option 1):** Die App behandelt jeden Nicht-Eingabe-, Nicht-Transport-Fehler dieser Aufrufe wie Erfolg (4.4). **Restrisiko rohe API bleibt akzeptiert** (wie R3, R6). **Betrieb:** Alarm/Monitoring auf GoTrue-Logs `unexpected_failure`/500 bei recover/resend/signup (`docs/auth-konfiguration.md` § 5, Release-Blocker). Der neutrale Zusatz „kann einige Minuten dauern“ bleibt. | | R16 | SMTP-Anbieter und Absender-Domain sind offen (F7) | Ohne SMTP keine Mails auf Staging/Prod | Lokal Mailpit; D1 ist Release-Blocker, kein Merge-Blocker | ### 9.2 Fragen an Chris (alle entschieden am 2026-11-02) @@ -458,6 +460,7 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge | F10 | Fundament als eigenes Issue? | entschieden, umgesetzt | Ja: Issue #31 angelegt, #1 hängt davon ab | E1, 5.1, 8.1, 8.4 | | F11 | (Spike Q1) Krit. 7: Wo läuft der Drosselungstest, da die CLI keine IP-Drosselung kann? | entschieden (2026-11-02) | Im CI gegen einen zusätzlich gestarteten GoTrue-Container mit gesetztem `GOTRUE_RATE_LIMIT_HEADER`; zusätzlich manuelle Prüfung am echten Gateway vor dem Release | 7, 7.1 Krit. 7, D2, T3, R2, R13 | | F12 | (Spike Q2) Seitenkanal bei erschöpftem Mail-Limit (`/recover`: bestehende Adresse 429, unbekannte 200) | entschieden (2026-11-02) | In der App verdeckt: Bei `over_email_send_rate_limit` zeigen Registrierung und Passwort-Reset denselben neutralen Screen „Prüfe dein Postfach … falls …“ (Krit. 4 bleibt in der App erfüllt), mit neutralem Zusatz „kann einige Minuten dauern“. Ergänzt (Chris, 2026-11-02): Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt neutral; nach Login mit richtigem Passwort (Krit. 6) bleibt der Drosselungshinweis. Über die rohe API bleibt der Kanal sichtbar: akzeptiert und als Risiko dokumentiert (Limit bleibt bei 30/h laut F4). | 4.2, 4.3, 4.4, 5.4, 7.1 Krit. 4, R3, R6 | +| F13 | (Issue #37, Spike #38) SMTP-Ausfall verrät, ob ein Konto existiert (500 vs. 200 bei `/recover`, `/resend`, `/signup`) | entschieden (2026-11-03, Chris: Option 1) | Neutrale Seite im Client: Jeder Fehler dieser Aufrufe, der nicht von der Eingabe oder vom Transport kommt und nicht die IP-Drosselung ist, zählt wie Erfolg (analog F12), auch „E-Mail erneut senden“ im LoginScreen. Das Logging bleibt über das zentrale Logger-Utility. Betriebshinweis: Monitoring auf SMTP-Fehler. | 4.4, R16, `docs/auth-konfiguration.md` § 5 | Es gibt keine offenen fachlichen Fragen mehr, die die Umsetzung von #1 blockieren. F7 blockiert nur das Release. @@ -520,3 +523,9 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien 2. **Neue Route `/forgot-password/check-email` (5.3):** Der Modus (Bestätigung/Reset) steht im Pfad statt im `extra`, damit er einen Web-Reload übersteht. Die Adresse wird im Modus Reset nicht übergeben. 3. **Kein Resend im Modus Reset** (wie 5.4): „Andere E-Mail-Adresse eingeben“ statt „E-Mail erneut senden“. `ResendOrigin.checkEmail` gilt weiter nur für den Modus Bestätigung. 4. **Erfolgstext Krit. 9** unverändert (kein „sofort“, ehrlich zu F1), dazu der Knopf „Weiter“; `AppBanner` um `actionEnabled` erweitert (Resend im LoginScreen bleibt beim Senden sichtbar, aber deaktiviert); `from` wird zwischen Login, Registrierung und „Passwort vergessen“ weitergegeben. + +### 2026-11-03 – F13 SMTP-Ausfall neutral behandeln (Issue #37, Branch `fix/37-smtp-neutral`) + +1. **Entscheidung Chris (Option 1):** Bei `recover`, `resend` und `signUp` zeigt die App für jeden Fehler, der weder Eingabe- noch Transportfehler noch IP-Drosselung ist (insbesondere HTTP 5xx / `unexpected_failure`), dieselbe neutrale Seite wie bei Erfolg. Umsetzung als Allowlist in `openEmailInputFailureFor` und `resendFailureFor`; der Mapper liefert 5xx mit Status bei diesen Aufrufen als `unexpected` (statt `network`), ein `signUp`-500 ohne bekannte Trigger-Meldung ebenfalls (statt `registrationRejected`). +2. **Login-Resend (Krit. 6):** Auch dort ist ein SMTP-Fehler neutral (Spike: `/resend` bekannt 500 / unbekannt 200). `emailSendLimited` zeigt dort weiter den Drosselungshinweis (F12). +3. **4.4, R16, F13** ergänzt; Betriebshinweis in `docs/auth-konfiguration.md`.