From 16d50433b933c38d93bc4b51052353aea5763e51 Mon Sep 17 00:00:00 2001 From: Lovehsigure_520 <62863834+Neko-233@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:34:43 +0800 Subject: [PATCH] fix(server): auth UI callback trust (#1976) --- apps/server/src/utils/origin.ts | 15 +++++++- apps/server/src/utils/tests/origin.test.ts | 43 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/server/src/utils/origin.ts b/apps/server/src/utils/origin.ts index 453e3c4a6..88247fec5 100644 --- a/apps/server/src/utils/origin.ts +++ b/apps/server/src/utils/origin.ts @@ -10,15 +10,24 @@ function getOriginFromUrl(url: string): string | undefined { } const TRUSTED_EXACT_ORIGINS = [ + 'https://airi.moeru.ai', // Production web app 'capacitor://localhost', // Capacitor mobile (iOS) 'ai.moeru.airi-pocket://links', // Android deep link - 'https://airi.moeru.ai', // Production 'https://accounts.airi.build', // Standalone auth UI 'https://server-dev.airi-server-auth.pages.dev', // Server-dev standalone auth UI 'https://admin.airi.build', // Standalone admin UI 'https://server-dev.airi-server-admin.pages.dev', // Server-dev standalone admin UI ] +// NOTICE: +// Better Auth accepts non-http(s) origins by prefix (`url.startsWith(pattern)`), +// so native deep-link schemes must not be copied from TRUSTED_EXACT_ORIGINS +// into auth callback validation. Browser auth callbacks only need web origins. +const TRUSTED_AUTH_CALLBACK_ORIGINS = TRUSTED_EXACT_ORIGINS.filter((origin) => { + const protocol = new URL(origin).protocol + return protocol === 'http:' || protocol === 'https:' +}) + // NOTICE: // Private LAN / CGNAT-style dev hosts (e.g. https://10.x:5273 from cap-vite) are NOT matched // by regex here — list them explicitly via env `ADDITIONAL_TRUSTED_ORIGINS` (see env.ts). @@ -149,6 +158,10 @@ export function getAuthTrustedOrigins( origins.add(apiServerOrigin) } + for (const origin of TRUSTED_AUTH_CALLBACK_ORIGINS) { + origins.add(origin) + } + for (const origin of env.ADDITIONAL_TRUSTED_ORIGINS) { origins.add(origin) } diff --git a/apps/server/src/utils/tests/origin.test.ts b/apps/server/src/utils/tests/origin.test.ts index d912f34ca..e752b984b 100644 --- a/apps/server/src/utils/tests/origin.test.ts +++ b/apps/server/src/utils/tests/origin.test.ts @@ -67,6 +67,11 @@ describe('origin utils', () => { ADDITIONAL_TRUSTED_ORIGINS: [], }, request)).toEqual([ 'https://api.airi.moeru.ai', + 'https://airi.moeru.ai', + 'https://accounts.airi.build', + 'https://server-dev.airi-server-auth.pages.dev', + 'https://admin.airi.build', + 'https://server-dev.airi-server-admin.pages.dev', 'http://localhost:*', 'http://127.0.0.1:*', 'http://localhost:5173', @@ -122,9 +127,47 @@ describe('origin utils', () => { ADDITIONAL_TRUSTED_ORIGINS: ['https://10.0.0.129:5273'], })).toEqual([ 'https://api.airi.moeru.ai', + 'https://airi.moeru.ai', + 'https://accounts.airi.build', + 'https://server-dev.airi-server-auth.pages.dev', + 'https://admin.airi.build', + 'https://server-dev.airi-server-admin.pages.dev', 'https://10.0.0.129:5273', 'http://localhost:*', 'http://127.0.0.1:*', ]) }) + + it('does not include native deep-link schemes in Better Auth trustedOrigins', () => { + expect(getTrustedOrigin('capacitor://localhost')).toBe('capacitor://localhost') + expect(getTrustedOrigin('ai.moeru.airi-pocket://links')).toBe('ai.moeru.airi-pocket://links') + + const authOrigins = getAuthTrustedOrigins({ + API_SERVER_URL: 'https://api.airi.build', + ADDITIONAL_TRUSTED_ORIGINS: [], + }) + + expect(authOrigins).not.toContain('capacitor://localhost') + expect(authOrigins).not.toContain('ai.moeru.airi-pocket://links') + }) + + // ROOT CAUSE: + // + // Email verification links carry a callbackURL query parameter that Better + // Auth validates only against its trustedOrigins list. The standalone auth + // UI sends callbackURL=https://accounts.airi.build/ui/verify-email?verified=true, + // but getAuthTrustedOrigins previously listed only API_SERVER_URL, + // additional env origins, and localhost wildcards. Clicking the email from + // a normal inbox has no usable Origin/Referer header, so request-derived + // trust could not add the auth UI origin and Better Auth returned + // INVALID_CALLBACK_URL. + // + // Before patch: auth UI callback -> not in trustedOrigins -> 403. + // After patch: built-in first-party exact origins are always present. + it('includes built-in first-party origins for email verification callbacks without request headers', () => { + expect(getAuthTrustedOrigins({ + API_SERVER_URL: 'https://api.airi.build', + ADDITIONAL_TRUSTED_ORIGINS: [], + })).toContain('https://accounts.airi.build') + }) })