mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
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:
@@ -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.
|
||||
|
||||
+24
-3
@@ -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<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(
|
||||
url: string,
|
||||
init?: SecureFetchInit,
|
||||
): Promise<Response> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user