feat(security): harden the API and lock synchronizations across instances
- Validate the environment at startup (src/config): required variables must be present, HIBOUTIK_BASE_URL must be HTTPS. - Global ValidationPipe (whitelist, forbidNonWhitelisted, transform). - Helmet, CORS disabled unless CORS_ORIGINS is set, TRUST_PROXY support, HTTP header/request timeouts, shutdown hooks. - Rate limiting: 60 req/min/IP globally, 5 req/min on POST /sync. - AllExceptionsFilter: unexpected errors are logged (message + stack, never the raw error object) and answered with a generic 500. - Postgres advisory lock (dedicated connection) so only one synchronization runs at a time across all instances; released by Postgres itself if the process dies. - Fix the Jest moduleNameMapper (unescaped dot also matched .cjs files). - Document the input validation and security rules in CLAUDE.md. - Add class-validator, class-transformer, helmet and @nestjs/throttler; npm audit fix refreshed package-lock.json. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
74
src/database/syncLock.database.repository.spec.ts
Normal file
74
src/database/syncLock.database.repository.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Client } from 'pg';
|
||||
import { SyncLockDatabaseRepository } from './syncLock.database.repository';
|
||||
|
||||
jest.mock('pg', () => ({ Client: jest.fn() }));
|
||||
|
||||
describe('SyncLockDatabaseRepository', () => {
|
||||
const client = { on: jest.fn(), connect: jest.fn(), query: jest.fn(), end: jest.fn() };
|
||||
const configService = { get: jest.fn().mockReturnValue('postgresql://user:pass@localhost:5432/db') };
|
||||
let repository: SyncLockDatabaseRepository;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(Client as unknown as jest.Mock).mockImplementation(() => client);
|
||||
client.connect.mockResolvedValue(undefined);
|
||||
client.end.mockResolvedValue(undefined);
|
||||
client.query.mockResolvedValue({ rows: [{ locked: true }] });
|
||||
repository = new SyncLockDatabaseRepository(configService as never);
|
||||
});
|
||||
|
||||
it('takes the advisory lock on a dedicated connection and keeps it open', async () => {
|
||||
const release = await repository.tryAcquire();
|
||||
|
||||
expect(release).toEqual(expect.any(Function));
|
||||
expect(Client).toHaveBeenCalledWith({ connectionString: 'postgresql://user:pass@localhost:5432/db' });
|
||||
expect(client.query).toHaveBeenCalledWith('SELECT pg_try_advisory_lock($1) AS locked', [expect.any(Number)]);
|
||||
expect(client.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null and closes the connection when the lock is already held', async () => {
|
||||
client.query.mockResolvedValue({ rows: [{ locked: false }] });
|
||||
|
||||
await expect(repository.tryAcquire()).resolves.toBeNull();
|
||||
|
||||
expect(client.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unlocks then closes the connection on release', async () => {
|
||||
const release = await repository.tryAcquire();
|
||||
|
||||
await release!();
|
||||
|
||||
expect(client.query).toHaveBeenLastCalledWith('SELECT pg_advisory_unlock($1)', [expect.any(Number)]);
|
||||
expect(client.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still closes the connection, and does not throw, when the explicit unlock fails', async () => {
|
||||
const release = await repository.tryAcquire();
|
||||
client.query.mockRejectedValueOnce(new Error('connection lost'));
|
||||
|
||||
await expect(release!()).resolves.toBeUndefined();
|
||||
|
||||
expect(client.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes the connection and rethrows when the lock query fails', async () => {
|
||||
client.query.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(repository.tryAcquire()).rejects.toThrow('boom');
|
||||
|
||||
expect(client.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs connection errors instead of leaving them unhandled', async () => {
|
||||
await repository.tryAcquire();
|
||||
|
||||
expect(client.on).toHaveBeenCalledWith('error', expect.any(Function));
|
||||
});
|
||||
});
|
||||
52
src/database/syncLock.database.repository.ts
Normal file
52
src/database/syncLock.database.repository.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Client } from 'pg';
|
||||
|
||||
/** Arbitrary constant identifying the Hiboutik synchronization among the Postgres advisory locks of the database. */
|
||||
const SYNC_LOCK_KEY = 727_001;
|
||||
|
||||
/** Function releasing a lock acquired with `tryAcquire`. Never throws. */
|
||||
export type ReleaseLock = () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Cross-instance lock for the synchronization, based on a Postgres session-level advisory lock.
|
||||
* It lives on its own dedicated connection (not the Prisma pool, where lock and unlock could land on different
|
||||
* connections) kept open for as long as the lock is held. If the process dies, Postgres closes the connection and
|
||||
* releases the lock by itself: there is never a stale lock to clean up.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SyncLockDatabaseRepository {
|
||||
private readonly logger = new Logger('SyncLockDatabaseRepository');
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
/** @returns the function releasing the lock, or `null` if another instance (or process) already holds it. */
|
||||
async tryAcquire(): Promise<ReleaseLock | null> {
|
||||
const client = new Client({ connectionString: this.configService.get<string>('DATABASE_URL') });
|
||||
// A lost connection means Postgres released the lock: log it instead of crashing the process on an unhandled 'error'
|
||||
client.on('error', (error) => this.logger.error(`Synchronization lock connection error: ${error.message}`));
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
const { rows } = await client.query<{ locked: boolean }>('SELECT pg_try_advisory_lock($1) AS locked', [SYNC_LOCK_KEY]);
|
||||
if (!rows[0]?.locked) {
|
||||
await client.end();
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
await client.end().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
await client.query('SELECT pg_advisory_unlock($1)', [SYNC_LOCK_KEY]);
|
||||
} catch (error) {
|
||||
// Ending the connection below releases the lock anyway
|
||||
this.logger.warn(`Failed to unlock the synchronization lock explicitly: ${(error as Error)?.message}`);
|
||||
} finally {
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user