diff --git a/src/lib/__tests__/totp-login.test.ts b/src/lib/__tests__/totp-login.test.ts index 62a80b0c..7ae53400 100644 --- a/src/lib/__tests__/totp-login.test.ts +++ b/src/lib/__tests__/totp-login.test.ts @@ -189,6 +189,39 @@ describe('exchangePasswordForTokens', () => { expect(tokens.accessToken).toBe('at'); }); + // Android's fetch names the URL as OkHttp normalised it: no default port, + // a non-ASCII host in punycode. Those are the same server. + it('signs in when the answers name the server the way the platform normalised it', async () => { + const server = 'https://mail.m\u00fcller.de:443'; + fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { + const normalised = url.replace('m\u00fcller.de:443', 'xn--mller-kva.de'); + let res: Response; + if (url.endsWith('/api/auth') && init?.method === 'POST') res = json({ type: 'authenticated', client_code: 'c' }); + else if (url.endsWith('/auth/token')) res = json({ access_token: 'at', refresh_token: 'rt' }); + else res = json({}, 404); + Object.defineProperty(res, 'url', { value: normalised }); + return res; + }); + const tokens = await exchangePasswordForTokens(server, 'u', 'pw', '1', { clientId: 'bulwark-webmail' }); + + expect(tokens.accessToken).toBe('at'); + }); + + it('takes a URL without its default port for the same server on the platform fetch too', async () => { + setManualFetchForTests(null); + vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => { + let res: Response; + if (url.endsWith('/api/auth') && init?.method === 'POST') res = json({ type: 'authenticated', client_code: 'c' }); + else if (url.endsWith('/auth/token')) res = json({ access_token: 'at', refresh_token: 'rt' }); + else res = json({}, 404); + Object.defineProperty(res, 'url', { value: url.replace(':443', '') }); + return res; + }); + const tokens = await exchangePasswordForTokens('https://mail.example.com:443', 'u', 'pw', '1', { clientId: 'bulwark-webmail' }); + + expect(tokens.accessToken).toBe('at'); + }); + it('refuses the answer when the platform fetch followed a redirect to another host', async () => { // React Native's fetch follows redirects itself and never sets // `redirected`; only `url` tells where the answer came from. diff --git a/src/lib/client-cert.ts b/src/lib/client-cert.ts index faa27a80..d945acc5 100644 --- a/src/lib/client-cert.ts +++ b/src/lib/client-cert.ts @@ -155,9 +155,14 @@ export interface SecureFetchInit extends RequestInit { const MAX_REDIRECTS = 5; +// Android's fetch reports the URL as OkHttp normalised it, without a default +// port: `https://h:443` and `https://h` are the same origin. function originOf(url: string): string { const m = /^(https?):\/\/([^/?#]+)/i.exec(url); - return m ? `${m[1].toLowerCase()}://${m[2].toLowerCase()}` : ''; + if (!m) return ''; + const scheme = m[1].toLowerCase(); + const host = m[2].toLowerCase().replace(scheme === 'https' ? /:443$/ : /:80$/, ''); + return `${scheme}://${host}`; } function hostOnly(origin: string): string { @@ -276,13 +281,28 @@ export function setManualFetchForTests(impl: ManualFetch | null | undefined): vo * fetch can be told so (Node, or Expo's missing), it falls back to the * platform fetch, and the caller still checks `response.url` afterwards. */ +// Answers that came through a fetch told not to follow redirects: every hop +// was ours to allow, so their `url` needs no second look. The platform's own +// normalising of it (punycode hosts) would otherwise read as another host. +const redirectChecked = new WeakSet(); + +/** Whether `response` came through a path that followed only allowed redirects. */ +export function followedOnlyAllowedRedirects(response: Response): boolean { + return redirectChecked.has(response); +} + export async function fetchWithoutForeignRedirect( url: string, init?: SecureFetchInit, ): Promise { const native = getNative(); - if (native && (await getClientCertAlias())) return secureFetch(url, init); - const doFetch: ManualFetch = getManualFetch() ?? ((u, i) => fetch(u, i)); + if (native && (await getClientCertAlias())) { + const response = await secureFetch(url, init); + redirectChecked.add(response); + return response; + } + const manual = getManualFetch(); + const doFetch: ManualFetch = manual ?? ((u, i) => fetch(u, i)); const { timeoutMs: _timeoutMs, ...rest } = init ?? {}; let currentUrl = url; for (let hop = 0; ; hop++) { @@ -300,6 +320,7 @@ export async function fetchWithoutForeignRedirect( continue; } } + if (manual) redirectChecked.add(response); return response; } } diff --git a/src/lib/totp-login.ts b/src/lib/totp-login.ts index 3c26caf7..1f2d56a5 100644 --- a/src/lib/totp-login.ts +++ b/src/lib/totp-login.ts @@ -24,7 +24,7 @@ // Token auth also survives TOTP rotation, unlike Basic auth, which would have // to embed the ~30 s code in every request. -import { fetchWithoutForeignRedirect, mayFollowRedirect } from './client-cert'; +import { fetchWithoutForeignRedirect, followedOnlyAllowedRedirects, mayFollowRedirect } from './client-cert'; import { sha256Hex } from './sha256'; import { base64UrlEncode, randomBytes, randomHex } from './random'; import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth'; @@ -143,7 +143,7 @@ function assertSameServer(requested: string, response: Response): void { const location = response.headers?.get?.('location'); if (location) throw new TotpLoginError('login_failed', `${requested} redirects to another host`); } - if (response.url && !mayFollowRedirect(requested, response.url)) { + if (response.url && !followedOnlyAllowedRedirects(response) && !mayFollowRedirect(requested, response.url)) { throw new TotpLoginError('login_failed', `${requested} redirects to another host`); } }