Files
sori.studio/server/utils/csp-report-request-limit.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

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 보고 요청이 너무 많습니다.'
})
}