From 376acfac26071476b3730675147fa26563657cf0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 29 Sep 2026 08:25:04 -0700 Subject: [PATCH] Reject subdomains of the reserved identity email namespace --- packages/auth/src/better-auth-email.ts | 15 +++++-- tests/integration/better-auth-oauth.test.ts | 3 ++ tests/integration/better-auth.test.ts | 47 +++++++++++++++++++++ tests/unit/better-auth-email.test.ts | 34 +++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 tests/unit/better-auth-email.test.ts diff --git a/packages/auth/src/better-auth-email.ts b/packages/auth/src/better-auth-email.ts index 82526ed..c403cbb 100644 --- a/packages/auth/src/better-auth-email.ts +++ b/packages/auth/src/better-auth-email.ts @@ -1,9 +1,16 @@ import { createHash } from 'node:crypto'; -const identityDomain = '@identity.pgstencil.invalid'; +const identityDomain = 'identity.pgstencil.invalid'; export function isIdentityEmail(email: string) { - return email.toLowerCase().endsWith(identityDomain); + const at = email.lastIndexOf('@'); + if (at === -1) return false; + // Reserve the whole DNS namespace, including an optional trailing root dot. + const domain = email + .slice(at + 1) + .toLowerCase() + .replace(/\.$/, ''); + return domain === identityDomain || domain.endsWith('.' + identityDomain); } /** Better Auth requires an email column, even for a provider-only account. @@ -21,7 +28,9 @@ export function identityEmail( return ( createHash('sha256') .update(JSON.stringify([provider, clientId, String(subject)])) - .digest('hex') + identityDomain + .digest('hex') + + '@' + + identityDomain ); } diff --git a/tests/integration/better-auth-oauth.test.ts b/tests/integration/better-auth-oauth.test.ts index 64b8cc8..51555df 100644 --- a/tests/integration/better-auth-oauth.test.ts +++ b/tests/integration/better-auth-oauth.test.ts @@ -720,6 +720,9 @@ test('Optional email never accepts a supplied but unverified email or invalid OI onTestFinished(() => f.close()); for (const options of [ { verified: false }, + { email: 'sub@a.identity.pgstencil.invalid' }, + { email: 'SUB@A.B.IDENTITY.PGSTENCIL.INVALID' }, + { email: 'dotted@identity.pgstencil.invalid.' }, { email: '', badSignature: true }, { email: '', claims: { nonce: 'wrong' } }, { diff --git a/tests/integration/better-auth.test.ts b/tests/integration/better-auth.test.ts index 7a260a6..f0d591d 100644 --- a/tests/integration/better-auth.test.ts +++ b/tests/integration/better-auth.test.ts @@ -166,6 +166,53 @@ test('Better Auth email: repeatable cookies, database and email snapshots across expect(plan.toBeAddedIndexes).toEqual([]); }); +test('Better Auth email rejects the entire reserved identity namespace before sending or signing in', async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(() => f.close()); + for (const email of [ + 'plain@identity.pgstencil.invalid', + 'PLAIN@IDENTITY.PGSTENCIL.INVALID', + 'sub@a.identity.pgstencil.invalid', + 'SUB@A.B.IDENTITY.PGSTENCIL.INVALID', + 'dotted@identity.pgstencil.invalid.', + 'dotted@a.identity.pgstencil.invalid.', + ]) { + for (const path of [ + 'email-otp/send-verification-otp', + 'sign-in/email-otp', + ]) { + const response = await f.post(path, { + email, + type: 'sign-in', + otp: '12345678', + }); + expect(response.status, `${path}: ${email}`).toBe(400); + expect(response.body).toEqual({ message: 'Invalid email' }); + } + } + expect(f.email.all()).toEqual([]); + expect(await queryDatabase(f.database.url, 'SELECT id FROM "user"')).toEqual( + [], + ); + expect( + await queryDatabase(f.database.url, 'SELECT id FROM verification'), + ).toEqual([]); + // Similar domain names remain ordinary delivery addresses. + for (const email of [ + 'user@notidentity.pgstencil.invalid', + 'user@identity.pgstencil.invalid.example.test', + ]) { + const response = await f.post('email-otp/send-verification-otp', { + email, + type: 'sign-in', + }); + expect(response.status, email).toBe(200); + expect((await f.email.next()).to).toEqual([email]); + } +}); + test('Better Auth time: 23 hours, expiration boundary, isolated async contexts and unchanged host clock', async ({ onTestFinished, }) => { diff --git a/tests/unit/better-auth-email.test.ts b/tests/unit/better-auth-email.test.ts new file mode 100644 index 0000000..7c816d6 --- /dev/null +++ b/tests/unit/better-auth-email.test.ts @@ -0,0 +1,34 @@ +import { test, expect } from 'vitest'; +import { + identityEmail, + isIdentityEmail, +} from '../../packages/auth/src/better-auth-email.ts'; + +test('reserved identity emails include subdomains and DNS root dots', () => { + for (const email of [ + 'plain@identity.pgstencil.invalid', + 'PLAIN@IDENTITY.PGSTENCIL.INVALID', + 'sub@a.identity.pgstencil.invalid', + 'SUB@A.B.IDENTITY.PGSTENCIL.INVALID', + 'dotted@identity.pgstencil.invalid.', + 'dotted@a.identity.pgstencil.invalid.', + ]) + expect(isIdentityEmail(email), email).toBe(true); +}); + +test('reserved identity email matching respects domain boundaries', () => { + for (const email of [ + 'user@example.test', + 'user@notidentity.pgstencil.invalid', + 'user@identity.pgstencil.invalid.example.test', + 'identity.pgstencil.invalid@example.test', + 'identity.pgstencil.invalid', + ]) + expect(isIdentityEmail(email), email).toBe(false); +}); + +test('generated identity addresses retain their exact reserved domain', () => { + const email = identityEmail('google', 'client-id', 'subject'); + expect(email).toMatch(/^[0-9a-f]{64}@identity\.pgstencil\.invalid$/); + expect(isIdentityEmail(email)).toBe(true); +});