feat: security audit fixes

This commit is contained in:
ZOMBIIIIIII
2026-05-13 00:17:32 +03:00
parent e87d178d71
commit 1498ed3431
31 changed files with 2198 additions and 339 deletions

View File

@@ -0,0 +1,92 @@
/**
* Idempotency-Key handling — C3 защита от double-spend при retry.
*
* Контракт:
* Client передаёт header `Idempotency-Key: <opaque-string-up-to-128-chars>`.
* Server:
* 1. INSERT row (user_id, key, request_hash) — PK conflict = retry detected.
* 2. На retry: SELECT existing row. Если response_status is null — operation
* ещё in-flight → return 409 "retry too soon". Если response_status set →
* return cached response (same status, same body).
* Retention: 24h. Cleanup via cron.
*/
import { createHash } from 'crypto';
import { db } from '../config/database';
export interface IdempotencyClaim {
fresh: boolean;
cached?: { status: number; body: string };
}
/**
* Try to claim the key. If first time → fresh=true, caller proceeds with mutation.
* If duplicate с existing response → fresh=false + cached response.
* If duplicate с pending in-flight → throws (caller returns 409).
*/
export async function claimIdempotency(
userId: string,
key: string,
requestBody: unknown,
): Promise<IdempotencyClaim> {
const requestHash = createHash('sha256')
.update(JSON.stringify(requestBody ?? {}))
.digest('hex');
try {
await db('idempotency_keys').insert({
user_id: userId,
key,
request_hash: requestHash,
});
return { fresh: true };
} catch (err: any) {
// PK violation = retry
const existing = await db('idempotency_keys')
.where({ user_id: userId, key })
.first();
if (!existing) throw err;
// Verify request body matches (защита от replay с другим body)
if (existing.request_hash !== requestHash) {
throw new Error(`Idempotency-Key reuse with different request body. Use a new key.`);
}
if (existing.response_status === null || existing.response_status === undefined) {
throw new Error('Operation already in flight; retry after a few seconds.');
}
return {
fresh: false,
cached: {
status: existing.response_status as number,
body: existing.response_body as string,
},
};
}
}
/** Сохранить response в idempotency row (после mutation succeeds/fails). */
export async function saveIdempotencyResponse(
userId: string,
key: string,
status: number,
body: string,
): Promise<void> {
await db('idempotency_keys')
.where({ user_id: userId, key })
.update({
response_status: status,
response_body: body,
});
}
/** Validate header format. Returns null if missing/invalid (caller may make mandatory). */
export function extractIdempotencyKey(headerValue: unknown): string | null {
if (typeof headerValue !== 'string') return null;
const v = headerValue.trim();
if (!v) return null;
// Restrict charset: alphanum + dash/underscore, max 128
if (!/^[A-Za-z0-9_-]{1,128}$/.test(v)) return null;
return v;
}