mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: stop signing out on launch when a redirect drops the auth header
When a proxy redirects the session request (an http to https upgrade, a trailing slash, another port), iOS and OkHttp follow it without the Authorization header. The server then answers 401 or, like Stalwart, an empty session, which the client reported as invalid credentials, so a restored account was evicted and its stored credentials deleted. The retry that was meant to catch this keyed on `response.redirected`, which React Native's fetch never sets. Session discovery now also detects a redirect by comparing the final `response.url` with the requested URL, and on a 401 or an empty session refetches the final URL with the header. Credentials only follow within the same host or its https upgrade, the rule `secureFetch` applies; a redirect elsewhere fails as a discovery error, which `loadAccount` turns into a `NetworkError`, so the stored account is kept.
This commit is contained in:
@@ -208,6 +208,80 @@ describe('JMAPClient hardening', () => {
|
|||||||
expect(client.accountId).toBe('acc-1');
|
expect(client.accountId).toBe('acc-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('redirects that drop the Authorization header (B15, webmail #892)', () => {
|
||||||
|
// RN's fetch leaves `redirected` false; only `url` shows the redirect.
|
||||||
|
const authOf = (call: unknown[]) =>
|
||||||
|
((call[1] as RequestInit | undefined)?.headers as Record<string, string> | undefined)?.Authorization;
|
||||||
|
|
||||||
|
it('refetches the final URL with credentials after a 401 behind an https upgrade', async () => {
|
||||||
|
const fetch = mockFetch([
|
||||||
|
{ status: 401, url: 'https://mail.example.com/jmap/session' },
|
||||||
|
{ status: 200, json: SESSION },
|
||||||
|
]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
await client.connect('http://mail.example.com', 'user', 'pass');
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetch.mock.calls[1][0]).toBe('https://mail.example.com/jmap/session');
|
||||||
|
expect(authOf(fetch.mock.calls[1])).toMatch(/^Basic /);
|
||||||
|
expect(client.accountId).toBe('acc-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches when the redirect only shows in response.url and the session came back empty', async () => {
|
||||||
|
const fetch = mockFetch([
|
||||||
|
{ status: 404, url: 'https://mail.example.com/jmap/session' },
|
||||||
|
{ status: 200, json: { apiUrl: '/jmap/', accounts: {}, primaryAccounts: {} }, url: 'https://mail.example.com/jmap/session/' },
|
||||||
|
{ status: 200, json: SESSION },
|
||||||
|
]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
await client.connect('https://mail.example.com', 'user', 'pass');
|
||||||
|
expect(fetch.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
'https://mail.example.com/jmap/session',
|
||||||
|
'https://mail.example.com/.well-known/jmap',
|
||||||
|
'https://mail.example.com/jmap/session/',
|
||||||
|
]);
|
||||||
|
expect(client.accountId).toBe('acc-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a canonicalised final URL as a redirect', async () => {
|
||||||
|
const fetch = mockFetch([{ status: 401, url: 'https://mail.example.com/jmap/session' }]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
const err = await client.connect('https://Mail.Example.com:443', 'user', 'bad').catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(AuthenticationError);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reports bad credentials when the direct refetch is rejected too', async () => {
|
||||||
|
mockFetch([
|
||||||
|
{ status: 401, url: 'https://mail.example.com/jmap/session' },
|
||||||
|
{ status: 401, url: 'https://mail.example.com/jmap/session' },
|
||||||
|
]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
const err = await client.connect('http://mail.example.com', 'user', 'bad').catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(AuthenticationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps credentials off another host and fails without an auth error', async () => {
|
||||||
|
const fetch = mockFetch([{ status: 401, url: 'https://sso.example.net/jmap/session' }]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
const err = await client.connect('https://mail.example.com', 'user', 'pass').catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err).not.toBeInstanceOf(AuthenticationError);
|
||||||
|
expect(String(err.message)).toContain('redirected to https://sso.example.net');
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a redirect it cannot follow as NetworkError on restore, so the account is kept', async () => {
|
||||||
|
const SecureStore = await import('expo-secure-store');
|
||||||
|
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||||
|
JSON.stringify({ serverUrl: 'https://mail.example.com', username: 'user', password: 'pass' }),
|
||||||
|
);
|
||||||
|
mockFetch([{ status: 200, json: { apiUrl: '/jmap/', accounts: {}, primaryAccounts: {} }, url: 'https://sso.example.net/session' }]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
const err = await client.loadAccount('user@mail.example.com').catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(NetworkError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('throws TotpRequiredError on a 402 MFA challenge', async () => {
|
it('throws TotpRequiredError on a 402 MFA challenge', async () => {
|
||||||
mockFetch([{ status: 402, json: { title: 'MFA code required' } }]);
|
mockFetch([{ status: 402, json: { title: 'MFA code required' } }]);
|
||||||
const client = new JMAPClient();
|
const client = new JMAPClient();
|
||||||
|
|||||||
+47
-13
@@ -7,7 +7,7 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { CAPABILITIES } from './types';
|
import { CAPABILITIES } from './types';
|
||||||
import { generateAccountId } from '../lib/account-utils';
|
import { generateAccountId } from '../lib/account-utils';
|
||||||
import { secureFetch } from '../lib/client-cert';
|
import { secureFetch, mayFollowRedirect } from '../lib/client-cert';
|
||||||
import {
|
import {
|
||||||
refreshOAuthAccessToken,
|
refreshOAuthAccessToken,
|
||||||
TransientRefreshError,
|
TransientRefreshError,
|
||||||
@@ -112,6 +112,18 @@ function fetchSessionDescribes(session: unknown): session is JMAPSession {
|
|||||||
return typeof s.apiUrl === 'string';
|
return typeof s.apiUrl === 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `url` with the scheme and host lowercased and a default port dropped, so a
|
||||||
|
* response's final URL can be compared with the requested one.
|
||||||
|
*/
|
||||||
|
function canonicalUrl(url: string): string {
|
||||||
|
const m = /^(https?):\/\/([^/?#]+)(.*)$/i.exec(url);
|
||||||
|
if (!m) return url;
|
||||||
|
const scheme = m[1].toLowerCase();
|
||||||
|
const host = m[2].toLowerCase().replace(scheme === 'https' ? /:443$/ : /:80$/, '');
|
||||||
|
return `${scheme}://${host}${m[3]}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Shared response helpers live in ./jmap-result (dependency-free) and are
|
// Shared response helpers live in ./jmap-result (dependency-free) and are
|
||||||
// re-exported here for convenience.
|
// re-exported here for convenience.
|
||||||
export { JMAPMethodError, requireMethodResult, assertSetResult, batched } from './jmap-result';
|
export { JMAPMethodError, requireMethodResult, assertSetResult, batched } from './jmap-result';
|
||||||
@@ -699,14 +711,39 @@ export class JMAPClient {
|
|||||||
// servers that don't serve /jmap/session (#39).
|
// servers that don't serve /jmap/session (#39).
|
||||||
const primary = `${baseUrl}/jmap/session`;
|
const primary = `${baseUrl}/jmap/session`;
|
||||||
const fallback = `${baseUrl}/.well-known/jmap`;
|
const fallback = `${baseUrl}/.well-known/jmap`;
|
||||||
|
let requested = primary;
|
||||||
const fetchSessionDoc = async () => {
|
const fetchSessionDoc = async () => {
|
||||||
const r = await this.authenticatedFetch(primary, { headers: { Accept: 'application/json' } });
|
const r = await this.authenticatedFetch(primary, { headers: { Accept: 'application/json' } });
|
||||||
return r.status === 404
|
if (r.status !== 404) return r;
|
||||||
? this.authenticatedFetch(fallback, { headers: { Accept: 'application/json' } })
|
requested = fallback;
|
||||||
: r;
|
return this.authenticatedFetch(fallback, { headers: { Accept: 'application/json' } });
|
||||||
};
|
};
|
||||||
let response = await fetchSessionDoc();
|
let response = await fetchSessionDoc();
|
||||||
|
|
||||||
|
// A redirect the platform followed may have dropped the Authorization
|
||||||
|
// header (iOS NSURLSession does; OkHttp does for another scheme, host or
|
||||||
|
// port). The server then answers 401, or - Stalwart - 200 with an empty
|
||||||
|
// session, although the credentials are fine. RN's fetch never sets
|
||||||
|
// `response.redirected`, so also compare the final URL with the one
|
||||||
|
// requested, and refetch it with the header (webmail #892).
|
||||||
|
const finalUrl = canonicalUrl((response as { url?: string }).url || requested);
|
||||||
|
const redirected =
|
||||||
|
Boolean((response as { redirected?: boolean }).redirected) || finalUrl !== canonicalUrl(requested);
|
||||||
|
let refetched = false;
|
||||||
|
const refetchRedirected = async (): Promise<Response> => {
|
||||||
|
// Credentials only follow a redirect within the same host (or its
|
||||||
|
// https upgrade), the rule secureFetch applies. Anything else fails as
|
||||||
|
// a discovery error, not an auth one, so a stored account survives it.
|
||||||
|
if (!mayFollowRedirect(canonicalUrl(requested), finalUrl)) {
|
||||||
|
throw new Error(`Session discovery failed: redirected to ${finalUrl}`);
|
||||||
|
}
|
||||||
|
refetched = true;
|
||||||
|
return this.authenticatedFetch(finalUrl, { headers: { Accept: 'application/json' } });
|
||||||
|
};
|
||||||
|
if (redirected && response.status === 401) {
|
||||||
|
response = await refetchRedirected();
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
throw new AuthenticationError('Invalid credentials');
|
throw new AuthenticationError('Invalid credentials');
|
||||||
}
|
}
|
||||||
@@ -730,18 +767,15 @@ export class JMAPClient {
|
|||||||
|
|
||||||
let session = (await response.json()) as JMAPSession;
|
let session = (await response.json()) as JMAPSession;
|
||||||
|
|
||||||
// Stalwart 307-redirects /.well-known/jmap to /jmap/session. iOS
|
// Stalwart 307-redirects /.well-known/jmap to /jmap/session and answers
|
||||||
// NSURLSession (and some auth proxies) drop the Authorization header on
|
// the header-less redirected request with a 200 empty session.
|
||||||
// the redirect, which yields a 200 with an empty session. Refetch the
|
|
||||||
// final URL with the header explicitly.
|
|
||||||
const redirectedEmpty =
|
const redirectedEmpty =
|
||||||
(response as { redirected?: boolean }).redirected &&
|
redirected &&
|
||||||
|
!refetched &&
|
||||||
(!session?.accounts || Object.keys(session.accounts).length === 0) &&
|
(!session?.accounts || Object.keys(session.accounts).length === 0) &&
|
||||||
!session?.username;
|
!session?.username;
|
||||||
if (redirectedEmpty && (response as { url?: string }).url) {
|
if (redirectedEmpty) {
|
||||||
response = await this.authenticatedFetch((response as { url: string }).url, {
|
response = await refetchRedirected();
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (response.status === 401) throw new AuthenticationError('Invalid credentials');
|
if (response.status === 401) throw new AuthenticationError('Invalid credentials');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`);
|
throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user