import { createHash } from 'node:crypto' import { createError, setResponseHeader } from 'h3' import { getClientIp } from './request-ip.js' const CSP_REPORT_WINDOW_MS = 60 * 1000 const CSP_REPORT_REQUEST_LIMIT = 60 const CSP_REPORT_BUCKET_LIMIT = 10000 /** @type {Map} */ const cspReportBuckets = new Map() let lastCleanupAt = 0 /** * 원문 IP를 메모리에 남기지 않는 CSP 요청 제한 키를 만든다. * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {string} IP 해시 키 */ const createCspReportLimitKey = (event) => createHash('sha256') .update(`csp-report:${getClientIp(event)}`) .digest('base64url') /** * 만료되거나 상한을 넘은 CSP 요청 제한 버킷을 정리한다. * @param {number} now - 현재 시각 * @returns {void} */ const cleanupCspReportBuckets = (now) => { if (now - lastCleanupAt < CSP_REPORT_WINDOW_MS && cspReportBuckets.size < CSP_REPORT_BUCKET_LIMIT) { return } lastCleanupAt = now for (const [key, bucket] of cspReportBuckets) { if (bucket.resetAt <= now) { cspReportBuckets.delete(key) } } while (cspReportBuckets.size >= CSP_REPORT_BUCKET_LIMIT) { const oldestKey = cspReportBuckets.keys().next().value if (!oldestKey) { break } cspReportBuckets.delete(oldestKey) } } /** * 현재 IP의 CSP 보고 요청이 분당 상한을 넘지 않았는지 확인한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {void} */ export const assertCspReportRequestAllowed = (event) => { const now = Date.now() const key = createCspReportLimitKey(event) cleanupCspReportBuckets(now) const current = cspReportBuckets.get(key) if (!current || current.resetAt <= now) { cspReportBuckets.set(key, { count: 1, resetAt: now + CSP_REPORT_WINDOW_MS }) return } current.count += 1 if (current.count <= CSP_REPORT_REQUEST_LIMIT) { return } setResponseHeader(event, 'Retry-After', String(Math.max(1, Math.ceil((current.resetAt - now) / 1000)))) throw createError({ statusCode: 429, message: 'CSP 보고 요청이 너무 많습니다.' }) }