diff --git a/src/lib/__tests__/oauth-redirects.test.ts b/src/lib/__tests__/oauth-redirects.test.ts index b1c4c1ac..8f3732dd 100644 --- a/src/lib/__tests__/oauth-redirects.test.ts +++ b/src/lib/__tests__/oauth-redirects.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as WebBrowser from 'expo-web-browser'; import { setManualFetchForTests } from '../client-cert'; -import { HandoffError, TransientRefreshError, refreshOAuthAccessToken, resetRefreshRotations } from '../oauth'; +import { HandoffError, PairingError, TransientRefreshError, redeemPairingCode, refreshOAuthAccessToken, resetRefreshRotations } from '../oauth'; import { discoverOAuthMetadata, loginWithPkce, revokeRefreshToken, type OAuthMetadata } from '../oauth-native'; // The refresh token, the code + verifier and the metadata that decides where @@ -149,3 +149,13 @@ describe('metadata discovery', () => { expect((await discoverOAuthMetadata(SERVER))?.token_endpoint).toBe(`${IDP}/token`); }); }); + +describe('pairing', () => { + it('does not take a sign-in code along a redirect to another host', async () => { + routes[`${SERVER}/api/auth/pair/redeem`] = () => redirect(`${EVIL}/api/auth/pair/redeem`); + + await expect(redeemPairingCode(SERVER, 'ABCD-EFGH')).rejects.toBeInstanceOf(PairingError); + + expect(evilHits()).toEqual([]); + }); +}); diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 9e4c7678..bb2b772f 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -459,7 +459,9 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi const exchange = (async () => { let response: Response; try { - response = await secureFetch(`${base}/api/auth/pair/redeem`, { + // The code is a one-time credential: never let a redirect carry it + // to another host. + response = await fetchWithoutForeignRedirect(`${base}/api/auth/pair/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ pairing_code: code }),