109 lines
4.2 KiB
TypeScript
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;
|