mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: stop OAuth discovery from reading a well-known document off another host
This commit is contained in:
@@ -35,6 +35,30 @@ describe('oauth/discovery', () => {
|
||||
discoverOAuth = mod.discoverOAuth;
|
||||
});
|
||||
|
||||
it('does not read a well-known document from another host the configured one redirects to', async () => {
|
||||
// The endpoints inside the document were validated, but the document
|
||||
// itself followed redirects anywhere.
|
||||
const redirect = { status: 302, ok: false, headers: new Headers({ location: 'https://evil.example/.well-known/openid-configuration' }), json: async () => ({}) };
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(redirect));
|
||||
const result = await discoverOAuth('https://mail.example.com', { validateEndpoint });
|
||||
expect(result).toBeNull();
|
||||
for (const call of (fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls) {
|
||||
expect(String(call[0])).toContain('mail.example.com');
|
||||
}
|
||||
});
|
||||
|
||||
it('follows one redirect that stays on the same host', async () => {
|
||||
const redirect = { status: 301, ok: false, headers: new Headers({ location: '/auth/.well-known/openid-configuration' }), json: async () => ({}) };
|
||||
const document = { status: 200, ok: true, headers: new Headers(), json: async () => VALID_METADATA };
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(redirect).mockResolvedValueOnce(document));
|
||||
const result = await discoverOAuth('https://mail.example.com', { validateEndpoint });
|
||||
expect(result).toEqual(VALID_METADATA);
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
'https://mail.example.com/auth/.well-known/openid-configuration',
|
||||
expect.objectContaining({ redirect: 'error' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers metadata from oauth-authorization-server', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
+25
-1
@@ -84,6 +84,30 @@ async function findRejectedEndpoint(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a well-known document without following redirects anywhere: the
|
||||
* endpoints inside it are validated, but the document itself was fetched
|
||||
* with `fetch`'s default `follow`, so a configured host that answered with a
|
||||
* redirect had the metadata read from wherever it pointed. One hop is
|
||||
* followed when it stays on the same host (an http -> https upgrade or a
|
||||
* path change); anything else is an error.
|
||||
*/
|
||||
async function fetchDiscoveryDocument(url: string): Promise<Response> {
|
||||
const first = await fetch(url, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), redirect: 'manual' });
|
||||
const status = typeof first.status === 'number' ? first.status : 200;
|
||||
if (status < 300 || status >= 400) return first;
|
||||
const location = first.headers.get('location');
|
||||
if (!location) throw new Error(`${url} redirected without a Location header`);
|
||||
const next = new URL(location, url);
|
||||
const from = new URL(url);
|
||||
const sameHost = next.hostname === from.hostname;
|
||||
const noDowngrade = next.protocol === 'https:' || next.protocol === from.protocol;
|
||||
if (!sameHost || !noDowngrade) {
|
||||
throw new Error(`${url} redirects to ${next.origin}, which is another host`);
|
||||
}
|
||||
return fetch(next.toString(), { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), redirect: 'error' });
|
||||
}
|
||||
|
||||
// One pass over the well-known documents. Returns usable metadata, or null
|
||||
// (pushing diagnostics into `errors`) when neither URL yields a public,
|
||||
// complete document. Each fetch is bounded by a timeout so an unresponsive IdP
|
||||
@@ -95,7 +119,7 @@ async function attemptDiscovery(
|
||||
): Promise<OAuthMetadata | null> {
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) });
|
||||
const response = await fetchDiscoveryDocument(url);
|
||||
if (!response.ok) {
|
||||
errors.push(`${url} returned HTTP ${response.status}`);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user