fix: sign in with password and two-factor code when the phone reports the server address without its default port or in punycode

This commit is contained in:
Linus Rath
2026-10-29 03:31:06 +01:00
parent 7c94039a7e
commit fe4f92112d
3 changed files with 59 additions and 5 deletions
+33
View File
@@ -189,6 +189,39 @@ describe('exchangePasswordForTokens', () => {
expect(tokens.accessToken).toBe('at'); 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 () => { 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 // React Native's fetch follows redirects itself and never sets
// `redirected`; only `url` tells where the answer came from. // `redirected`; only `url` tells where the answer came from.
+24 -3
View File
@@ -155,9 +155,14 @@ export interface SecureFetchInit extends RequestInit {
const MAX_REDIRECTS = 5; 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 { function originOf(url: string): string {
const m = /^(https?):\/\/([^/?#]+)/i.exec(url); 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 { 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 * 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. * 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<object>();
/** 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( export async function fetchWithoutForeignRedirect(
url: string, url: string,
init?: SecureFetchInit, init?: SecureFetchInit,
): Promise<Response> { ): Promise<Response> {
const native = getNative(); const native = getNative();
if (native && (await getClientCertAlias())) return secureFetch(url, init); if (native && (await getClientCertAlias())) {
const doFetch: ManualFetch = getManualFetch() ?? ((u, i) => fetch(u, i)); 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 ?? {}; const { timeoutMs: _timeoutMs, ...rest } = init ?? {};
let currentUrl = url; let currentUrl = url;
for (let hop = 0; ; hop++) { for (let hop = 0; ; hop++) {
@@ -300,6 +320,7 @@ export async function fetchWithoutForeignRedirect(
continue; continue;
} }
} }
if (manual) redirectChecked.add(response);
return response; return response;
} }
} }
+2 -2
View File
@@ -24,7 +24,7 @@
// Token auth also survives TOTP rotation, unlike Basic auth, which would have // Token auth also survives TOTP rotation, unlike Basic auth, which would have
// to embed the ~30 s code in every request. // 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 { sha256Hex } from './sha256';
import { base64UrlEncode, randomBytes, randomHex } from './random'; import { base64UrlEncode, randomBytes, randomHex } from './random';
import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth'; 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'); const location = response.headers?.get?.('location');
if (location) throw new TotpLoginError('login_failed', `${requested} redirects to another host`); 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`); throw new TotpLoginError('login_failed', `${requested} redirects to another host`);
} }
} }