From d28ee361c115c1ba11670edee8b5bb3c9f5ab932 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:53:34 +0100 Subject: [PATCH] fix: refuse an impersonation link when its use can't be recorded #1066 --- lib/__tests__/impersonation-replay-store.test.ts | 10 +++++++++- lib/impersonation/replay-store.ts | 12 +++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/__tests__/impersonation-replay-store.test.ts b/lib/__tests__/impersonation-replay-store.test.ts index b1e3464d6..e05080922 100644 --- a/lib/__tests__/impersonation-replay-store.test.ts +++ b/lib/__tests__/impersonation-replay-store.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { mkdtempSync, readdirSync, rmSync, utimesSync } from 'node:fs'; +import { mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -44,6 +44,14 @@ describe('consumeImpersonationJti', () => { expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(false); }); + it('refuses the token when its marker cannot be written', async () => { + // A state "dir" that is a file: the marker dir can't be created. + const blocked = path.join(stateDir, 'not-a-dir'); + writeFileSync(blocked, ''); + process.env.ADMIN_STATE_DIR = blocked; + expect(await consumeImpersonationJti('jti-unrecorded', 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'); diff --git a/lib/impersonation/replay-store.ts b/lib/impersonation/replay-store.ts index 6f3cba8ac..2f8cf9361 100644 --- a/lib/impersonation/replay-store.ts +++ b/lib/impersonation/replay-store.ts @@ -20,7 +20,8 @@ async function pruneExpired(dir: string, nowMs: number): Promise { } /** - * Record an impersonation token's jti as used; false when it was used before. + * Record an impersonation token's jti as used; false when it was used before + * or the use can't be recorded. * * 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 @@ -45,11 +46,12 @@ export async function consumeImpersonationJti( 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', { + // Without the marker a restart or another replica would accept the + // token again, so it is refused rather than trusted to this process's + // memory. + logger.error('Impersonation replay store unavailable, refusing the token', { error: err instanceof Error ? err.message : 'Unknown', }); - return true; + return false; } }