mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: stop the password and TOTP sign-in from re-posting the password to another host when the mail server redirects
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
|
||||
}));
|
||||
|
||||
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
|
||||
|
||||
// The password login posts the password, the authorization code, the PKCE
|
||||
// verifier and the client secret to an admin-configured server. A redirect
|
||||
// from that server (or whatever sits in front of it) must not carry them to
|
||||
// another origin.
|
||||
|
||||
interface Received { path: string; body: string }
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
async function serve(handler: (req: http.IncomingMessage, body: string, res: http.ServerResponse) => void) {
|
||||
const received: Received[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
received.push({ path: req.url ?? '', body });
|
||||
handler(req, body, res);
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return { origin: `http://127.0.0.1:${port}`, received };
|
||||
}
|
||||
|
||||
function stalwart(req: http.IncomingMessage, _body: string, res: http.ServerResponse) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(req.url?.endsWith('/api/auth')
|
||||
? { type: 'authenticated', client_code: 'code-1' }
|
||||
: { access_token: 'at', refresh_token: 'rt', expires_in: 3600 }));
|
||||
}
|
||||
|
||||
function login(serverUrl: string) {
|
||||
return stalwartPasswordLogin({
|
||||
serverUrl,
|
||||
username: 'alice@example.org',
|
||||
password: 'hunter2',
|
||||
clientId: 'webmail',
|
||||
clientSecret: 'admin-secret',
|
||||
redirectUri: 'https://webmail.example/en/auth/callback',
|
||||
trusted: true,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((s) => new Promise((resolve) => s.close(resolve))));
|
||||
});
|
||||
|
||||
describe('stalwartPasswordLogin redirects', () => {
|
||||
it('does not re-post the password to another origin', async () => {
|
||||
const other = await serve(stalwart);
|
||||
const mail = await serve((req, _body, res) => {
|
||||
res.statusCode = 307;
|
||||
res.setHeader('Location', `${other.origin}${req.url}`);
|
||||
res.end();
|
||||
});
|
||||
|
||||
const result = await login(mail.origin);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(other.received).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not send the code and client secret to another origin', async () => {
|
||||
const other = await serve(stalwart);
|
||||
const mail = await serve((req, body, res) => {
|
||||
if (req.url?.endsWith('/auth/token')) {
|
||||
res.statusCode = 308;
|
||||
res.setHeader('Location', `${other.origin}/auth/token`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
stalwart(req, body, res);
|
||||
});
|
||||
|
||||
const result = await login(mail.origin);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'token_exchange_failed' });
|
||||
expect(other.received).toEqual([]);
|
||||
});
|
||||
|
||||
it('follows a redirect that stays on the same origin', async () => {
|
||||
const mail = await serve((req, body, res) => {
|
||||
if (!req.url?.startsWith('/stalwart/')) {
|
||||
res.statusCode = 308;
|
||||
res.setHeader('Location', `/stalwart${req.url}`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
stalwart(req, body, res);
|
||||
});
|
||||
|
||||
const result = await login(mail.origin);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, tokens: { access_token: 'at' } });
|
||||
expect(mail.received.map((r) => r.path)).toEqual([
|
||||
'/api/auth', '/stalwart/api/auth', '/auth/token', '/stalwart/auth/token',
|
||||
]);
|
||||
expect(mail.received[1].body).toContain('hunter2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedLoginRedirect', () => {
|
||||
it('allows the same origin and an http to https upgrade of the same host only', async () => {
|
||||
const { isAllowedLoginRedirect } = await import('@/lib/auth/stalwart-password-login');
|
||||
expect(isAllowedLoginRedirect('https://mail.example/api/auth', 'https://mail.example/s/api/auth')).toBe(true);
|
||||
expect(isAllowedLoginRedirect('http://mail.example/api/auth', 'https://mail.example/api/auth')).toBe(true);
|
||||
expect(isAllowedLoginRedirect('http://mail.example:8080/api/auth', 'https://mail.example/api/auth')).toBe(true);
|
||||
expect(isAllowedLoginRedirect('https://mail.example/api/auth', 'http://mail.example/api/auth')).toBe(false);
|
||||
expect(isAllowedLoginRedirect('https://mail.example/api/auth', 'https://other.example/api/auth')).toBe(false);
|
||||
expect(isAllowedLoginRedirect('https://mail.example/api/auth', 'https://mail.example:8443/api/auth')).toBe(false);
|
||||
expect(isAllowedLoginRedirect('http://mail.example/api/auth', 'http://mail.example.evil/api/auth')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -96,12 +96,36 @@ async function upstreamDetail(response: Response, trusted: boolean): Promise<str
|
||||
return (await response.text()).substring(0, 500);
|
||||
}
|
||||
|
||||
/** A redirect that stays on the same origin, or upgrades the same host to https. */
|
||||
export function isAllowedLoginRedirect(from: string, to: string): boolean {
|
||||
const a = new URL(from);
|
||||
const b = new URL(to);
|
||||
if (a.origin === b.origin) return true;
|
||||
return a.hostname === b.hostname && a.protocol === 'http:' && b.protocol === 'https:';
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch for an admin-configured server. The requests carry the password, the
|
||||
* authorization code, the PKCE verifier and the client secret, so a redirect
|
||||
* is followed once and only within the same host; anywhere else is an error.
|
||||
*/
|
||||
async function fetchConfiguredServer(input: string, init: RequestInit): Promise<Response> {
|
||||
const first = await fetch(input, { ...init, redirect: 'manual' });
|
||||
if (first.status < 300 || first.status >= 400) return first;
|
||||
const location = first.headers.get('location');
|
||||
const next = location ? new URL(location, input).toString() : null;
|
||||
if (!next || !isAllowedLoginRedirect(input, next)) {
|
||||
throw new Error(`${input} redirects to another host`);
|
||||
}
|
||||
return fetch(next, { ...init, redirect: 'error' });
|
||||
}
|
||||
|
||||
export async function stalwartPasswordLogin(
|
||||
opts: StalwartPasswordLoginOptions,
|
||||
): Promise<StalwartPasswordLoginResult> {
|
||||
const base = trimUrl(opts.serverUrl);
|
||||
const upstreamFetch: typeof fetch = opts.trusted
|
||||
? fetch
|
||||
? ((input, init) => fetchConfiguredServer(String(input), init ?? {}))
|
||||
: ((input, init) => fetchPublicUrl(String(input), init as Parameters<typeof fetchPublicUrl>[1]) as unknown as Promise<Response>);
|
||||
|
||||
// PKCE proves the token exchange originates from the same client that
|
||||
|
||||
Reference in New Issue
Block a user