diff --git a/AGENTS.md b/AGENTS.md index 0fde01985..2b9a236df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1124,6 +1124,23 @@ The contract: lies to the user in the missed-branch case ("If that email is registered, a link has been sent" → it wasn't, but they don't get told), which is the intended behavior. +- **Mail the stored address, never the request value.** After the + lookup matches, every downstream step keys on the stored row: + `Mail::send($row['email'], …)`, links built from `$row['email']`, + `UPDATE … WHERE aid = :aid`. `:prefix_admins.email` uses the + table's case- and accent-insensitive collation + (`utf8mb4_general_ci`), so the request value can differ from the + stored address and still match. The stored address is the only + one the account owner has proven they control. `email` is also + not unique, so never `UPDATE … WHERE email = :email` either: it + rolls every collation-equal duplicate. +- **Compare secrets byte-exact in PHP, not via SQL.** The same + collation makes `WHERE validate = :token` case-insensitive. + Look the row up by its identifier, then + `hash_equals((string) $row['validate'], (string) $token)`, and + treat a NULL / empty stored secret as never matching. Reference + shape: the `?email=…&validation=…` branch in + `web/pages/page.lostpassword.php`. **Caveat: response-time differential remains.** The matched branch performs a DB write + an SMTP round-trip; the unmatched branch @@ -1147,7 +1164,15 @@ the canonical "byte-for-byte identical" assertion) + `web/tests/api/__snapshots__/auth/lost_password_generic.json` (the locked wire-format) + `web/tests/e2e/specs/flows/lostpassword-toast.spec.ts` (the chrome-side parity test — same painted toast for known + -unknown emails). +unknown emails). The stored-address / byte-exact rules are pinned by +`AuthTest::testLostPasswordMailsOnlyTheStoredAddressForCollationVariants` +(case- and accent-insensitive request values; recipients captured via +the test-only `Sbpp\Mail\Mailer::overrideTransportForTests()` seam) ++ `testLostPasswordRollsTokenOnlyOnTheMatchedAidWhenEmailsCollide`, +and on the page side by the "Collation-variant email" + "Token with +different letter case" tests in `lostpassword-toast.spec.ts` +(mailpit-backed, per-run admin rows via +`seedLostpasswordE2e(email)`). **`api_auth_login` (#59):** the empty-password check runs **before** the user lookup, so `{username: 'admin', password: ''}` and diff --git a/CHANGELOG.md b/CHANGELOG.md index 028484ccc..54801f5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ Legend: ? = Other stuff ``` +(26/10/26): Version 2.2.1-srcdslab +----------------------- +``` +01. ! Security: password reset mails go to the address stored on the + admin row, never to a case- or accent-variant of it typed in + the form; the reset token is rolled by admin id +02. ! Security: the reset token is compared byte-exact (hash_equals) +``` + (22/10/26): Version 2.1.26-srcdslab ----------------------- ``` diff --git a/web/api/handlers/auth.php b/web/api/handlers/auth.php index b66a4d48a..c10daff7e 100644 --- a/web/api/handlers/auth.php +++ b/web/api/handlers/auth.php @@ -175,7 +175,11 @@ function api_auth_lost_password(array $params): array $email = (string)($params['email'] ?? ''); - $GLOBALS['PDO']->query("SELECT aid, user FROM `:prefix_admins` WHERE email = :email"); + // `email` uses the table's case- and accent-insensitive collation, so + // the request value can differ from the stored address. Everything + // past the lookup keys on the stored row (`aid`, `email`), never on + // the request string. + $GLOBALS['PDO']->query("SELECT aid, user, email FROM `:prefix_admins` WHERE email = :email"); $GLOBALS['PDO']->bind(':email', $email); $row = $GLOBALS['PDO']->single(); @@ -191,16 +195,17 @@ function api_auth_lost_password(array $params): array return _api_auth_lost_password_generic_response(); } - $validation = Crypto::recoveryHash(); - $GLOBALS['PDO']->query("UPDATE `:prefix_admins` SET validate = :validate WHERE email = :email"); + $storedEmail = (string) $row['email']; + $validation = Crypto::recoveryHash(); + $GLOBALS['PDO']->query("UPDATE `:prefix_admins` SET validate = :validate WHERE aid = :aid"); $GLOBALS['PDO']->bind(':validate', $validation); - $GLOBALS['PDO']->bind(':email', $email); + $GLOBALS['PDO']->bind(':aid', (int) $row['aid']); $GLOBALS['PDO']->execute(); - $url = Host::panelUrl() . '/index.php?p=lostpassword&email=' . urlencode($email) + $url = Host::panelUrl() . '/index.php?p=lostpassword&email=' . urlencode($storedEmail) . '&validation=' . urlencode($validation); - $sent = Mail::send($email, EmailType::PasswordReset, [ + $sent = Mail::send($storedEmail, EmailType::PasswordReset, [ '{link}' => $url, '{name}' => $row['user'], '{home}' => Host::panelUrl(), diff --git a/web/includes/Mail/Mailer.php b/web/includes/Mail/Mailer.php index 037fe39d5..67aedf3e6 100644 --- a/web/includes/Mail/Mailer.php +++ b/web/includes/Mail/Mailer.php @@ -13,6 +13,7 @@ use Sbpp\Log; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Transport; +use Symfony\Component\Mailer\Transport\TransportInterface; use Symfony\Component\Mime\Email; final class Mailer @@ -23,6 +24,9 @@ final class Mailer /** Latched so we only emit one deprecation warning per process when the legacy SB_EMAIL fallback is used. */ private static bool $sbEmailDeprecationLogged = false; + /** Test-only replacement for the SMTP transport {@see send()} builds from the configured DSN. */ + private static ?TransportInterface $transportOverride = null; + public function __construct( private readonly string $host, private readonly string $user, @@ -52,7 +56,7 @@ public function send(array|string $destination, if(!$this->verifyPeer) $dsn .= '?verify_peer=false'; - $mailer = Transport::fromDsn($dsn); + $mailer = self::$transportOverride ?? Transport::fromDsn($dsn); $mail = $this->buildMessage($destination, $subject, $body, $files); @@ -169,6 +173,17 @@ public static function resetDeprecationLatch(): void self::$sbEmailDeprecationLogged = false; } + /** + * Test-only seam: route every {@see send()} through `$transport` + * (e.g. a capturing `AbstractTransport`) instead of SMTP, so tests + * can assert recipients without a live mail server. Pass `null` to + * restore the configured transport. + */ + public static function overrideTransportForTests(?TransportInterface $transport): void + { + self::$transportOverride = $transport; + } + private static function formatFrom(string $name, string $email): string { return sprintf('"%s" <%s>', addcslashes($name, '"\\'), $email); diff --git a/web/pages/page.lostpassword.php b/web/pages/page.lostpassword.php index 932386e95..975c7fe38 100644 --- a/web/pages/page.lostpassword.php +++ b/web/pages/page.lostpassword.php @@ -60,12 +60,24 @@ PageDie(); } - $GLOBALS['PDO']->query("SELECT aid, user FROM `:prefix_admins` WHERE `email` = :email AND `validate` = :validate"); + // Both columns use a case- and accent-insensitive collation, so SQL + // only narrows by email: the token is checked byte-exact here, and + // the new password goes to the stored address, never the + // collation-equal request value. + $GLOBALS['PDO']->query("SELECT aid, user, email, validate FROM `:prefix_admins` WHERE `email` = :email"); $GLOBALS['PDO']->bind(':email', $email); - $GLOBALS['PDO']->bind(':validate', $validation); - $result = $GLOBALS['PDO']->single(); + $candidates = $GLOBALS['PDO']->resultset(); - if (empty($result['aid'])) { + $result = null; + foreach ($candidates as $candidate) { + $storedToken = (string) $candidate['validate']; + if ($storedToken !== '' && hash_equals($storedToken, (string) $validation)) { + $result = $candidate; + break; + } + } + + if ($result === null) { \Sbpp\View\Toast::emit('error', 'Error', 'The validation string does not match the email for this reset request.'); PageDie(); } @@ -83,7 +95,7 @@ // surface the error otherwise so the user can retry once SMTP is // configured. $password = Crypto::genSecret(MIN_PASS_LENGTH + 8); - $isEmailSent = Mail::send($email, EmailType::PasswordResetSuccess, [ + $isEmailSent = Mail::send((string) $result['email'], EmailType::PasswordResetSuccess, [ '{password}' => $password, '{name}' => $result['user'], '{home}' => Host::panelUrl() diff --git a/web/tests/api/AuthTest.php b/web/tests/api/AuthTest.php index 6e183de8b..3d1e4179d 100644 --- a/web/tests/api/AuthTest.php +++ b/web/tests/api/AuthTest.php @@ -2,11 +2,20 @@ namespace Sbpp\Tests\Api; +use PHPUnit\Framework\Attributes\DataProvider; +use Sbpp\Mail\Mailer; use Sbpp\Tests\ApiTestCase; use Sbpp\Tests\Fixture; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\AbstractTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; final class AuthTest extends ApiTestCase { + /** @var list Messages recorded by {@see captureMail()}. */ + private array $sentMail = []; + /** * #1456 — privacy fix. The pre-fix handler threw * `ApiError('not_registered', …)` on the miss branch and let any @@ -207,6 +216,166 @@ public function testLostPasswordReturnsDisabledEnvelopeUniformlyWhenNormalLoginI } } + /** + * `:prefix_admins.email` uses a case- and accent-insensitive + * collation, so a request value that differs in case or accents + * matches the stored row. The reset link must still go ONLY to the + * stored address. + * + * @return iterable + */ + public static function collationVariantEmails(): iterable + { + yield 'upper-case' => ['admin@example.test', 'ADMIN@EXAMPLE.TEST']; + yield 'accent variant' => ['owner@example.test', 'ówner@example.test']; + } + + #[DataProvider('collationVariantEmails')] + public function testLostPasswordMailsOnlyTheStoredAddressForCollationVariants(string $stored, string $requested): void + { + $ownerAid = $this->seedAdmin('owner', 'owner@example.test'); + $targetAid = $stored === 'admin@example.test' ? Fixture::adminAid() : $ownerAid; + $otherAid = $targetAid === $ownerAid ? Fixture::adminAid() : $ownerAid; + $this->assertSame(1, $this->countAdminsByEmail($requested), + 'precondition: the request value must be collation-equal to exactly the stored row'); + + $this->captureMail(); + $env = $this->api('auth.lost_password', ['email' => $requested]); + + $this->assertTrue($env['ok'] ?? false, 'expected ok envelope: ' . json_encode($env)); + $this->assertSnapshot('auth/lost_password_generic', $env); + + $this->assertCount(1, $this->sentMail, 'exactly one reset mail must be sent'); + $this->assertSame([$stored], $this->recipients($this->sentMail[0])); + + $target = $this->row('admins', ['aid' => $targetAid]); + $this->assertNotEmpty($target['validate'] ?? null, 'the matched admin\'s token must be rolled'); + $this->assertNull($this->row('admins', ['aid' => $otherAid])['validate'] ?? null, + 'no other admin\'s token may be touched'); + + $body = $this->htmlBody($this->sentMail[0]); + $this->assertStringContainsString( + 'email=' . urlencode($stored) . '&validation=' . urlencode((string) $target['validate']), + $body, + 'the reset link must carry the stored address and the freshly rolled token', + ); + $this->assertStringNotContainsString('email=' . urlencode($requested) . '&', $body); + } + + /** + * `email` is not unique, and collation-equal duplicates are easy to + * end up with. Rolling `validate` by email would reset every + * duplicate's token (invalidating their in-flight links) while only + * one of them gets a mail; the handler keys the UPDATE on `aid`. + */ + public function testLostPasswordRollsTokenOnlyOnTheMatchedAidWhenEmailsCollide(): void + { + $firstAid = $this->seedAdmin('dup-one', 'dup@example.test'); + $secondAid = $this->seedAdmin('dup-two', 'DUP@example.test'); + + $this->captureMail(); + $this->api('auth.lost_password', ['email' => 'Dup@Example.Test']); + + $rolled = array_values(array_filter( + [$this->row('admins', ['aid' => $firstAid]), $this->row('admins', ['aid' => $secondAid])], + static fn (?array $r): bool => !empty($r['validate']), + )); + + $this->assertCount(1, $rolled, 'exactly one of the collation-equal admins may have its token rolled'); + $this->assertCount(1, $this->sentMail); + $this->assertSame([(string) $rolled[0]['email']], $this->recipients($this->sentMail[0]), + 'the mail must go to the stored address of the row whose token was rolled'); + } + + protected function tearDown(): void + { + Mailer::overrideTransportForTests(null); + parent::tearDown(); + } + + private function seedAdmin(string $user, string $email): int + { + $pdo = Fixture::rawPdo(); + $stmt = $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins` (user, authid, password, gid, email, validate, extraflags, immunity) + VALUES (?, \'\', ?, -1, ?, NULL, 0, 0)', + DB_PREFIX, + )); + $stmt->execute([$user, password_hash(bin2hex(random_bytes(8)), PASSWORD_BCRYPT), $email]); + return (int) $pdo->lastInsertId(); + } + + private function countAdminsByEmail(string $email): int + { + $stmt = Fixture::rawPdo()->prepare(sprintf( + 'SELECT COUNT(*) FROM `%s_admins` WHERE email = ?', + DB_PREFIX, + )); + $stmt->execute([$email]); + return (int) $stmt->fetchColumn(); + } + + /** + * Configure SMTP just enough for `Mailer::create()` to build a + * mailer, and swap the transport for one that appends every + * message to `$this->sentMail` instead of opening a socket. + */ + private function captureMail(): void + { + $stmt = Fixture::rawPdo()->prepare(sprintf( + 'REPLACE INTO `%s_settings` (`setting`, `value`) VALUES (?, ?)', + DB_PREFIX, + )); + foreach ([ + 'smtp.host' => 'smtp.invalid', + 'smtp.user' => 'user', + 'smtp.pass' => 'pass', + 'config.mail.from_email' => 'noreply@example.test', + ] as $key => $value) { + $stmt->execute([$key, $value]); + } + \Config::init($GLOBALS['PDO']); + + $this->sentMail = []; + $record = function (SentMessage $message): void { + $this->sentMail[] = $message; + }; + Mailer::overrideTransportForTests(new class ($record) extends AbstractTransport { + public function __construct(private readonly \Closure $record) + { + parent::__construct(); + } + + protected function doSend(SentMessage $message): void + { + ($this->record)($message); + } + + public function __toString(): string + { + return 'capture://'; + } + }); + } + + /** @return list */ + private function recipients(SentMessage $message): array + { + return array_map( + static fn (Address $a): string => $a->getAddress(), + $message->getEnvelope()->getRecipients(), + ); + } + + private function htmlBody(SentMessage $message): string + { + $email = $message->getOriginalMessage(); + $this->assertInstanceOf(Email::class, $email); + // Template values are HTML-escaped (`&` → `&`), so decode + // before matching the reset link's query string. + return html_entity_decode((string) $email->getHtmlBody(), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + /** * Helper: count the number of `:prefix_log` rows whose `title` * column matches the given literal. Used by diff --git a/web/tests/e2e/fixtures/db.ts b/web/tests/e2e/fixtures/db.ts index d19d0023b..e5ccc15f3 100644 --- a/web/tests/e2e/fixtures/db.ts +++ b/web/tests/e2e/fixtures/db.ts @@ -285,13 +285,19 @@ export interface LostpasswordSeed { * single source of truth for which settings the happy-path requires * (kept out of the TS side so the schema-shape of `:prefix_settings` * stays Python-style invisible to the spec author). + * + * Pass `email` to seed (and target) a dedicated admin row for that + * address instead of the default `lostpw-happy@example.test`. */ -export async function seedLostpasswordE2e(): Promise { +export async function seedLostpasswordE2e(email?: string): Promise { const inContainer = process.env.E2E_IN_CONTAINER === '1'; const cmd = inContainer ? 'php' : 'docker'; - const cmdArgs = inContainer + const scriptArgs = email === undefined ? [SEED_LOSTPASSWORD_INSIDE_CONTAINER] - : ['compose', 'exec', '-T', 'web', 'php', SEED_LOSTPASSWORD_INSIDE_CONTAINER]; + : [SEED_LOSTPASSWORD_INSIDE_CONTAINER, email]; + const cmdArgs = inContainer + ? scriptArgs + : ['compose', 'exec', '-T', 'web', 'php', ...scriptArgs]; const { stdout, stderr } = await execFileP(cmd, cmdArgs, { maxBuffer: 1 * 1024 * 1024, diff --git a/web/tests/e2e/scripts/seed-lostpassword-e2e.php b/web/tests/e2e/scripts/seed-lostpassword-e2e.php index af0a5ac4b..f822da2bd 100644 --- a/web/tests/e2e/scripts/seed-lostpassword-e2e.php +++ b/web/tests/e2e/scripts/seed-lostpassword-e2e.php @@ -44,7 +44,10 @@ * Usage (inside the web container): * * php seed-lostpassword-e2e.php - * # → {"email":"admin@example.test","token":"<16-hex-chars>"} + * # → {"email":"lostpw-happy@example.test","token":"<16-hex-chars>"} + * + * php seed-lostpassword-e2e.php someone@example.test + * # → same, against a dedicated admin row created for that address * * The output is JSON on stdout; consumed by `seedLostpasswordE2e` * in `web/tests/e2e/fixtures/db.ts`. @@ -112,16 +115,19 @@ $token = bin2hex(random_bytes(8)); // A dedicated admin, not the seeded `admin@example.test`: completing a // reset revokes every session of the target admin (#54), and the shared -// Playwright storage state belongs to the seeded admin. INSERT IGNORE -// keeps re-runs idempotent (`user` is unique). -$email = 'lostpw-happy@example.test'; +// Playwright storage state belongs to the seeded admin. A caller-supplied +// address gets its own row too, so a spec can drive the reset branch +// without racing other specs (or the other Playwright project) on a +// shared token. INSERT IGNORE keeps re-runs idempotent (`user` is unique). +$email = $argv[1] ?? 'lostpw-happy@example.test'; +$isDefault = $email === 'lostpw-happy@example.test'; $pdo->query( 'INSERT IGNORE INTO `:prefix_admins` (user, authid, password, gid, email, validate, extraflags, immunity) VALUES (:user, :authid, :password, -1, :email, NULL, 0, 0)' ); -$pdo->bind(':user', 'lostpw-happy'); -$pdo->bind(':authid', 'STEAM_0:0:9999998'); -$pdo->bind(':password', hash('sha256', bin2hex(random_bytes(16)))); +$pdo->bind(':user', $isDefault ? 'lostpw-happy' : $email); +$pdo->bind(':authid', $isDefault ? 'STEAM_0:0:9999998' : ''); +$pdo->bind(':password', password_hash(bin2hex(random_bytes(16)), PASSWORD_BCRYPT)); $pdo->bind(':email', $email); $pdo->execute(); diff --git a/web/tests/e2e/specs/flows/lostpassword-toast.spec.ts b/web/tests/e2e/specs/flows/lostpassword-toast.spec.ts index 4fb547ce9..f7e529ab5 100644 --- a/web/tests/e2e/specs/flows/lostpassword-toast.spec.ts +++ b/web/tests/e2e/specs/flows/lostpassword-toast.spec.ts @@ -80,15 +80,17 @@ * `smoke/routing-truthiness.spec.ts`'s AUTH-1 block). */ +import type { APIRequestContext } from '@playwright/test'; import { expect, test } from '../../fixtures/auth.ts'; import { seedLostpasswordE2e, seedLostpasswordEnumAdminE2e } from '../../fixtures/db.ts'; -// #1456 cross-test isolation: the marquee #1403 test seeds a -// dedicated `lostpw-happy@example.test` admin's `:prefix_admins.validate` -// column to a known token then GETs a URL keyed on it. (Not the seeded -// `admin@example.test`: completing a reset revokes the target admin's -// sessions since #54, and the shared storage state belongs to that admin.) The form-POST tests at -// the tail of this file call `api_auth_lost_password` which UPDATEs +// #1456 cross-test isolation: the GET-validation tests seed a known +// `:prefix_admins.validate` token on a per-run admin row (see +// `seedLostpasswordE2e(email)`) then GET a URL keyed on it. (Never the +// seeded `admin@example.test`: completing a reset revokes the target +// admin's sessions since #54, and the shared storage state belongs to +// that admin.) The form-POST tests at the tail of this file call +// `api_auth_lost_password` which UPDATEs // the matched admin's `validate` to a fresh random value. Two // safeguards keep these from racing: // @@ -127,6 +129,24 @@ const MISMATCH_TOKEN = '0000000000000000aaaa'; // 20 chars; the SELECT will not const MAILPIT_BASE_URL = process.env.E2E_MAILPIT_BASE_URL ?? 'http://mailpit:8025'; +/** + * Every `To` address in mailpit's inbox that is case- and + * accent-insensitively equal to `address` (the stored address itself + * plus any collation-equal variant), so a spec can assert where mail + * for one admin went. + */ +async function mailpitRecipientsFoldingTo(request: APIRequestContext, address: string): Promise { + const fold = (s: string): string => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); + const resp = await request.get(`${MAILPIT_BASE_URL}/api/v1/messages?limit=200`); + expect(resp.ok(), `mailpit list failed (${resp.status()})`).toBe(true); + const body = await resp.json() as { + messages?: Array<{ To?: Array<{ Address?: string }> }>; + }; + return (body.messages ?? []) + .flatMap((msg) => (msg.To ?? []).map((addr) => addr.Address ?? '')) + .filter((addr) => fold(addr) === fold(address)); +} + test.describe('flow: lostpassword toast (#1403 ShowBox → Toast::emit)', () => { // Per-describe override: log out for this whole block. The form // / GET-validation branch is logged-out-only — page.lostpassword.php @@ -204,26 +224,19 @@ test.describe('flow: lostpassword toast (#1403 ShowBox → Toast::emit)', () => ).toEqual([]); }); - test('Happy path: password reset → "Password Reset" toast + chrome footer + email lands in mailpit (marquee #1403 user-reported regression)', async ({ page, request }) => { + test('Happy path: password reset → "Password Reset" toast + chrome footer + email lands in mailpit (marquee #1403 user-reported regression)', async ({ page, request }, testInfo) => { const consoleErrors: string[] = []; page.on('pageerror', (err) => consoleErrors.push(err.message)); // ---- 0. Set up the SMTP / from-address config and a known - // validate token. The seeder is idempotent so re-runs - // (and CI's `retries: 1`) work even after a previous - // success consumed the validate token. ----------- - const { email, token } = await seedLostpasswordE2e(); - - // Clear mailpit's inbox so the post-request assertion is - // unambiguous. The DELETE endpoint is documented at - // https://mailpit.axllent.org/docs/api-v1/messages — pre-fix - // any prior test's mail would still be in the inbox and the - // count check would have to skip the first N messages. - const purge = await request.delete(`${MAILPIT_BASE_URL}/api/v1/messages`); - expect( - purge.ok(), - `mailpit purge failed (status ${purge.status()}): ${await purge.text()}`, - ).toBe(true); + // validate token on a per-run admin row. A shared row + // (or a global mailpit purge) races the other + // Playwright project when workers > 1; the unique + // address also keeps the inbox lookup below + // unambiguous without purging. ----------- + const { email, token } = await seedLostpasswordE2e( + `lostpw-happy-${testInfo.project.name}-${Date.now()}@example.test`, + ); // ---- 1. Drive the success branch ------------------------------ // Same shape the user hits when they click the "Reset password" @@ -299,6 +312,60 @@ test.describe('flow: lostpassword toast (#1403 ShowBox → Toast::emit)', () => ).toEqual([]); }); + // `:prefix_admins.email` and `.validate` use a case- and + // accent-insensitive collation. The page must match the token + // byte-exact and mail the new password to the STORED address only. + // AuthTest pins the request-side half. Each + // test seeds its own per-run admin row and filters mailpit by + // address, so it can't race the happy-path test above (or the + // other Playwright project) on `admin@example.test`'s token or on + // a shared inbox purge. + test('Collation-variant email + correct token → new password mailed only to the stored address', async ({ page, request }, testInfo) => { + const { email, token } = await seedLostpasswordE2e( + `lostpw-variant-${testInfo.project.name}-${Date.now()}@example.test`, + ); + const variant = email.toUpperCase().replace('A', 'Ä'); + expect(variant).not.toBe(email); + + await page.goto( + `/index.php?p=lostpassword&email=${encodeURIComponent(variant)}&validation=${token}`, + ); + + await expect( + page.locator('.toast[data-kind="info"], .toast[data-kind="success"]') + .filter({ hasText: 'Password Reset' }), + ).toBeVisible(); + + let recipients: string[] = []; + for (let attempt = 0; attempt < 10 && recipients.length === 0; attempt += 1) { + recipients = await mailpitRecipientsFoldingTo(request, email); + if (recipients.length === 0) await page.waitForTimeout(500); + } + expect(recipients, 'the new password must go only to the stored address').toEqual([email]); + }); + + test('Token with different letter case does not match (byte-exact compare)', async ({ page, request }, testInfo) => { + const email = `lostpw-token-case-${testInfo.project.name}-${Date.now()}@example.test`; + let seed = await seedLostpasswordE2e(email); + for (let attempt = 0; attempt < 5 && !/[a-f]/.test(seed.token); attempt += 1) { + seed = await seedLostpasswordE2e(email); + } + const wrongCase = seed.token.toUpperCase(); + expect(wrongCase).not.toBe(seed.token); + + await page.goto( + `/index.php?p=lostpassword&email=${encodeURIComponent(email)}&validation=${wrongCase}`, + ); + + await expect( + page.locator('.toast[data-kind="error"]').filter({ hasText: /validation string does not match/i }), + ).toBeVisible(); + expect( + await mailpitRecipientsFoldingTo(request, email), + 'no mail may be sent on a token mismatch', + ).toEqual([]); + }); + test('No raw