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:
MastersGame
2026-11-08 10:11:35 +01:00
parent 30c56f9330
commit 05911f4230
18 changed files with 830 additions and 454 deletions

View 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));
});
});

View 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);
}
};
}
}