세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
78 lines
2.1 KiB
JavaScript
78 lines
2.1 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { createError, setResponseHeader } from 'h3'
|
|
import { getClientIp } from './request-ip.js'
|
|
|
|
const CSP_REPORT_WINDOW_MS = 60 * 1000
|
|
const CSP_REPORT_REQUEST_LIMIT = 60
|
|
const CSP_REPORT_BUCKET_LIMIT = 10000
|
|
|
|
/** @type {Map<string, { count: number, resetAt: number }>} */
|
|
const cspReportBuckets = new Map()
|
|
let lastCleanupAt = 0
|
|
|
|
/**
|
|
* 원문 IP를 메모리에 남기지 않는 CSP 요청 제한 키를 만든다.
|
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
|
* @returns {string} IP 해시 키
|
|
*/
|
|
const createCspReportLimitKey = (event) => createHash('sha256')
|
|
.update(`csp-report:${getClientIp(event)}`)
|
|
.digest('base64url')
|
|
|
|
/**
|
|
* 만료되거나 상한을 넘은 CSP 요청 제한 버킷을 정리한다.
|
|
* @param {number} now - 현재 시각
|
|
* @returns {void}
|
|
*/
|
|
const cleanupCspReportBuckets = (now) => {
|
|
if (now - lastCleanupAt < CSP_REPORT_WINDOW_MS && cspReportBuckets.size < CSP_REPORT_BUCKET_LIMIT) {
|
|
return
|
|
}
|
|
|
|
lastCleanupAt = now
|
|
for (const [key, bucket] of cspReportBuckets) {
|
|
if (bucket.resetAt <= now) {
|
|
cspReportBuckets.delete(key)
|
|
}
|
|
}
|
|
|
|
while (cspReportBuckets.size >= CSP_REPORT_BUCKET_LIMIT) {
|
|
const oldestKey = cspReportBuckets.keys().next().value
|
|
if (!oldestKey) {
|
|
break
|
|
}
|
|
cspReportBuckets.delete(oldestKey)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 IP의 CSP 보고 요청이 분당 상한을 넘지 않았는지 확인한다.
|
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
|
* @returns {void}
|
|
*/
|
|
export const assertCspReportRequestAllowed = (event) => {
|
|
const now = Date.now()
|
|
const key = createCspReportLimitKey(event)
|
|
cleanupCspReportBuckets(now)
|
|
|
|
const current = cspReportBuckets.get(key)
|
|
if (!current || current.resetAt <= now) {
|
|
cspReportBuckets.set(key, {
|
|
count: 1,
|
|
resetAt: now + CSP_REPORT_WINDOW_MS
|
|
})
|
|
return
|
|
}
|
|
|
|
current.count += 1
|
|
if (current.count <= CSP_REPORT_REQUEST_LIMIT) {
|
|
return
|
|
}
|
|
|
|
setResponseHeader(event, 'Retry-After', String(Math.max(1, Math.ceil((current.resetAt - now) / 1000))))
|
|
throw createError({
|
|
statusCode: 429,
|
|
message: 'CSP 보고 요청이 너무 많습니다.'
|
|
})
|
|
}
|