✂️ fix: Cap Audit Chain Verification and Honor Client Cancellation (#13903)

This commit is contained in:
Danny Avila 2026-06-23 08:33:16 -04:00 • committed by GitHub
parent ddc763595a
commit d9251fc3b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 115 additions and 7 deletions

View file

@ -1,6 +1,6 @@
import { Types } from 'mongoose';
import { EventEmitter } from 'events';
import { MAX_AUDIT_EXPORT_ROWS } from '@librechat/data-schemas';
import { MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_VERIFY_ROWS } from '@librechat/data-schemas';
import type {
AdminAuditLogEntry,
AuditChainVerification,
@ -64,11 +64,20 @@ function createReqRes(
user: 'user' in overrides ? overrides.user : { _id: new Types.ObjectId(), role: 'admin' },
} as unknown as ServerRequest;
const emitter = new EventEmitter();
const reqEmitter = new EventEmitter();
const json = jest.fn();
const status = jest.fn().mockReturnValue({ json });
const res = { status, json } as unknown as Response;
const res = Object.assign(emitter, {
status,
json,
removeListener: emitter.removeListener.bind(emitter),
}) as unknown as Response;
const eventReq = Object.assign(reqEmitter, req, {
removeListener: reqEmitter.removeListener.bind(reqEmitter),
}) as unknown as ServerRequest;
return { req, res, status, json };
return { req: eventReq, res, status, json, emitter, reqEmitter };
}
interface CsvCaptureContext {
@ -351,10 +360,29 @@ describe('createAdminAuditLogHandlers', () => {
});
await handlers.verifyAuditLog(req, res);
expect((deps.verifyAuditChain as jest.Mock).mock.calls[0][0]).toBe('real-tenant');
expect((deps.verifyAuditChain as jest.Mock).mock.calls[0][1].maxRows).toBe(
MAX_AUDIT_VERIFY_ROWS,
);
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith(verification);
});
it('threads cancellation into verification and skips the response after client close', async () => {
const ctx = createReqRes();
const deps = createDeps({
verifyAuditChain: jest.fn(async (_tenantId, options) => {
ctx.emitter.emit('close');
options?.isCancelled?.();
return mockVerification({ ok: false, reason: 'verification cancelled' });
}),
});
const handlers = createAdminAuditLogHandlers(deps);
await handlers.verifyAuditLog(ctx.req, ctx.res);
const optionsArg = (deps.verifyAuditChain as jest.Mock).mock.calls[0][1];
expect(optionsArg.isCancelled()).toBe(true);
expect(ctx.status).not.toHaveBeenCalled();
});
it('returns 500 when verification throws', async () => {
const deps = createDeps({
verifyAuditChain: jest.fn().mockRejectedValue(new Error('boom')),

View file

@ -7,6 +7,7 @@ import {
AUDIT_ACTOR_TYPES,
MAX_AUDIT_EXPORT_ROWS,
MAX_AUDIT_LOG_LIMIT,
MAX_AUDIT_VERIFY_ROWS,
} from '@librechat/data-schemas';
import type {
AdminAuditLogEntry,
@ -59,7 +60,10 @@ export interface AdminAuditLogDeps {
onEntry: (entry: AdminAuditLogEntry) => void | Promise<void>,
options?: { isCancelled?: () => boolean; maxRows?: number },
) => Promise<{ count: number; truncated: boolean }>;
verifyAuditChain: (tenantId: string | undefined) => Promise<AuditChainVerification>;
verifyAuditChain: (
tenantId: string | undefined,
options?: { isCancelled?: () => boolean; maxRows?: number },
) => Promise<AuditChainVerification>;
}
interface CallerContext {
@ -328,8 +332,25 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps): {
const caller = resolveCaller(req);
if (!caller) return res.status(401).json({ error: 'Authentication required' });
const result = await verifyAuditChain(caller.tenantId);
return res.status(200).json(result);
let clientAborted = false;
const markAborted = () => {
clientAborted = true;
};
res.once('close', markAborted);
req.once('aborted', markAborted);
try {
const result = await verifyAuditChain(caller.tenantId, {
isCancelled: () => clientAborted,
maxRows: MAX_AUDIT_VERIFY_ROWS,
});
if (clientAborted) return res;
return res.status(200).json(result);
} finally {
res.removeListener('close', markAborted);
req.removeListener('aborted', markAborted);
}
} catch (err) {
logger.error('[adminAuditLog] verify error:', err);
return res.status(500).json({ error: 'Failed to verify audit log integrity' });

View file

@ -27,6 +27,7 @@ export {
AUDIT_SCHEMA_VERSION,
MAX_AUDIT_EXPORT_ROWS,
MAX_AUDIT_LOG_LIMIT,
MAX_AUDIT_VERIFY_ROWS,
} from './methods';
export type * from './types';
export type * from './methods';

View file

@ -351,6 +351,30 @@ describe('auditLog methods', () => {
expect(result.checked).toBe(0);
});
it('stops verification when the caller cancels', async () => {
await seed(3);
const result = await methods.verifyAuditChain('tenant-a', { isCancelled: () => true });
expect(result.ok).toBe(false);
expect(result.checked).toBe(0);
expect(result.reason).toBe('verification cancelled');
});
it('bounds verification when rows exceed the configured cap', async () => {
await seed(3);
const result = await methods.verifyAuditChain('tenant-a', { maxRows: 2 });
expect(result.ok).toBe(false);
expect(result.checked).toBe(2);
expect(result.brokenAt).toBe(3);
expect(result.reason).toMatch(/row limit exceeded/);
});
it('allows an exact-cap verification to complete', async () => {
await seed(2);
const result = await methods.verifyAuditChain('tenant-a', { maxRows: 2 });
expect(result.ok).toBe(true);
expect(result.checked).toBe(2);
});
it('detects a tampered field (hash mismatch)', async () => {
await seed(3);
// mutate a field via the raw driver, bypassing the append-only hooks

View file

@ -27,6 +27,11 @@ export const MAX_AUDIT_LOG_LIMIT = 500;
* should be sliced by `from`/`to`.
*/
export const MAX_AUDIT_EXPORT_ROWS = 100_000;
/**
* Upper bound on rows verified in a single HTTP-triggered integrity check. Full
* offline jobs can pass a larger value explicitly when needed.
*/
export const MAX_AUDIT_VERIFY_ROWS = 100_000;
/** Record-format version stamped on every new entry. */
export const AUDIT_SCHEMA_VERSION = 1;
const MAX_SEARCH_LENGTH = 200;
@ -516,6 +521,8 @@ export function createAuditLogMethods(mongoose: typeof import('mongoose')): Audi
.cursor({ batchSize: 500 });
const trustedCheckpoint = options?.trustedCheckpoint;
const isCancelled = options?.isCancelled;
const maxRows = options?.maxRows;
let prevHash = GENESIS_HASH;
let expectedSeq: number | null = null;
let firstSeq: number | null = null;
@ -524,6 +531,28 @@ export function createAuditLogMethods(mongoose: typeof import('mongoose')): Audi
try {
for await (const doc of cursor) {
if (isCancelled?.()) {
await cursor.close();
return {
ok: false,
chainKey,
checked,
reason: 'verification cancelled',
range: firstSeq !== null ? { firstSeq, lastSeq } : undefined,
};
}
if (maxRows != null && checked >= maxRows) {
await cursor.close();
return {
ok: false,
chainKey,
checked,
brokenAt: doc.seq,
reason: `verification row limit exceeded (${maxRows})`,
range:
firstSeq !== null ? { firstSeq, lastSeq } : { firstSeq: doc.seq, lastSeq: doc.seq },
};
}
if (firstSeq === null) {
firstSeq = doc.seq;
expectedSeq = doc.seq;

View file

@ -25,6 +25,7 @@ import {
AUDIT_SCHEMA_VERSION,
MAX_AUDIT_EXPORT_ROWS,
MAX_AUDIT_LOG_LIMIT,
MAX_AUDIT_VERIFY_ROWS,
type AuditLogMethods,
} from './auditLog';
import { createShareMethods, type ShareMethods } from './share';
@ -108,7 +109,7 @@ export {
deriveStructuredFrontmatterFields,
inferSkillFileCategory,
};
export { AUDIT_SCHEMA_VERSION, MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT };
export { AUDIT_SCHEMA_VERSION, MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT, MAX_AUDIT_VERIFY_ROWS };
export type AllMethods = UserMethods &
SessionMethods &

View file

@ -166,6 +166,10 @@ export interface PurgeAuditLogResult {
}
export interface VerifyAuditChainOptions {
/** Stop verification early when the caller has gone away. */
isCancelled?: () => boolean;
/** Maximum rows to inspect before returning a bounded, non-OK result. */
maxRows?: number;
/** When the chain no longer starts at `seq: 1` (a prefix was purged), the
* verifier requires this boundary to distinguish an authorized retention purge
* from an attacker deleting the oldest rows. Without it, a non-genesis start