Files
sori.studio/server/api/csp-report.post.js
zenn de767095ba v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:05:40 +09:00

119 lines
3.1 KiB
JavaScript

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<unknown>} 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<null>} 빈 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
})