Skip to content

Commit

Permalink
feat(core): added ddos mitigation
Browse files Browse the repository at this point in the history
  • Loading branch information
tabarra committed Oct 24, 2023
1 parent 6d0a446 commit 83ef90e
Show file tree
Hide file tree
Showing 11 changed files with 165 additions and 33 deletions.
13 changes: 8 additions & 5 deletions core/components/WebServer/authLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { convars } from '@core/globalData';
import consoleFactory from '@extras/console';
import TxAdmin from "@core/txAdmin";
import { SessToolsType } from "./middlewares/sessionMws";
import { isIpAddressLocal } from "@extras/isIpAddressLocal";
const console = consoleFactory(modulename);


Expand Down Expand Up @@ -132,11 +133,12 @@ const validSessAuthSchema = z.discriminatedUnion('type', [
export const checkRequestAuth = (
txAdmin: TxAdmin,
reqHeader: { [key: string]: unknown },
reqIP: string,
reqIp: string,
isLocalRequest: boolean,
sessTools: SessToolsType,
) => {
return typeof reqHeader['x-txadmin-token'] === 'string'
? nuiAuthLogic(txAdmin, reqIP, reqHeader)
? nuiAuthLogic(txAdmin, reqIp, isLocalRequest, reqHeader)
: normalAuthLogic(txAdmin, sessTools);
}

Expand Down Expand Up @@ -202,17 +204,18 @@ export const normalAuthLogic = (
*/
export const nuiAuthLogic = (
txAdmin: TxAdmin,
reqIP: string,
reqIp: string,
isLocalRequest: boolean,
reqHeader: { [key: string]: unknown }
): AuthLogicReturnType => {
try {
// Check sus IPs
if (
!convars.loopbackInterfaces.includes(reqIP)
!isLocalRequest
&& !convars.isZapHosting
&& !txAdmin.webServer.config.disableNuiSourceCheck
) {
console.verbose.warn(`NUI Auth Failed: reqIP "${reqIP}" not in ${JSON.stringify(convars.loopbackInterfaces)}.`);
console.verbose.warn(`NUI Auth Failed: reqIp "${reqIp}" not a local or allowed address.`);
return failResp('Invalid Request: source');
}

Expand Down
1 change: 1 addition & 0 deletions core/components/WebServer/getReactIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default async function getReactIndex(ctx: CtxWithVars | AuthedCtx) {
ctx.txAdmin,
ctx.request.headers,
ctx.ip,
ctx.txVars.isLocalRequest,
ctx.sessTools
);
let authedAdmin: AuthedAdminType | false = false;
Expand Down
24 changes: 6 additions & 18 deletions core/components/WebServer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import topLevelMw from './middlewares/topLevelMw';
import ctxVarsMw from './middlewares/ctxVarsMw';
import ctxUtilsMw from './middlewares/ctxUtilsMw';
import { SessionMemoryStorage, koaSessMw, socketioSessMw } from './middlewares/sessionMws';
import checkRateLimit from './middlewares/globalRateLimiterMw';
const console = consoleFactory(modulename);
const nanoid = customAlphabet(dict51, 32);

Expand All @@ -36,7 +37,6 @@ export type WebServerConfigType = {
export default class WebServer {
readonly #txAdmin: TxAdmin;
public isListening = false;
private httpRequestsCounter = 0;
private sessionCookieName: string;
public luaComToken: string;
//setupKoa
Expand All @@ -52,18 +52,7 @@ export default class WebServer {
constructor(txAdmin: TxAdmin, public config: WebServerConfigType) {
this.#txAdmin = txAdmin;

//Counting requests per minute
setInterval(() => {
if (this.httpRequestsCounter > 10_000) {
const numberFormatter = new Intl.NumberFormat('en-US');
console.majorMultilineError([
'txAdmin might be under a DDoS attack!',
`We detected ${numberFormatter.format(this.httpRequestsCounter)} HTTP requests in the last minute.`,
'Make sure you have a proper firewall setup and/or a reverse proxy with rate limiting.',
]);
}
this.httpRequestsCounter = 0;
}, 60_000);


//Generate cookie key & luaComToken
const pathHash = crypto.createHash('shake256', { outputLength: 6 })
Expand Down Expand Up @@ -157,17 +146,16 @@ export default class WebServer {

/**
* Handler for all HTTP requests
* Note: i gave up on typing these
*/
httpCallbackHandler(req: Request, res: Response) {
httpCallbackHandler(req: any, res: any) {
//Calls the appropriate callback
try {
// console.debug(`HTTP ${req.method} ${req.url}`);
this.httpRequestsCounter++;
if (!checkRateLimit(req?.socket?.remoteAddress)) return;
if (req.url.startsWith('/socket.io')) {
//@ts-ignore
this.io.engine.handleRequest(req, res);
(this.io.engine as any).handleRequest(req, res);
} else {
//@ts-ignore
this.koaCallback(req, res);
}
} catch (error) { }
Expand Down
3 changes: 3 additions & 0 deletions core/components/WebServer/middlewares/authMws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const console = consoleFactory(modulename);
/**
* Intercom auth middleware
* This does not set ctx.admin and does not use session/cookies whatsoever.
* FIXME: add isLocalAddress check?
*/
export const intercomAuthMw = async (ctx: InitializedCtx, next: Function) => {
if (
Expand All @@ -30,6 +31,7 @@ export const webAuthMw = async (ctx: InitializedCtx, next: Function) => {
ctx.txAdmin,
ctx.request.headers,
ctx.ip,
ctx.txVars.isLocalRequest,
ctx.sessTools
);
if (!authResult.success) {
Expand Down Expand Up @@ -60,6 +62,7 @@ export const apiAuthMw = async (ctx: InitializedCtx, next: Function) => {
ctx.txAdmin,
ctx.request.headers,
ctx.ip,
ctx.txVars.isLocalRequest,
ctx.sessTools
);
if (!authResult.success) {
Expand Down
7 changes: 5 additions & 2 deletions core/components/WebServer/middlewares/ctxVarsMw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import consts from '@extras/consts';
const console = consoleFactory(modulename);
import { Next } from "koa";
import { CtxWithSession } from '../ctxTypes';
import { isIpAddressLocal } from '@extras/isIpAddressLocal';

//The custom tx-related vars set to the ctx
export type CtxTxVars = {
isWebInterface: boolean;
realIP: string;
isLocalRequest: boolean;
hostType: 'localhost' | 'ip' | 'other';
};

Expand All @@ -24,12 +26,13 @@ const ctxVarsMw = (txAdmin: TxAdmin) => {
const txVars: CtxTxVars = {
isWebInterface: typeof ctx.headers['x-txadmin-token'] !== 'string',
realIP: ctx.ip,
isLocalRequest: isIpAddressLocal(ctx.ip),
hostType: 'other',
};

//Setting up the user's host type
const host = ctx.request.host ?? 'none';
if (host.startsWith('localhost') || host.startsWith('127.0.0.1')) {
if (host.startsWith('localhost') || host.startsWith('127.')) {
txVars.hostType = 'localhost';
} else if (/^\d+\.\d+\.\d+\.\d+(?::\d+)?$/.test(host)) {
txVars.hostType = 'ip';
Expand All @@ -41,7 +44,7 @@ const ctxVarsMw = (txAdmin: TxAdmin) => {
typeof ctx.headers['x-txadmin-identifiers'] === 'string'
&& typeof ctx.headers['x-txadmin-token'] === 'string'
&& ctx.headers['x-txadmin-token'] === txAdmin.webServer.luaComToken
&& convars.loopbackInterfaces.includes(ctx.ip)
&& txVars.isLocalRequest
) {
const ipIdentifier = ctx.headers['x-txadmin-identifiers']
.split(',')
Expand Down
98 changes: 98 additions & 0 deletions core/components/WebServer/middlewares/globalRateLimiterMw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const modulename = 'WebServer:RateLimiter';
import consoleFactory from '@extras/console';
import { isIpAddressLocal } from '@extras/isIpAddressLocal';
const console = consoleFactory(modulename);


/*
Expected requests per user per minute:
50 usual
800 live console with ingame + 2 web pages
2300 very very very heavy behavior
*/


//Config
const DDOS_THRESHOLD = 20_000;
const MAX_RPM_DEFAULT = 5000;
const MAX_RPM_UNDER_ATTACK = 2500;
const DDOS_COOLDOWN_MINUTES = 15;

//Vars
const bannedIps = new Set<string>();
const reqsPerIp = new Map<string, number>();
let httpRequestsCounter = 0;
let bansPendingWarn: string[] = [];
let minutesSinceLastAttack = Number.MAX_SAFE_INTEGER;


/**
* Process the counts and declares a DDoS or not, as well as warns of new banned IPs.
* Note if the requests are under the DDOS_THRESHOLD, banned ips will be immediately unbanned, so
* in this case the rate limiter will only serve to limit instead of banning these IPs.
*/
setInterval(() => {
if (httpRequestsCounter > DDOS_THRESHOLD) {
minutesSinceLastAttack = 0;
const numberFormatter = new Intl.NumberFormat('en-US');
console.majorMultilineError([
'You might be under a DDoS attack!',
`txAdmin got ${numberFormatter.format(httpRequestsCounter)} HTTP requests in the last minute.`,
`The attacker IP addresses have been blocked until ${DDOS_COOLDOWN_MINUTES} mins after the attack stops.`,
'Make sure you have a proper firewall setup and/or a reverse proxy with rate limiting.',
'You can join https://discord.gg/txAdmin for support.'
]);
} else {
minutesSinceLastAttack++;
if (minutesSinceLastAttack > DDOS_COOLDOWN_MINUTES) {
bannedIps.clear();
}
}
httpRequestsCounter = 0;
reqsPerIp.clear();
if (bansPendingWarn.length) {
console.warn('IPs blocked:', bansPendingWarn.join(', '));
bansPendingWarn = [];
}
}, 60_000);


/**
* Checks if an IP is allowed to make a request based on the rate limit per IP.
* The rate limit ignores local IPs.
* The limits are calculated based on requests per minute, which varies if under attack or not.
* All bans are cleared 15 minutes after the attack stops.
*/
const checkRateLimit = (remoteAddress: string) => {
// Sanity check on the ip
if (typeof remoteAddress !== 'string' || !remoteAddress.length) return false;

// Counting requests per minute
httpRequestsCounter++;

// Whitelist all local addresses
if (isIpAddressLocal(remoteAddress)) return true;

// Checking if the IP is banned
if (bannedIps.has(remoteAddress)) return false;

// Check rate and count request
const reqsCount = reqsPerIp.get(remoteAddress);
if (reqsCount !== undefined) {
const limit = minutesSinceLastAttack < DDOS_COOLDOWN_MINUTES
? MAX_RPM_UNDER_ATTACK
: MAX_RPM_DEFAULT;
if (reqsCount > limit) {
bannedIps.add(remoteAddress);
bansPendingWarn.push(remoteAddress);
return false;
}
reqsPerIp.set(remoteAddress, reqsCount + 1);
} else {
reqsPerIp.set(remoteAddress, 1);
}
return true;
}

export default checkRateLimit;

4 changes: 4 additions & 0 deletions core/components/WebServer/middlewares/sessionMws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export class SessionMemoryStorage {
destroy(key: string) {
return this.sessions.delete(key);
}

get size() {
return this.sessions.size;
}
}


Expand Down
7 changes: 5 additions & 2 deletions core/components/WebServer/webSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import playerlist from './wsRooms/playerlist';
import liveconsole from './wsRooms/liveconsole';
import serverlog from './wsRooms/serverlog';
import TxAdmin from '@core/txAdmin';
import { AuthedAdminType, checkRequestAuth, normalAuthLogic, nuiAuthLogic } from './authLogic';
import { AuthedAdminType, checkRequestAuth } from './authLogic';
import { SocketWithSession } from './ctxTypes';
import { isIpAddressLocal } from '@extras/isIpAddressLocal';
const console = consoleFactory(modulename);

//Types
Expand Down Expand Up @@ -70,10 +71,12 @@ export default class WebSocket {
handleConnection(socket: SocketWithSession) {
try {
//Checking for session auth
const reqIp = getIP(socket);
const authResult = checkRequestAuth(
this.#txAdmin,
socket.request.headers,
getIP(socket),
reqIp,
isIpAddressLocal(reqIp),
socket.sessTools
);
if (!authResult.success) {
Expand Down
3 changes: 2 additions & 1 deletion core/extras/banner.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import got from '@core/extras/got.js';
import getOsDistro from '@core/extras/getOsDistro.js';
import { convars, txEnv } from '@core/globalData';
import consoleFactory from '@extras/console';
import { addLocalIpAddress } from './isIpAddressLocal';
const console = consoleFactory();


Expand Down Expand Up @@ -126,7 +127,7 @@ export const printBanner = async () => {
];
if (ipRes.value) {
addrs.push(ipRes.value);
convars.loopbackInterfaces.push(ipRes.value);
addLocalIpAddress(ipRes.value);
}
} else {
addrs = [convars.forceInterface];
Expand Down
29 changes: 29 additions & 0 deletions core/extras/isIpAddressLocal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const modulename = 'IpChecker';
import consoleFactory from '@extras/console';
const console = consoleFactory(modulename);

const extendedAllowedLanIps: string[] = [];


/**
* Return if the IP Address is a loopback interface, LAN, detected WAN or any other
* IP that is registered by the user via the forceInterface convar or config file.
*
* This is used to secure the webpipe auth and the rate limiter.
*/
export const isIpAddressLocal = (ipAddress: string): boolean => {
return (
/^(127\.|192\.168\.|10\.|::1|fd00::)/.test(ipAddress)
|| extendedAllowedLanIps.includes(ipAddress)
)
}


/**
* Used to register a new LAN interface.
* Added automatically from forceInterface, zapHosting config and banner.js after detecting the WAN address.
*/
export const addLocalIpAddress = (ipAddress: string): void => {
console.verbose.debug(`Adding local IP address: ${ipAddress}`);
extendedAllowedLanIps.push(ipAddress);
}
9 changes: 4 additions & 5 deletions core/globalData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';
import slash from 'slash';

import consoleFactory, { setConsoleEnvData } from '@extras/console';
import { addLocalIpAddress } from '@extras/isIpAddressLocal';
const console = consoleFactory();


Expand Down Expand Up @@ -156,7 +157,6 @@ let txAdminPort: number;
let loginPageLogo: false | string;
let defaultMasterAccount: false | { name: string, password_hash: string };
let deployerDefaults: false | Record<string, string>;
const loopbackInterfaces = ['::1', '127.0.0.1', '127.0.1.1'];
const isPterodactyl = !isWindows && process.env?.TXADMIN_ENABLE === '1';
if (fs.existsSync(zapCfgFile)) {
isZapHosting = !isPterodactyl;
Expand Down Expand Up @@ -189,8 +189,6 @@ if (fs.existsSync(zapCfgFile)) {
};
}

loopbackInterfaces.push(forceInterface);

if (!isDevMode) fs.unlinkSync(zapCfgFile);
} catch (error) {
console.error(`Failed to load with ZAP-Hosting configuration error: ${(error as Error).message}`);
Expand Down Expand Up @@ -219,9 +217,11 @@ if (fs.existsSync(zapCfgFile)) {
process.exit(111);
}
forceInterface = txAdminInterfaceConvar;
loopbackInterfaces.push(forceInterface);
}
}
if(forceInterface){
addLocalIpAddress(forceInterface);
}
if (verboseConvar) {
console.dir({ isPterodactyl, isZapHosting, forceInterface, forceFXServerPort, txAdminPort, loginPageLogo, deployerDefaults });
}
Expand Down Expand Up @@ -261,5 +261,4 @@ export const convars = Object.freeze({
loginPageLogo,
defaultMasterAccount,
deployerDefaults,
loopbackInterfaces,
});

0 comments on commit 83ef90e

Please sign in to comment.