From caf767612d2867d901849a8ae9a21c7970d6cbdf Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Thu, 1 Oct 2026 05:44:03 +0100 Subject: [PATCH 1/3] feat(webhook): add response size limit and request timeout for dispatch Create webhook dispatcher with 1MB response body limit using AbortController for timeout and ArrayBuffer size checking. Protects against malicious endpoints sending unbounded responses. Refs #493 --- src/services/webhook-dispatcher.ts | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/services/webhook-dispatcher.ts diff --git a/src/services/webhook-dispatcher.ts b/src/services/webhook-dispatcher.ts new file mode 100644 index 0000000..954bf11 --- /dev/null +++ b/src/services/webhook-dispatcher.ts @@ -0,0 +1,72 @@ +import { logger } from "../utils/logger.js"; + +const MAX_RESPONSE_BYTES = 1 * 1024 * 1024; // 1 MB +const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds + +interface WebhookPayload { + event: string; + data: Record; + timestamp: string; +} + +interface DispatchResult { + success: boolean; + statusCode?: number; + error?: string; +} + +/** + * Dispatch a webhook notification to the given URL. + * Protects against oversized responses and slow endpoints. + */ +export async function dispatchWebhook( + url: string, + payload: WebhookPayload, + options?: { timeoutMs?: number } +): Promise { + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const body = JSON.stringify(payload); + + const response: any = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body).toString(), + }, + body, + signal: controller.signal, + }); + + // Read response with size limit + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_RESPONSE_BYTES) { + logger.warn( + { url, bytes: arrayBuffer.byteLength }, + "Webhook response exceeded max size" + ); + return { + success: false, + statusCode: response.status, + error: `Response body too large (${arrayBuffer.byteLength} bytes, max ${MAX_RESPONSE_BYTES})`, + }; + } + + return { + success: response.ok, + statusCode: response.status, + }; + } catch (err: any) { + if (err.name === "AbortError") { + logger.warn({ url, timeoutMs }, "Webhook request timed out"); + return { success: false, error: `Request timed out after ${timeoutMs}ms` }; + } + logger.error({ url, err }, "Webhook dispatch failed"); + return { success: false, error: err.message }; + } finally { + clearTimeout(timer); + } +} \ No newline at end of file From e77f1bab47c8a1c70f2ea04d683f2cf836279bbf Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Thu, 1 Oct 2026 05:44:08 +0100 Subject: [PATCH 2/3] feat(security): add CSRF documentation and admin auth-blocks endpoint Document CSRF protection approach for cookie-based auth (currently Bearer tokens are CSRF-safe). Add admin endpoints to view and clear IP-based auth blocks. Refs #494, Refs #495 --- src/server.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/server.ts b/src/server.ts index aee4747..5d60e0d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { config } from "./config/index.js"; import { logger } from "./utils/logger.js"; import { registerErrorHandler } from "./middleware/error-handler.js"; import { rateLimitOptions } from "./middleware/rate-limit.js"; +import { listBlockedIps, clearIpBlock } from "./middleware/auth-brute-force.js"; // Route modules import { authRoutes } from "./modules/auth/auth.routes.js"; @@ -45,6 +46,19 @@ async function buildApp() { await app.register(rateLimit, rateLimitOptions()); + // ─── CSRF Protection ─────────────────────────────────────────────────── + // Auth uses Bearer tokens (Authorization header), which are CSRF-safe. + // credentials: true in CORS only matters if auth moves to cookies. + // If cookie-based auth is added, enable @fastify/csrf-protection here: + // + // import csrf from "@fastify/csrf-protection"; + // await app.register(csrf, { + // sessionPlugin: "@fastify/cookie", + // csrfOpts: { ignoreMethods: ["GET", "HEAD", "OPTIONS"] }, + // }); + // + // Until then, no CSRF token generation or validation is needed. + // ─── Error Handler ────────────────────────────────────────────────────── registerErrorHandler(app); @@ -55,6 +69,20 @@ async function buildApp() { uptime: process.uptime(), })); + // ─── Admin: Auth IP Blocks ───────────────────────────────────────────── + app.get("/admin/auth-blocks", async (_request, reply) => { + const blocks = await listBlockedIps(); + reply.send({ blocks }); + }); + + app.delete<{ Params: { ip: string } }>( + "/admin/auth-blocks/:ip", + async (request, reply) => { + const cleared = await clearIpBlock(request.params.ip); + reply.send({ cleared }); + } + ); + // ─── API Routes ───────────────────────────────────────────────────────── await app.register(authRoutes, { prefix: "/api/auth" }); await app.register(userRoutes, { prefix: "/api/users" }); From 50335f4c9456a13e70c8d7a4dce108aad52050a3 Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Thu, 1 Oct 2026 05:44:14 +0100 Subject: [PATCH 3/3] feat(auth): add IP-based blocking for repeated auth failures Track failed auth attempts per IP in Redis. After 10 failures within 5 minutes, temporarily block the IP for 5 minutes. Blocked requests return 429 with Retry-After header. Integrates with auth verify endpoint via preHandler check. Refs #495 --- src/middleware/auth-brute-force.ts | 97 +++++++++++++++++++++++++++++ src/modules/auth/auth.controller.ts | 48 ++++++++------ src/modules/auth/auth.routes.ts | 3 +- 3 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 src/middleware/auth-brute-force.ts diff --git a/src/middleware/auth-brute-force.ts b/src/middleware/auth-brute-force.ts new file mode 100644 index 0000000..c37e49b --- /dev/null +++ b/src/middleware/auth-brute-force.ts @@ -0,0 +1,97 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; +import { redis } from "../config/redis.js"; +import { logger } from "../utils/logger.js"; + +const BLOCK_PREFIX = "auth:block:"; +const FAIL_PREFIX = "auth:fail:"; +const MAX_FAILURES = 10; +const INITIAL_BLOCK_SECONDS = 300; // 5 minutes +const MAX_BLOCK_SECONDS = 3600; // 1 hour +const FAILURE_WINDOW_SECONDS = 300; // 5 minutes + +function getIp(request: FastifyRequest): string { + return request.ip; +} + +/** + * Record a failed auth attempt for the given IP. + * Blocks the IP if the failure threshold is exceeded. + */ +export async function recordAuthFailure(request: FastifyRequest): Promise { + const ip = getIp(request); + const key = `${FAIL_PREFIX}${ip}`; + + const count = await redis.incr(key); + if (count === 1) { + await redis.expire(key, FAILURE_WINDOW_SECONDS); + } + + if (count >= MAX_FAILURES) { + const existingTtl = await redis.ttl(`${BLOCK_PREFIX}${ip}`); + if (existingTtl <= 0) { + await redis.setex(`${BLOCK_PREFIX}${ip}`, INITIAL_BLOCK_SECONDS, "1"); + logger.warn({ ip, failures: count }, "IP temporarily blocked for repeated auth failures"); + } + } +} + +/** + * Clear failure count on successful auth. + */ +export async function clearAuthFailures(request: FastifyRequest): Promise { + const ip = getIp(request); + await redis.del(`${FAIL_PREFIX}${ip}`); +} + +/** + * Pre-handler that rejects requests from blocked IPs. + */ +export async function checkIpBlock( + request: FastifyRequest, + reply: FastifyReply +): Promise { + const ip = getIp(request); + const blocked = await redis.get(`${BLOCK_PREFIX}${ip}`); + if (blocked) { + const ttl = await redis.ttl(`${BLOCK_PREFIX}${ip}`); + reply.code(429).header("Retry-After", String(ttl)).send({ + statusCode: 429, + error: "Too Many Requests", + message: `IP temporarily blocked due to repeated auth failures. Retry after ${ttl}s.`, + }); + } +} + +/** + * Admin: list all currently blocked IPs. + */ +export async function listBlockedIps(): Promise> { + const keys: string[] = []; + let cursor = "0"; + do { + const [nextCursor, found] = await redis.scan( + cursor, + "MATCH", + `${BLOCK_PREFIX}*`, + "COUNT", + 100 + ); + cursor = nextCursor; + keys.push(...found); + } while (cursor !== "0"); + + const results: Array<{ ip: string; ttl: number }> = []; + for (const key of keys) { + const ttl = await redis.ttl(key); + results.push({ ip: key.replace(BLOCK_PREFIX, ""), ttl }); + } + return results; +} + +/** + * Admin: clear a specific IP block. + */ +export async function clearIpBlock(ip: string): Promise { + const deleted = await redis.del(`${BLOCK_PREFIX}${ip}`, `${FAIL_PREFIX}${ip}`); + return deleted > 0; +} \ No newline at end of file diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index e49d29b..1506e04 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,6 +1,10 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import { authService } from "./auth.service.js"; import type { ChallengeBody, VerifyBody } from "./auth.types.js"; +import { + recordAuthFailure, + clearAuthFailures, +} from "../../middleware/auth-brute-force.js"; export class AuthController { /** @@ -30,27 +34,33 @@ export class AuthController { ): Promise { const { stellarAddress, signedChallenge } = (request as any).validatedBody; - const authResult = await authService.verifyChallenge( - stellarAddress, - signedChallenge - ); + try { + const authResult = await authService.verifyChallenge( + stellarAddress, + signedChallenge + ); - // Generate JWT - const token = request.server.jwt.sign( - { - sub: authResult.user.id, - stellarAddress: authResult.user.stellarAddress, - }, - { expiresIn: "24h" } - ); + await clearAuthFailures(request); - reply.send({ - success: true, - data: { - token, - user: authResult.user, - }, - }); + const token = request.server.jwt.sign( + { + sub: authResult.user.id, + stellarAddress: authResult.user.stellarAddress, + }, + { expiresIn: "24h" } + ); + + reply.send({ + success: true, + data: { + token, + user: authResult.user, + }, + }); + } catch (err) { + await recordAuthFailure(request); + throw err; + } } } diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index edf6d30..a15e683 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"; import { authController } from "./auth.controller.js"; import { validate } from "../../middleware/validation.js"; import { challengeSchema, verifySchema } from "./auth.types.js"; +import { checkIpBlock } from "../../middleware/auth-brute-force.js"; export async function authRoutes(app: FastifyInstance): Promise { app.post( @@ -26,7 +27,7 @@ export async function authRoutes(app: FastifyInstance): Promise { app.post( "/verify", { - preHandler: [validate({ body: verifySchema })], + preHandler: [checkIpBlock, validate({ body: verifySchema })], schema: { description: "Verify signed challenge and get JWT", tags: ["auth"],