import { createError, getRequestHeader, setResponseStatus } from 'h3' import { CSP_REPORT_MAX_BYTES, isSupportedCspReportContentType, parseCspReportPayload } from '../../lib/csp-report.js' import { recordCspReports } from '../repositories/csp-report-repository.js' import { assertCspReportRequestAllowed } from '../utils/csp-report-request-limit.js' const STORAGE_FAILURE_LOG_INTERVAL_MS = 5 * 60 * 1000 let lastStorageFailureLogAt = 0 /** * 요청 스트림을 최대 크기 안에서 JSON으로 읽는다. * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {Promise} JSON payload */ const readLimitedJsonBody = async (event) => { const contentLengthHeader = String(getRequestHeader(event, 'content-length') || '').trim() if (contentLengthHeader) { const contentLength = Number(contentLengthHeader) if (!Number.isSafeInteger(contentLength) || contentLength < 0) { throw createError({ statusCode: 400, message: '올바르지 않은 Content-Length입니다.' }) } if (contentLength > CSP_REPORT_MAX_BYTES) { throw createError({ statusCode: 413, message: 'CSP 보고 본문이 너무 큽니다.' }) } } const chunks = [] let byteLength = 0 for await (const chunk of event.node.req) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) byteLength += buffer.byteLength if (byteLength > CSP_REPORT_MAX_BYTES) { throw createError({ statusCode: 413, message: 'CSP 보고 본문이 너무 큽니다.' }) } chunks.push(buffer) } try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw createError({ statusCode: 400, message: '올바른 JSON CSP 보고서가 아닙니다.' }) } } /** * 저장 실패를 원문 정보 없이 제한된 빈도로 기록한다. * @returns {void} */ const logStorageFailure = () => { const now = Date.now() if (now - lastStorageFailureLogAt < STORAGE_FAILURE_LOG_INTERVAL_MS) { return } lastStorageFailureLogAt = now console.warn('CSP 위반 집계 저장에 실패했습니다.') } /** * 인증 없는 CSP 위반 보고를 개인정보 최소화 집계로 저장한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {Promise} 빈 204 응답 */ export default defineEventHandler(async (event) => { assertCspReportRequestAllowed(event) const contentType = String(getRequestHeader(event, 'content-type') || '') if (!isSupportedCspReportContentType(contentType)) { throw createError({ statusCode: 415, message: '지원하지 않는 CSP 보고 형식입니다.' }) } const payload = await readLimitedJsonBody(event) let reports try { reports = parseCspReportPayload( payload, String(getRequestHeader(event, 'user-agent') || '') ) } catch { throw createError({ statusCode: 400, message: '올바른 CSP 보고서가 아닙니다.' }) } try { await recordCspReports(reports) } catch { logStorageFailure() } setResponseStatus(event, 204) return null })