monitor/core/modules/WebServer/middlewares/topLevelMw.ts
2025-04-16 22:30:27 +07:00

109 lines
4.2 KiB
TypeScript

const modulename = 'WebServer:TopLevelMw';
import { txEnv } from '@core/globalData';
import consoleFactory from '@lib/console';
const console = consoleFactory(modulename);
import { Next } from "koa";
import { RawKoaCtx } from '../ctxTypes';
//Token Bucket (Rate Limiter)
const maxTokens = 20;
const tokensPerInterval = 5;
let availableTokens = maxTokens;
let suppressedErrors = 0;
setInterval(() => {
availableTokens = Math.min(availableTokens + tokensPerInterval, maxTokens);
if (suppressedErrors) {
console.warn(`Suppressed ${suppressedErrors} errors to prevent log spam.`);
suppressedErrors = 0;
}
}, 5_000);
const consumePrintToken = () => {
if (availableTokens > 0) {
availableTokens--;
return true;
}
suppressedErrors++;
return false;
}
//Consts
const timeoutLimit = 47 * 1000; //REQ_TIMEOUT_REALLY_REALLY_LONG is 45s
/**
* Middleware responsible for timeout/error/no-output/413
*/
const topLevelMw = async (ctx: RawKoaCtx, next: Next) => {
ctx.set('Server', `txAdmin v${txEnv.txaVersion}`);
let timerId;
const timeout = new Promise((_, reject) => {
timerId = setTimeout(() => {
reject(new Error('route_timed_out'));
}, timeoutLimit);
});
try {
await Promise.race([timeout, next()]);
if (typeof ctx.body == 'undefined' || (typeof ctx.body == 'string' && !ctx.body.length)) {
console.verbose.warn(`Route without output: ${ctx.path}`);
return ctx.body = '[no output from route]';
}
} catch (e) {
const error = e as any; //this has all been previously validated
const prefix = `[txAdmin v${txEnv.txaVersion}]`;
const reqPath = (ctx.path.length > 80) ? `${ctx.path.slice(0, 77)}...` : ctx.path;
const methodName = (error.stack && error.stack[0] && error.stack[0].name) ? error.stack[0].name : 'anonym';
//NOTE: I couldn't force xss on path message, but just in case I'm forcing it here
//but it is overwritten by koa when we set the body to an object, which is fine
ctx.type = 'text/plain';
ctx.set('X-Content-Type-Options', 'nosniff');
//NOTE: not using HTTP logger endpoint anymore, FD3 only
if (error.type === 'entity.too.large') {
const desc = `Entity too large for: ${reqPath}`;
ctx.status = 413;
ctx.body = { error: desc };
if (consumePrintToken()) console.verbose.error(desc, methodName);
} else if (error.type === 'stream.not.readable') {
const desc = `Stream Not Readable: ${reqPath}`;
ctx.status = 422; //"Unprocessable Entity" kinda matches
ctx.body = { error: desc };
if (consumePrintToken()) console.verbose.warn(desc, methodName);
} else if (error.message === 'route_timed_out') {
const desc = `${prefix} Route timed out: ${reqPath}`;
ctx.status = 408;
ctx.body = desc;
if (consumePrintToken()) console.error(desc, methodName);
} else if (error.message === 'Malicious Path' || error.message === 'failed to decode') {
const desc = `${prefix} Malicious Path: ${reqPath}`;
ctx.status = 406;
ctx.body = desc;
if (consumePrintToken()) console.verbose.error(desc, methodName);
} else if (error.message.startsWith('Unexpected token')) {
const desc = `${prefix} Invalid JSON for: ${reqPath}`;
ctx.status = 400;
ctx.body = { error: desc };
if (consumePrintToken()) console.verbose.error(desc, methodName);
} else {
const desc = [
`${prefix} Internal Error.`,
`Route: ${reqPath}`,
`Message: ${error.message}`,
'Make sure your txAdmin is updated.',
].join('\n');
ctx.status = 500;
ctx.body = desc;
if (consumePrintToken()) {
console.error(desc, methodName);
console.verbose.dir(error);
}
}
} finally {
//Cannot forget about this or the ctx will only be released from memory after the timeout,
//making it easier to crash the server in a DDoS attack
clearTimeout(timerId);
}
}
export default topLevelMw;