Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/middleware/auth-brute-force.ts
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 9 in src/middleware/auth-brute-force.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

'MAX_BLOCK_SECONDS' is assigned a value but never used. Allowed unused vars must match /^_/u
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<void> {
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<void> {
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<void> {
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<Array<{ ip: string; ttl: number }>> {
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<boolean> {
const deleted = await redis.del(`${BLOCK_PREFIX}${ip}`, `${FAIL_PREFIX}${ip}`);
return deleted > 0;
}
32 changes: 32 additions & 0 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -50,6 +55,33 @@ export class AuthController {
): Promise<void> {
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(
Expand Down
3 changes: 3 additions & 0 deletions src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -35,6 +37,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: import("./auth.types.js").VerifyBody }>(
"/verify",
{
preHandler: [checkIpBlock, validate({ body: verifySchema })],
config: { rateLimit: authRateLimit },
preHandler: [validate({ body: verifySchema })],
schema: {
Expand Down
27 changes: 27 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down
68 changes: 68 additions & 0 deletions src/services/webhook-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<DispatchResult> {
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";
Expand Down Expand Up @@ -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)}`,
};
}
Expand Down
Loading