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 126a8a2..1f92968 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,6 +1,11 @@ import crypto from "node:crypto"; 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"; import { issueRefreshToken, rotateRefreshToken, @@ -50,6 +55,33 @@ export class AuthController { ): Promise { const { stellarAddress, challengeId, signedChallenge } = request.body; + try { + const authResult = await authService.verifyChallenge( + stellarAddress, + signedChallenge + ); + + await clearAuthFailures(request); + + 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; + } let authResult; try { authResult = await authService.verifyChallenge( diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index e0f751e..34bc6a1 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -1,6 +1,8 @@ import type { FastifyInstance, FastifySchema } 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"; import { authGuard } from "../../middleware/auth.js"; import { authRateLimit } from "../../middleware/rate-limit.js"; import { @@ -35,6 +37,7 @@ export async function authRoutes(app: FastifyInstance): Promise { app.post<{ Body: import("./auth.types.js").VerifyBody }>( "/verify", { + preHandler: [checkIpBlock, validate({ body: verifySchema })], config: { rateLimit: authRateLimit }, preHandler: [validate({ body: verifySchema })], schema: { diff --git a/src/server.ts b/src/server.ts index 1d33534..627e37b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,7 @@ import { registerMetricsHook } from "./metrics/fastify-hook.js"; import { registerErrorHandler } from "./middleware/error-handler.js"; import { registerRequestTimeout } from "./middleware/timeout.js"; import { rateLimitOptions } from "./middleware/rate-limit.js"; +import { listBlockedIps, clearIpBlock } from "./middleware/auth-brute-force.js"; import { authGuard } from "./middleware/auth.js"; import { db } from "./config/database.js"; import { redis } from "./config/redis.js"; @@ -222,6 +223,18 @@ 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. await app.register(multipart, { limits: { fileSize: config.MULTIPART_BODY_LIMIT_BYTES, @@ -382,6 +395,20 @@ async function buildApp() { return reply.send(await registry.metrics()); }); + // ─── 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 registerVersionedRoutes(app); diff --git a/src/services/webhook-dispatcher.ts b/src/services/webhook-dispatcher.ts index 6e9911c..878e473 100644 --- a/src/services/webhook-dispatcher.ts +++ b/src/services/webhook-dispatcher.ts @@ -1,3 +1,52 @@ +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" import crypto from "node:crypto"; import { eq, and, lte, isNull } from "drizzle-orm"; import { db } from "../config/database.js"; @@ -89,6 +138,25 @@ async function sendWebhook( 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); + } +} error: `Client error (${response.status}): ${responseBody.substring(0, 200)}`, }; }