mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: refuse a used impersonation link after a restart or on another replica
Used impersonation token ids were only remembered in process memory, so a captured handoff link could be redeemed a second time within its five minute lifetime once the process restarted, or on another replica. Each used id now also leaves a marker file in the state directory, created exclusively, which every replica sharing the state volume sees; markers are pruned once the token could no longer verify. The absolute session limit comes from the eight-hour app password that Stalwart itself expires.
This commit is contained in:
@@ -7,11 +7,8 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
|
||||
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import {
|
||||
ImpersonationJwtError,
|
||||
impersonationReplayCache,
|
||||
verifyImpersonationJwt,
|
||||
} from '@/lib/impersonation/jwt';
|
||||
import { ImpersonationJwtError, verifyImpersonationJwt } from '@/lib/impersonation/jwt';
|
||||
import { consumeImpersonationJti } from '@/lib/impersonation/replay-store';
|
||||
import {
|
||||
readImpersonationConfig,
|
||||
resolveImpersonationServerUrl,
|
||||
@@ -81,7 +78,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
|
||||
if (!(await consumeImpersonationJti(claims.jti, claims.exp))) {
|
||||
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
|
||||
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, readdirSync, rmSync, utimesSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { IMPERSONATION_REPLAY_WINDOW_SEC, impersonationReplayCache } from '@/lib/impersonation/jwt';
|
||||
import { consumeImpersonationJti } from '@/lib/impersonation/replay-store';
|
||||
|
||||
// The jti cache lived only in process memory: after a restart, or on another
|
||||
// replica, a captured handoff link could be redeemed again.
|
||||
|
||||
let stateDir: string;
|
||||
const previous = process.env.ADMIN_STATE_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
stateDir = mkdtempSync(path.join(tmpdir(), 'bw-jti-'));
|
||||
process.env.ADMIN_STATE_DIR = stateDir;
|
||||
impersonationReplayCache.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previous === undefined) delete process.env.ADMIN_STATE_DIR;
|
||||
else process.env.ADMIN_STATE_DIR = previous;
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('consumeImpersonationJti', () => {
|
||||
const now = Date.now();
|
||||
const exp = Math.floor(now / 1000) + 120;
|
||||
|
||||
it('refuses a jti the second time', async () => {
|
||||
expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(true);
|
||||
expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('still refuses it after the in-process cache is gone (restart, other replica)', async () => {
|
||||
expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(true);
|
||||
impersonationReplayCache.clear();
|
||||
expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('prunes markers once their token can no longer verify', async () => {
|
||||
await consumeImpersonationJti('jti-old', exp, now);
|
||||
const dir = path.join(stateDir, 'impersonation-jti');
|
||||
const [marker] = readdirSync(dir);
|
||||
const old = (now - (IMPERSONATION_REPLAY_WINDOW_SEC + 5) * 1000) / 1000;
|
||||
utimesSync(path.join(dir, marker), old, old);
|
||||
|
||||
await consumeImpersonationJti('jti-new', exp, now);
|
||||
expect(readdirSync(dir)).toHaveLength(1);
|
||||
expect(readdirSync(dir)).not.toContain(marker);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
// @vitest-environment node
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
vi.mock('@/lib/auth/session-secret', () => ({
|
||||
@@ -78,7 +81,12 @@ async function readSession() {
|
||||
}
|
||||
|
||||
describe('impersonation handoff', () => {
|
||||
// Used jtis are recorded in the state directory.
|
||||
const stateDir = mkdtempSync(path.join(tmpdir(), 'bw-imp-'));
|
||||
afterAll(() => rmSync(stateDir, { recursive: true, force: true }));
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.ADMIN_STATE_DIR = stateDir;
|
||||
process.env.BULWARK_JWT_AUTH_SECRET = SECRET;
|
||||
process.env.BULWARK_STALWART_MASTER_USER = 'master@example.org';
|
||||
process.env.BULWARK_STALWART_MASTER_PASSWORD = MASTER_PASSWORD;
|
||||
|
||||
@@ -26,6 +26,13 @@ const MAX_TOKEN_LIFETIME_SEC = 300;
|
||||
const CLOCK_SKEW_SEC = 60;
|
||||
const MIN_SECRET_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* How long after it is first redeemed a token can still verify: `iat` may
|
||||
* run CLOCK_SKEW_SEC ahead, `exp` is at most MAX_TOKEN_LIFETIME_SEC after
|
||||
* `iat`, and `exp` is honoured for another CLOCK_SKEW_SEC.
|
||||
*/
|
||||
export const IMPERSONATION_REPLAY_WINDOW_SEC = MAX_TOKEN_LIFETIME_SEC + 2 * CLOCK_SKEW_SEC;
|
||||
|
||||
function base64UrlDecode(input: string): Buffer {
|
||||
const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4);
|
||||
const b64 = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad);
|
||||
@@ -141,12 +148,10 @@ export function verifyImpersonationJwt(
|
||||
|
||||
// ─── Replay protection ──────────────────────────────────────────
|
||||
// In-memory LRU keyed by jti. Entries expire automatically once their
|
||||
// underlying JWT could no longer be replayed (exp + skew). On a multi-pod
|
||||
// deployment each pod has its own cache; that's acceptable because a token
|
||||
// stolen mid-flight could only be replayed against the pod that already
|
||||
// consumed it (and that pod will reject it). For stronger guarantees,
|
||||
// platforms can issue per-pod-routed tokens or front Bulwark with a
|
||||
// single-leader load balancer for the impersonate route.
|
||||
// underlying JWT could no longer be replayed (exp + skew). This is the fast
|
||||
// path; consumeImpersonationJti() in replay-store.ts backs it with marker
|
||||
// files in the state directory so a restart or another replica sharing the
|
||||
// state volume still refuses a used token.
|
||||
|
||||
const REPLAY_CACHE_MAX = 4096;
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, open, readdir, stat, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getStatePath } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { IMPERSONATION_REPLAY_WINDOW_SEC, impersonationReplayCache } from './jwt';
|
||||
|
||||
const STORE_DIR = 'impersonation-jti';
|
||||
|
||||
async function pruneExpired(dir: string, nowMs: number): Promise<void> {
|
||||
for (const name of await readdir(dir)) {
|
||||
const file = path.join(dir, name);
|
||||
try {
|
||||
const { mtimeMs } = await stat(file);
|
||||
if (nowMs - mtimeMs > IMPERSONATION_REPLAY_WINDOW_SEC * 1000) await unlink(file);
|
||||
} catch {
|
||||
// Another process pruned it first.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an impersonation token's jti as used; false when it was used before.
|
||||
*
|
||||
* The in-process cache alone forgot every jti on restart and knew nothing of
|
||||
* other replicas, so a captured handoff link could be redeemed again within
|
||||
* its lifetime. Each jti also gets a marker file in the state directory,
|
||||
* created with O_EXCL, which survives restarts and is shared by every
|
||||
* replica that mounts the same state volume. Markers are pruned once the
|
||||
* token they stand for can no longer verify.
|
||||
*/
|
||||
export async function consumeImpersonationJti(
|
||||
jti: string,
|
||||
exp: number,
|
||||
nowMs: number = Date.now(),
|
||||
): Promise<boolean> {
|
||||
if (!impersonationReplayCache.consume(jti, exp, Math.floor(nowMs / 1000))) return false;
|
||||
|
||||
const dir = getStatePath(STORE_DIR);
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await pruneExpired(dir, nowMs);
|
||||
const marker = await open(path.join(dir, createHash('sha256').update(jti).digest('hex')), 'wx');
|
||||
await marker.close();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
||||
// The state directory is documented as always writable; if it is not,
|
||||
// keep impersonation working on the in-process cache.
|
||||
logger.warn('Impersonation replay store unavailable, using the in-process cache only', {
|
||||
error: err instanceof Error ? err.message : 'Unknown',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user