v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
160
lib/csp-report.js
Normal file
160
lib/csp-report.js
Normal file
@@ -0,0 +1,160 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user