import { z } from 'zod' export const CSP_REPORT_POLICY_VERSION = 'compat-v1' export const CSP_REPORT_MAX_BYTES = 32 * 1024 export const CSP_REPORT_MAX_ITEMS = 20 const reportBodySchema = z.object({ documentURL: z.string().optional(), documentUri: z.string().optional(), 'document-uri': z.string().optional(), blockedURL: z.string().optional(), blockedUri: z.string().optional(), 'blocked-uri': z.string().optional(), effectiveDirective: z.string().optional(), 'effective-directive': z.string().optional(), violatedDirective: z.string().optional(), 'violated-directive': z.string().optional() }).passthrough() const legacyReportSchema = z.object({ 'csp-report': reportBodySchema }).passthrough() const reportingApiReportSchema = z.object({ type: z.string().optional(), body: reportBodySchema }).passthrough() const cspPayloadSchema = z.union([ legacyReportSchema, reportingApiReportSchema, z.array(reportingApiReportSchema).min(1).max(CSP_REPORT_MAX_ITEMS) ]) /** * CSP 수집 API가 허용하는 JSON 계열 미디어 타입인지 확인한다. * @param {string} contentType - Content-Type 헤더 * @returns {boolean} 허용 여부 */ export const isSupportedCspReportContentType = (contentType) => { const mediaType = String(contentType || '').split(';', 1)[0].trim().toLowerCase() return mediaType === 'application/csp-report' || mediaType === 'application/reports+json' || mediaType === 'application/json' || (mediaType.startsWith('application/') && mediaType.endsWith('+json')) } /** * URL에서 쿼리와 fragment를 제외한 문서 경로만 추출한다. * @param {string} value - 문서 URL * @returns {string} 정규화된 문서 경로 */ export const normalizeCspDocumentPath = (value) => { try { const url = new URL(String(value || ''), 'https://csp.invalid') const pathname = url.pathname .replace(/\/{2,}/g, '/') .slice(0, 512) return pathname.startsWith('/') ? pathname : '/' } catch { return '/' } } /** * 차단 URL에서 경로·쿼리·fragment를 제거하고 origin 수준으로 축약한다. * @param {string} value - 차단 URL * @returns {string} 차단 origin 또는 안전한 분류값 */ export const normalizeCspBlockedOrigin = (value) => { const normalized = String(value || '').trim().toLowerCase() if (!normalized) { return 'unknown' } if (['inline', 'eval', 'self'].includes(normalized)) { return normalized } if (/^(data|blob|filesystem):/.test(normalized)) { return `${normalized.split(':', 1)[0]}:` } try { const url = new URL(normalized) return ['http:', 'https:'].includes(url.protocol) ? url.origin.slice(0, 255) : `${url.protocol}`.slice(0, 32) } catch { return 'unknown' } } /** * User-Agent 원문을 저장하지 않도록 브라우저 계열로 축약한다. * @param {string} userAgent - User-Agent 헤더 * @returns {string} 브라우저 계열 */ export const classifyCspUserAgentFamily = (userAgent) => { const value = String(userAgent || '') if (/Edg\//i.test(value)) { return 'Edge' } if (/Firefox\//i.test(value)) { return 'Firefox' } if (/Chrome\//i.test(value) || /Chromium\//i.test(value)) { return 'Chrome' } if (/Safari\//i.test(value) && /Version\//i.test(value)) { return 'Safari' } return 'Other' } /** * 지시어 이름을 집계 키로 사용할 수 있는 짧은 값으로 정규화한다. * @param {string} value - CSP 지시어 * @returns {string} 정규화된 지시어 */ const normalizeDirective = (value) => { const directive = String(value || '').trim().toLowerCase().split(/\s+/, 1)[0] return /^[a-z][a-z0-9-]{0,63}$/.test(directive) ? directive : 'unknown' } /** * CSP 보고 payload를 검증하고 개인정보가 최소화된 집계 행으로 변환한다. * @param {unknown} payload - JSON payload * @param {string} userAgent - User-Agent 헤더 * @returns {Array<{ policyVersion: string, documentPath: string, effectiveDirective: string, blockedOrigin: string, userAgentFamily: string }>} 집계 행 목록 */ export const parseCspReportPayload = (payload, userAgent) => { const parsed = cspPayloadSchema.safeParse(payload) if (!parsed.success) { throw new Error('INVALID_CSP_REPORT') } const reports = Array.isArray(parsed.data) ? parsed.data : [parsed.data] return reports.map((report) => { const body = 'csp-report' in report ? report['csp-report'] : report.body const documentUrl = body.documentURL || body.documentUri || body['document-uri'] || '' const blockedUrl = body.blockedURL || body.blockedUri || body['blocked-uri'] || '' const directive = body.effectiveDirective || body['effective-directive'] || body.violatedDirective || body['violated-directive'] || '' return { policyVersion: CSP_REPORT_POLICY_VERSION, documentPath: normalizeCspDocumentPath(documentUrl), effectiveDirective: normalizeDirective(directive), blockedOrigin: normalizeCspBlockedOrigin(blockedUrl), userAgentFamily: classifyCspUserAgentFamily(userAgent) } }) }