feat: add cache to block IPs from brute force attempts

This commit is contained in:
2025-07-05 16:09:36 -07:00
parent 42bad8c6c8
commit 4ca9042bcd
5 changed files with 79 additions and 1 deletions

7
server/src/cache/blockedIps.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export const blockedIps: Array<{ ip: string; ttl: Date }> = [];

36
server/src/hooks/ips.ts Normal file
View File

@ -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;
};

View File

@ -6,6 +6,7 @@
import fastify from "fastify"; import fastify from "fastify";
import { corsHook } from "./hooks/cors.js"; import { corsHook } from "./hooks/cors.js";
import { ipHook } from "./hooks/ips.js";
import { announcementRoutes } from "./routes/announcement.js"; import { announcementRoutes } from "./routes/announcement.js";
import { baseRoutes } from "./routes/base.js"; import { baseRoutes } from "./routes/base.js";
import { logger } from "./utils/logger.js"; import { logger } from "./utils/logger.js";
@ -15,6 +16,7 @@ const server = fastify({
}); });
server.addHook("preHandler", corsHook); server.addHook("preHandler", corsHook);
server.addHook("preHandler", ipHook);
server.register(baseRoutes); server.register(baseRoutes);
server.register(announcementRoutes); server.register(announcementRoutes);

View File

@ -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;
};

View File

@ -4,11 +4,15 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { blockedIps } from "../cache/blockedIps.js";
import { database } from "../db/database.js"; import { database } from "../db/database.js";
import { announceOnDiscord } from "../modules/announceOnDiscord.js"; import { announceOnDiscord } from "../modules/announceOnDiscord.js";
import { announceOnForum } from "../modules/announceOnForum.js"; import { announceOnForum } from "../modules/announceOnForum.js";
import { getIpFromRequest } from "../modules/getIpFromRequest.js";
import type { FastifyPluginAsync } from "fastify"; import type { FastifyPluginAsync } from "fastify";
const oneDay = 24 * 60 * 60 * 1000;
/** /**
* Mounts the entry routes for the application. These routes * Mounts the entry routes for the application. These routes
* should not require CORS, as they are used by external services * should not require CORS, as they are used by external services
@ -34,10 +38,14 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => {
async(request, reply) => { async(request, reply) => {
const token = request.headers.authorization; const token = request.headers.authorization;
if (token === undefined || token !== process.env.ANNOUNCEMENT_TOKEN) { 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({ return await reply.status(401).send({
error: error:
// eslint-disable-next-line stylistic/max-len -- Big boi string. // 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.",
}); });
} }