abuse.ts140 lines · main
1/**
2 * briven-engine abuse protection: rate limits + optional Turnstile.
3 * Doltgres-backed counters when Redis is unavailable.
4 */
5
6import type { Context, MiddlewareHandler } from 'hono';
7
8import { env } from '../../env.js';
9import { getRedis } from '../../lib/redis.js';
10import { verifyTurnstileToken } from '../auth-turnstile.js';
11import { getEnginePool } from './db.js';
12import { isAuthCoreInitialized } from './engine.js';
13
14const WINDOW_MS = 15 * 60 * 1000;
15const MAX_HITS = 60; // per IP per window on auth FDI
16
17function 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
26async 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
60async 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 */
77export 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 */
111export 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
137export const AUTH_CORE_ABUSE = {
138 windowMs: WINDOW_MS,
139 maxHits: MAX_HITS,
140};