132 lines
3.3 KiB
JavaScript
132 lines
3.3 KiB
JavaScript
/**
|
|
* Sehr einfache In-Memory Rate-Limits für Nitro/h3.
|
|
*
|
|
* Hinweis: In-Memory ist pro Prozess/Instance. Für horizontale Skalierung
|
|
* sollte das auf Redis o.ä. umgestellt werden (siehe Doku).
|
|
*/
|
|
|
|
const buckets = globalThis.__HTC_RATE_LIMIT_BUCKETS__ || new Map()
|
|
// Persist across hot reloads
|
|
globalThis.__HTC_RATE_LIMIT_BUCKETS__ = buckets
|
|
|
|
function nowMs() {
|
|
return Date.now()
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
}
|
|
|
|
export function getClientIp(event) {
|
|
const xff = getHeader(event, 'x-forwarded-for')
|
|
if (xff) {
|
|
// First IP in list is original client
|
|
const first = xff.split(',')[0]?.trim()
|
|
if (first) return first
|
|
}
|
|
|
|
const realIp = getHeader(event, 'x-real-ip')
|
|
if (realIp) return realIp.trim()
|
|
|
|
return event?.node?.req?.socket?.remoteAddress || 'unknown'
|
|
}
|
|
|
|
function getBucket(key) {
|
|
let b = buckets.get(key)
|
|
if (!b) {
|
|
b = {
|
|
windowStart: nowMs(),
|
|
count: 0,
|
|
consecutiveFails: 0,
|
|
lockedUntil: 0
|
|
}
|
|
buckets.set(key, b)
|
|
}
|
|
return b
|
|
}
|
|
|
|
function normalizeKeyPart(part) {
|
|
return String(part || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, ' ')
|
|
.slice(0, 200)
|
|
}
|
|
|
|
function buildKey(name, keyParts) {
|
|
const parts = (Array.isArray(keyParts) ? keyParts : [keyParts]).map(normalizeKeyPart)
|
|
return `${name}:${parts.join(':')}`
|
|
}
|
|
|
|
function resetWindowIfNeeded(bucket, windowMs, now) {
|
|
if (now - bucket.windowStart >= windowMs) {
|
|
bucket.windowStart = now
|
|
bucket.count = 0
|
|
// consecutiveFails bleibt bewusst erhalten (Backoff für "nervige" Clients)
|
|
}
|
|
}
|
|
|
|
export function assertRateLimit(event, options) {
|
|
const {
|
|
name,
|
|
keyParts,
|
|
windowMs = 10 * 60 * 1000,
|
|
maxAttempts = 10,
|
|
lockoutMs = 15 * 60 * 1000,
|
|
statusCode = 429,
|
|
message = 'Zu viele Versuche. Bitte später erneut versuchen.'
|
|
} = options || {}
|
|
|
|
const key = buildKey(name, keyParts)
|
|
const bucket = getBucket(key)
|
|
const now = nowMs()
|
|
|
|
if (bucket.lockedUntil && bucket.lockedUntil > now) {
|
|
const retryAfterSec = Math.ceil((bucket.lockedUntil - now) / 1000)
|
|
setHeader(event, 'Retry-After', String(retryAfterSec))
|
|
throw createError({ statusCode, statusMessage: message })
|
|
}
|
|
|
|
resetWindowIfNeeded(bucket, windowMs, now)
|
|
|
|
if (bucket.count >= maxAttempts) {
|
|
bucket.lockedUntil = now + lockoutMs
|
|
const retryAfterSec = Math.ceil(lockoutMs / 1000)
|
|
setHeader(event, 'Retry-After', String(retryAfterSec))
|
|
throw createError({ statusCode, statusMessage: message })
|
|
}
|
|
|
|
// Count the attempt
|
|
bucket.count += 1
|
|
}
|
|
|
|
export async function registerRateLimitFailure(event, options) {
|
|
const {
|
|
name,
|
|
keyParts,
|
|
delayBaseMs = 300,
|
|
delayMaxMs = 5000
|
|
} = options || {}
|
|
|
|
const key = buildKey(name, keyParts)
|
|
const bucket = getBucket(key)
|
|
bucket.consecutiveFails = Math.min((bucket.consecutiveFails || 0) + 1, 30)
|
|
|
|
// Exponential backoff: base * 2^(n-1)
|
|
const delay = Math.min(delayBaseMs * Math.pow(2, bucket.consecutiveFails - 1), delayMaxMs)
|
|
await sleep(delay)
|
|
}
|
|
|
|
export function registerRateLimitSuccess(_event, options) {
|
|
const { name, keyParts } = options || {}
|
|
const key = buildKey(name, keyParts)
|
|
const bucket = getBucket(key)
|
|
bucket.consecutiveFails = 0
|
|
// Nach Erfolg darf es "frisch" starten
|
|
bucket.count = 0
|
|
bucket.windowStart = nowMs()
|
|
bucket.lockedUntil = 0
|
|
}
|
|
|
|
|