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