generated from nhcarrigan/template
feat: add cache to block IPs from brute force attempts
This commit is contained in:
7
server/src/cache/blockedIps.ts
vendored
Normal file
7
server/src/cache/blockedIps.ts
vendored
Normal 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
36
server/src/hooks/ips.ts
Normal 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;
|
||||||
|
};
|
@ -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);
|
||||||
|
25
server/src/modules/getIpFromRequest.ts
Normal file
25
server/src/modules/getIpFromRequest.ts
Normal 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;
|
||||||
|
};
|
@ -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.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user