Various bug fixes and small additions

IMPORTANT:
This commit contains new config options.
Make sure your local config file is up to date with config.example.toml to avoid issues.

CHANGELOG:
- Fix crash exploit caused by abnormally long WebSocket messages
- Added config option for limiting the maximum amount of active connections from the same IP
- Made per-IP turn limiter configurable
- Fixed an issue where vote updates were not sent to clients in some cases
- Added warning about using qmpSockDir on Windows hosts
- Updated dependencies
- Removed unused code/imports
This commit is contained in:
MDMCK10
2023-09-12 00:25:57 +02:00
parent d80d2c8ed9
commit 43fddbc521
10 changed files with 85 additions and 43 deletions

View File

@@ -10,6 +10,8 @@ proxyAllowedIps = ["127.0.0.1"]
origin = false
# Origins to accept connections from.
originAllowedDomains = ["computernewb.com"]
# Maximum amount of active connections allowed from the same IP.
maxConnections = 3
[vm]
qemuArgs = "qemu-system-x86_64"
@@ -32,6 +34,8 @@ moderatorEnabled = true
usernameblacklist = []
maxChatLength = 100
maxChatHistoryLength = 10
# Limit the amount of users allowed in the turn queue at the same time from the same IP
turnlimit = {enabled = true, maximum = 1}
# Temporarily mute a user if they send more than x messages in n seconds
automute = {enabled = true, seconds = 5, messages = 5}
# How long a temporary mute lasts, in seconds

View File

@@ -10,19 +10,19 @@
"author": "Elijah R",
"license": "GPL-3.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node": "^20.6.0",
"@types/sharp": "^0.31.1",
"@types/ws": "^8.5.4",
"@types/ws": "^8.5.5",
"async-mutex": "^0.4.0",
"canvas": "^2.11.0",
"execa": "^6.1.0",
"canvas": "^2.11.2",
"execa": "^8.0.1",
"fs": "^0.0.1-security",
"jimp": "^0.16.2",
"jimp": "^0.22.10",
"mnemonist": "^0.39.5",
"rfb2": "github:elijahr2411/node-rfb2",
"toml": "^3.0.0",
"typescript": "^4.9.5",
"ws": "^8.12.0"
"typescript": "^5.2.2",
"ws": "^8.14.1"
},
"type": "module"
}

View File

