From 4ca9042bcd20787e178e52960eee90dc83a19aef Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sat, 5 Jul 2025 16:09:36 -0700 Subject: [PATCH] feat: add cache to block IPs from brute force attempts --- server/src/cache/blockedIps.ts | 7 +++++ server/src/hooks/ips.ts | 36 ++++++++++++++++++++++++++ server/src/index.ts | 2 ++ server/src/modules/getIpFromRequest.ts | 25 ++++++++++++++++++ server/src/routes/announcement.ts | 10 ++++++- 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 server/src/cache/blockedIps.ts create mode 100644 server/src/hooks/ips.ts create mode 100644 server/src/modules/getIpFromRequest.ts diff --git a/server/src/cache/blockedIps.ts b/server/src/cache/blockedIps.ts new file mode 100644 index 0000000..5277359 --- /dev/null +++ b/server/src/cache/blockedIps.ts @@ -0,0 +1,7 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +export const blockedIps: Array<{ ip: string; ttl: Date }> = []; diff --git a/server/src/hooks/ips.ts b/server/src/hooks/ips.ts new file mode 100644 index 0000000..d892adc --- /dev/null +++ b/server/src/hooks/ips.ts @@ -0,0 +1,36 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { blockedIps } from "../cache/blockedIps.js"; +import { getIpFromRequest } from "../modules/getIpFromRequest.js"; +import type { onRequestHookHandler } from "fastify"; + +/** + * Ensures that form submissions only come from our web application. + * @param request - The request payload from the server. + * @param response - The reply handler from Fastify. + * @returns A Fastify reply if the request is invalid, otherwise undefined. + */ +// eslint-disable-next-line @typescript-eslint/no-misused-promises -- For reasons I cannot comprehend, Fastify seems to require us to return a request? +export const ipHook: onRequestHookHandler = async(request, response) => { + const ip = getIpFromRequest(request); + const ipRecord = blockedIps.find( + (record) => { + return record.ip === ip && record.ttl > new Date(); + }, + ); + if (ipRecord && ipRecord.ttl > new Date()) { + return await response. + status(403). + send({ + error: `Your IP address (${ipRecord.ip}) has been blocked until ${ipRecord.ttl.toISOString()}, to protect our API against brute-force attacks.`, + }); + } + if (ipRecord && ipRecord.ttl <= new Date()) { + blockedIps.splice(blockedIps.indexOf(ipRecord), 1); + } + return undefined; +}; diff --git a/server/src/index.ts b/server/src/index.ts index 0eee3a0..f85d544 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,7 @@ import fastify from "fastify"; import { corsHook } from "./hooks/cors.js"; +import { ipHook } from "./hooks/ips.js"; import { announcementRoutes } from "./routes/announcement.js"; import { baseRoutes } from "./routes/base.js"; import { logger } from "./utils/logger.js"; @@ -15,6 +16,7 @@ const server = fastify({ }); server.addHook("preHandler", corsHook); +server.addHook("preHandler", ipHook); server.register(baseRoutes); server.register(announcementRoutes); diff --git a/server/src/modules/getIpFromRequest.ts b/server/src/modules/getIpFromRequest.ts new file mode 100644 index 0000000..5050dfe --- /dev/null +++ b/server/src/modules/getIpFromRequest.ts @@ -0,0 +1,25 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { FastifyRequest } from "fastify"; + +/** + * Parses an IP address from a request, first looking for the + * Cloudflare headers, then falling back to the request IP. + * @param request - The Fastify request object. + * @returns The IP address as a string. + */ +export const getIpFromRequest = (request: FastifyRequest): string => { + const header + = request.headers["X-Forwarded-For"] ?? request.headers["Cf-Connecting-IP"]; + if (typeof header === "string") { + return header; + } + if (Array.isArray(header)) { + return header[0] ?? header.join(", "); + } + return request.ip; +}; diff --git a/server/src/routes/announcement.ts b/server/src/routes/announcement.ts index 1b20680..ac2e98e 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -4,11 +4,15 @@ * @author Naomi Carrigan */ +import { blockedIps } from "../cache/blockedIps.js"; import { database } from "../db/database.js"; import { announceOnDiscord } from "../modules/announceOnDiscord.js"; import { announceOnForum } from "../modules/announceOnForum.js"; +import { getIpFromRequest } from "../modules/getIpFromRequest.js"; import type { FastifyPluginAsync } from "fastify"; +const oneDay = 24 * 60 * 60 * 1000; + /** * Mounts the entry routes for the application. These routes * should not require CORS, as they are used by external services @@ -34,10 +38,14 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { async(request, reply) => { const token = request.headers.authorization; if (token === undefined || token !== process.env.ANNOUNCEMENT_TOKEN) { + blockedIps.push({ + ip: getIpFromRequest(request), + ttl: new Date(Date.now() + oneDay), + }); return await reply.status(401).send({ error: // eslint-disable-next-line stylistic/max-len -- Big boi string. - "This endpoint requires a special auth token. If you believe you should have access, please contact Naomi.", + "This endpoint requires a special auth token. If you believe you should have access, please contact Naomi. To protect our services, your IP has been blocked from all routes for 24 hours.", }); }