mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: never follow redirects on OAuth token and revocation requests
The code exchange, token refresh and revocation POSTs carry the authorization code, refresh token and client secret, and used a plain `fetch` that follows redirects. The endpoints come from a validated discovery document, but a redirect from them (or from an endpoint that changed after discovery) re-sent the body to a host nobody validated. These requests now fail on any redirect. A failed refresh still keeps the refresh token, as for any other outage.
This commit is contained in:
@@ -52,11 +52,14 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada
|
||||
}, serverId, { fallbackClientId: DEFAULT_CLIENT_ID });
|
||||
|
||||
try {
|
||||
// Never follow a redirect with the refresh token and client secret in
|
||||
// the body: the endpoint was validated, its redirect target was not.
|
||||
const revocationResponse = await fetch(metadata.revocation_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS),
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!revocationResponse.ok) {
|
||||
logger.warn('Token revocation returned error', { status: revocationResponse.status });
|
||||
@@ -205,6 +208,8 @@ export async function PUT(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
// See revokeRefreshToken: the body holds the refresh token.
|
||||
redirect: 'error',
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
|
||||
@@ -145,6 +145,28 @@ describe('oauth token route - access token cache (#552)', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never follows a redirect with the refresh token in the body', async () => {
|
||||
cookieStore.set('jmap_rt', 'refresh-token');
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ access_token: 'fresh-token', expires_in: 1800 }),
|
||||
});
|
||||
|
||||
await callPut();
|
||||
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: 'POST', redirect: 'error' });
|
||||
});
|
||||
|
||||
it('keeps the refresh token when the token endpoint redirects', async () => {
|
||||
cookieStore.set('jmap_rt', 'refresh-token');
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed: unexpected redirect'));
|
||||
|
||||
const { status } = await callPut();
|
||||
|
||||
expect(status).toBe(500);
|
||||
expect(cookieStore.get('jmap_rt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ignores an expired cached token', async () => {
|
||||
cookieStore.set('jmap_rt', 'refresh-token');
|
||||
seedCachedToken('long-gone', -600);
|
||||
|
||||
@@ -174,10 +174,14 @@ export async function exchangeCodeForTokens(
|
||||
code_verifier: codeVerifier,
|
||||
}, serverId);
|
||||
|
||||
// The body carries the code, the PKCE verifier and the client secret. The
|
||||
// endpoint was validated; a redirect would re-send all of it to a host
|
||||
// that was not, so none is followed.
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
redirect: 'error',
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
|
||||
Reference in New Issue
Block a user