abuse.ts140 lines · main
| 1 | /** |
| 2 | * briven-engine abuse protection: rate limits + optional Turnstile. |
| 3 | * Doltgres-backed counters when Redis is unavailable. |
| 4 | */ |
| 5 | |
| 6 | import type { Context, MiddlewareHandler } from 'hono'; |
| 7 | |
| 8 | import { env } from '../../env.js'; |
| 9 | import { getRedis } from '../../lib/redis.js'; |
| 10 | import { verifyTurnstileToken } from '../auth-turnstile.js'; |
| 11 | import { getEnginePool } from './db.js'; |
| 12 | import { isAuthCoreInitialized } from './engine.js'; |
| 13 | |
| 14 | const WINDOW_MS = 15 * 60 * 1000; |
| 15 | const MAX_HITS = 60; // per IP per window on auth FDI |
| 16 | |
| 17 | function clientIp(c: Context): string { |
| 18 | return ( |
| 19 | c.req.header('cf-connecting-ip')?.trim() || |
| 20 | c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || |
| 21 | c.req.header('x-real-ip')?.trim() || |
| 22 | 'unknown' |
| 23 | ); |
| 24 | } |
| 25 | |
| 26 | async function hitDoltgres(key: string): Promise<{ allowed: boolean; count: number }> { |
| 27 | if (!isAuthCoreInitialized()) return { allowed: true, count: 0 }; |
| 28 | const pool = getEnginePool(); |
| 29 | const now = new Date(); |
| 30 | const res = await pool.query( |
| 31 | `SELECT hit_count, window_start FROM be_rate_limits WHERE bucket_key = $1 LIMIT 1`, |
| 32 | [key], |
| 33 | ); |
| 34 | const row = res.rows[0] as |
| 35 | | { hit_count: number; window_start: Date | string } |
| 36 | | undefined; |
| 37 | if (!row) { |
| 38 | await pool.query( |
| 39 | `INSERT INTO be_rate_limits (bucket_key, hit_count, window_start) VALUES ($1, 1, $2)`, |
| 40 | [key, now.toISOString()], |
| 41 | ); |
| 42 | return { allowed: true, count: 1 }; |
| 43 | } |
| 44 | const start = new Date(row.window_start).getTime(); |
| 45 | if (Date.now() - start > WINDOW_MS) { |
| 46 | await pool.query( |
| 47 | `UPDATE be_rate_limits SET hit_count = 1, window_start = $2 WHERE bucket_key = $1`, |
| 48 | [key, now.toISOString()], |
| 49 | ); |
| 50 | return { allowed: true, count: 1 }; |
| 51 | } |
| 52 | const count = Number(row.hit_count) + 1; |
| 53 | await pool.query( |
| 54 | `UPDATE be_rate_limits SET hit_count = $2 WHERE bucket_key = $1`, |
| 55 | [key, count], |
| 56 | ); |
| 57 | return { allowed: count <= MAX_HITS, count }; |
| 58 | } |
| 59 | |
| 60 | async function hitRedis(key: string): Promise<{ allowed: boolean; count: number } | null> { |
| 61 | const redis = getRedis(); |
| 62 | if (!redis) return null; |
| 63 | const bucket = Math.floor(Date.now() / WINDOW_MS); |
| 64 | const rk = `rl:briven-engine:${key}:${bucket}`; |
| 65 | try { |
| 66 | const count = await redis.incr(rk); |
| 67 | if (count === 1) await redis.pexpire(rk, WINDOW_MS); |
| 68 | return { allowed: count <= MAX_HITS, count }; |
| 69 | } catch { |
| 70 | return null; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Rate-limit middleware for /v1/auth-core/fdi/* |
| 76 | */ |
| 77 | export function brivenEngineFdiRateLimit(): MiddlewareHandler { |
| 78 | return async (c, next) => { |
| 79 | if (!c.req.path.includes('/v1/auth-core/fdi')) { |
| 80 | await next(); |
| 81 | return; |
| 82 | } |
| 83 | const method = c.req.method.toUpperCase(); |
| 84 | if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { |
| 85 | await next(); |
| 86 | return; |
| 87 | } |
| 88 | const ip = clientIp(c); |
| 89 | const redisHit = await hitRedis(ip); |
| 90 | const hit = redisHit ?? (await hitDoltgres(ip)); |
| 91 | c.header('x-briven-engine-ratelimit-limit', String(MAX_HITS)); |
| 92 | c.header('x-briven-engine-ratelimit-count', String(hit.count)); |
| 93 | if (!hit.allowed) { |
| 94 | return c.json( |
| 95 | { |
| 96 | code: 'rate_limited', |
| 97 | engine: 'briven-engine', |
| 98 | message: 'Too many auth attempts. Try again later.', |
| 99 | }, |
| 100 | 429, |
| 101 | ); |
| 102 | } |
| 103 | await next(); |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Optional Turnstile on sensitive FDI mutations. |
| 109 | * Body field: turnstileToken (or cf-turnstile-response). |
| 110 | */ |
| 111 | export async function requireTurnstileIfConfigured( |
| 112 | body: Record<string, unknown>, |
| 113 | ): Promise<{ ok: true } | { ok: false; message: string }> { |
| 114 | const secret = env.BRIVEN_TURNSTILE_SECRET_KEY; |
| 115 | if (!secret) { |
| 116 | // No captcha configured — allow (dev and prod until keys set) |
| 117 | return { ok: true }; |
| 118 | } |
| 119 | const token = |
| 120 | (typeof body.turnstileToken === 'string' && body.turnstileToken) || |
| 121 | (typeof body['cf-turnstile-response'] === 'string' && |
| 122 | body['cf-turnstile-response']) || |
| 123 | ''; |
| 124 | if (!token) { |
| 125 | return { |
| 126 | ok: false, |
| 127 | message: 'turnstileToken required', |
| 128 | }; |
| 129 | } |
| 130 | const result = await verifyTurnstileToken(token); |
| 131 | if (!result.success) { |
| 132 | return { ok: false, message: result.message ?? 'captcha failed' }; |
| 133 | } |
| 134 | return { ok: true }; |
| 135 | } |
| 136 | |
| 137 | export const AUTH_CORE_ABUSE = { |
| 138 | windowMs: WINDOW_MS, |
| 139 | maxHits: MAX_HITS, |
| 140 | }; |