mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 01:22:54 +00:00
✂️ fix: Cap Audit Chain Verification and Honor Client Cancellation (#13903)
This commit is contained in:
parent
ddc763595a
commit
d9251fc3b6
7 changed files with 115 additions and 7 deletions
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue