세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
119 lines
3.1 KiB
JavaScript
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
|
|
})
|