@@ -6,6 +6,7 @@ export default interface IConfig {
proxyAllowedIps : string[];
origin : boolean;
originAllowedDomains : string[];
maxConnections: number;
};
vm : {
qemuArgs : string;
@@ -24,6 +25,10 @@ export default interface IConfig {
usernameblacklist : string[];
maxChatLength : number;
maxChatHistoryLength : number;
turnlimit : {
enabled: boolean,
maximum: number;
};
automute : {
enabled: boolean;
seconds: number;

View File

@@ -1,5 +1,5 @@
export class IPData {
tempMuteExpireTimeout? : NodeJS.Timer;
tempMuteExpireTimeout? : NodeJS.Timeout;
muted: Boolean;
vote: boolean | null;
address: string;

View File

@@ -1,8 +1,7 @@
import { EventEmitter } from "events";
import IConfig from "./IConfig.js";
import * as rfb from 'rfb2';
import * as fs from 'fs';
import { execa, ExecaChildProcess, execaCommand } from "execa";
import { ExecaChildProcess, execaCommand } from "execa";
import QMPClient from "./QMPClient.js";
import BatchRects from "./RectBatcher.js";
import { createCanvas, Canvas, CanvasRenderingContext2D, createImageData } from "canvas";
@@ -25,13 +24,13 @@ export default class QEMUVM extends VM {
processRestartErrorLevel : number;
expectedExit : boolean;
vncOpen : boolean;
vncUpdateInterval? : NodeJS.Timer;
vncUpdateInterval? : NodeJS.Timeout;
rects : {height:number,width:number,x:number,y:number,data:Buffer}[];
rectMutex : Mutex;
vncReconnectTimeout? : NodeJS.Timer;
qmpReconnectTimeout? : NodeJS.Timer;
qemuRestartTimeout? : NodeJS.Timer;
vncReconnectTimeout? : NodeJS.Timeout;
qmpReconnectTimeout? : NodeJS.Timeout;
qemuRestartTimeout? : NodeJS.Timeout;
constructor(Config : IConfig) {
super();

View File

@@ -5,7 +5,7 @@ export default class RateLimiter extends EventEmitter {
private limit : number;
private interval : number;
private requestCount : number;
private limiter? : NodeJS.Timer;
private limiter? : NodeJS.Timeout;
private limiterSet : boolean;
constructor(limit : number, interval : number) {
super();

View File

@@ -7,9 +7,9 @@ import RateLimiter from './RateLimiter.js';
import { execaCommand } from 'execa';
export class User {
socket : WebSocket;
nopSendInterval : NodeJS.Timer;
msgRecieveInterval : NodeJS.Timer;
nopRecieveTimeout? : NodeJS.Timer;
nopSendInterval : NodeJS.Timeout;
msgRecieveInterval : NodeJS.Timeout;
nopRecieveTimeout? : NodeJS.Timeout;
username? : string;
connectedToNode : boolean;
viewMode : number;

View File

@@ -11,9 +11,9 @@ import Queue from 'mnemonist/queue.js';
import { createHash } from 'crypto';
import { isIP } from 'net';
import QEMUVM from './QEMUVM.js';
import { Canvas, createCanvas, CanvasRenderingContext2D } from 'canvas';
import { Canvas, createCanvas } from 'canvas';
import { IPData } from './IPData.js';
import { read, readFileSync } from 'fs';
import { readFileSync } from 'fs';
import log from './log.js';
import VM from './VM.js';
import { fileURLToPath } from 'url';
@@ -32,19 +32,17 @@ export default class WSServer {
// Time remaining on the current turn
private TurnTime : number;
// Interval to keep track of the current turn time
private TurnInterval? : NodeJS.Timer;
// Is the turn interval running?
private TurnIntervalRunning : boolean;
private TurnInterval? : NodeJS.Timeout;
// If a reset vote is in progress
private voteInProgress : boolean;
// Interval to keep track of vote resets
private voteInterval? : NodeJS.Timer;
private voteInterval? : NodeJS.Timeout;
// How much time is left on the vote
private voteTime : number;
// How much time until another reset vote can be cast
private voteCooldown : number;
// Interval to keep track
private voteCooldownInterval? : NodeJS.Timer;
private voteCooldownInterval? : NodeJS.Timeout;
// Completely disable turns
private turnsAllowed : boolean;
// Hide the screen
@@ -61,7 +59,6 @@ export default class WSServer {
this.ChatHistory = new CircularBuffer<{user:string,msg:string}>(Array, this.Config.collabvm.maxChatHistoryLength);
this.TurnQueue = new Queue<User>();
this.TurnTime = 0;
this.TurnIntervalRunning = false;
this.clients = [];
this.ips = [];
this.voteInProgress = false;
@@ -140,7 +137,7 @@ export default class WSServer {
// Get the first IP from the X-Forwarded-For variable
_ip = req.headers["x-forwarded-for"]?.toString().replace(/\ /g, "").split(",")[0];
} catch {
// If we can't get the ip, kill the connection
// If we can't get the IP, kill the connection
killConnection();
return;
}
@@ -149,7 +146,7 @@ export default class WSServer {
killConnection();
return;
}
// Make sure the ip is valid. If not, kill the connection.
// Make sure the IP is valid. If not, kill the connection.
if (!isIP(_ip)) {
killConnection();
return;
@@ -157,11 +154,8 @@ export default class WSServer {
//@ts-ignore
req.proxiedIP = _ip;
}
this.socket.handleUpgrade(req, socket, head, (ws) => this.socket.emit('connection', ws, req));
}
private onConnection(ws : WebSocket, req : http.IncomingMessage) {
var ip: string;
let ip: string;
if (this.Config.http.proxying) {
//@ts-ignore
if (!req.proxiedIP) return;
@@ -172,23 +166,45 @@ export default class WSServer {
ip = req.socket.remoteAddress;
}
var _ipdata = this.ips.filter(data => data.address == ip);
//@ts-ignore
req.IP = ip;
// Get the amount of active connections coming from the requesting IP.
let connections = this.clients.filter(client => client.IP.address == ip);
// If it exceeds the limit set in the config, reject the connection with a 429.
if(connections.length + 1 > this.Config.http.maxConnections) {
socket.write("HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests");
socket.destroy();
}
this.socket.handleUpgrade(req, socket, head, (ws: WebSocket) => this.socket.emit('connection', ws, req));
}
private onConnection(ws : WebSocket, req: http.IncomingMessage) {
//@ts-ignore
var _ipdata = this.ips.filter(data => data.address == req.IP);
var ipdata;
if(_ipdata.length > 0) {
ipdata = _ipdata[0];
}else{
ipdata = new IPData(ip);
//@ts-ignore
ipdata = new IPData(req.IP);
this.ips.push(ipdata);
}
var user = new User(ws, ipdata, this.Config);
this.clients.push(user);
ws.on('error', (e) => {
//@ts-ignore
log("ERROR", `${e} (caused by connection ${req.IP})`);
ws.close();
});
ws.on('close', () => this.connectionClosed(user));
ws.on('message', (e) => {
var msg;
try {msg = e.toString()}
catch {
// Fuck the user off if they send a non-string message
// Close the user's connection if they send a non-string message
user.closeConnection();
return;
}
@@ -199,7 +215,10 @@ export default class WSServer {
};
private connectionClosed(user : User) {
if(user.IP.vote != null) user.IP.vote = null;
if(user.IP.vote != null) {
user.IP.vote = null;
this.sendVoteUpdate();
};
if (this.indefiniteTurn === user) this.indefiniteTurn = null;
this.clients.splice(this.clients.indexOf(user), 1);
log("INFO", `Disconnect From ${user.IP.address}${user.username ? ` with username ${user.username}` : ""}`);
@@ -322,13 +341,17 @@ export default class WSServer {
}
if (takingTurn) {
var currentQueue = this.TurnQueue.toArray();
// If the user is already in the queue, fuck them off
// If the user is already in the turn queue, ignore the turn request.
if (currentQueue.indexOf(client) !== -1) return;
// If they're muted, also fuck them off.
// If they're muted, also ignore the turn request.
// Send them the turn queue to prevent client glitches
if (client.IP.muted) return;
// Only allow one active turn per IP address
if(currentQueue.find(user => user.IP.address == client.IP.address)) return;
if(this.Config.collabvm.turnlimit.enabled) {
// Get the amount of users in the turn queue with the same IP as the user requesting a turn.
let turns = currentQueue.filter(user => user.IP.address == client.IP.address);
// If it exceeds the limit set in the config, ignore the turn request.
if(turns.length + 1 > this.Config.collabvm.turnlimit.maximum) return;
}
this.TurnQueue.enqueue(client);
if (this.TurnQueue.size === 1) this.nextTurn();
} else {
@@ -684,7 +707,6 @@ export default class WSServer {
private nextTurn() {
clearInterval(this.TurnInterval);
if (this.TurnQueue.size === 0) {
this.TurnIntervalRunning = false;
} else {
this.TurnTime = this.Config.collabvm.turnTime;
this.TurnInterval = setInterval(() => this.turnInterval(), 1000);
@@ -694,7 +716,6 @@ export default class WSServer {
clearTurns() {
clearInterval(this.TurnInterval);
this.TurnIntervalRunning = false;
this.TurnQueue.clear();
this.sendTurnUpdate();
}

View File

@@ -25,6 +25,15 @@ try {
async function start() {
// Print a warning if qmpSockDir is set
// and the host OS is Windows, as this
// configuration will very likely not work.
if(process.platform === "win32" && Config.vm.qmpSockDir) {
log("WARN", "You appear to have the option 'qmpSockDir' enabled in the config.")
log("WARN", "This is not supported on Windows, and you will likely run into issues.");
log("WARN", "To remove this warning, use the qmpHost and qmpPort options instead.");
}
// Fire up the VM
var VM = new QEMUVM(Config);
await VM.Start();

View File

@@ -1,3 +1,7 @@
export default function log(loglevel : string, message : string) {
console[(loglevel === "ERROR" || loglevel === "FATAL") ? "error" : "log"](`[${new Date().toLocaleString()}] [${loglevel}] ${message}`);
console[
(loglevel === "ERROR" || loglevel === "FATAL") ? "error" :
(loglevel === "WARN") ? "warn" :
"log"
](`[${new Date().toLocaleString()}] [${loglevel}] ${message}`);
}