diff --git a/config.example.toml b/config.example.toml index 0fbfa9a..34ebcf0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -7,6 +7,11 @@ proxying = true # 99% of the time this will only be 127.0.0.1 proxyAllowedIps = ["127.0.0.1"] +[hcaptcha] +enabled = false +sitekey = "" +secret = "" + [vm] qemuArgs = "qemu-system-x86_64" vncPort = 5900 diff --git a/package.json b/package.json index f281c80..d39d40b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@types/sharp": "^0.31.1", "@types/ws": "^8.5.4", "async-mutex": "^0.4.0", + "axios": "^1.3.2", "canvas": "^2.11.0", "execa": "^6.1.0", "fs": "^0.0.1-security", diff --git a/src/IConfig.ts b/src/IConfig.ts index 1a9e3c4..89274fa 100644 --- a/src/IConfig.ts +++ b/src/IConfig.ts @@ -5,6 +5,12 @@ export default interface IConfig { proxying : boolean; proxyAllowedIps : string[]; }; + hcaptcha : { + enabled : boolean; + sitekey : string; + secret : string; + whitelist : string[]; + }; vm : { qemuArgs : string; vncPort : number; diff --git a/src/User.ts b/src/User.ts index c6066e6..e33e106 100644 --- a/src/User.ts +++ b/src/User.ts @@ -18,6 +18,7 @@ export class User { Config : IConfig; IP : string; vote : boolean | null; + captchaValidated : boolean; // Rate limiters ChatRateLimit : RateLimiter; LoginRateLimit : RateLimiter; @@ -52,6 +53,7 @@ export class User { this.LoginRateLimit.on('limit', () => this.closeConnection()); this.TurnRateLimit = new RateLimiter(5, 3); this.TurnRateLimit.on('limit', () => this.closeConnection()); + this.captchaValidated = false; } assignGuestName(existingUsers : string[]) : string { var username; diff --git a/src/WSServer.ts b/src/WSServer.ts index b6c2d9f..fb1dbe8 100644 --- a/src/WSServer.ts +++ b/src/WSServer.ts @@ -12,6 +12,7 @@ import { createHash } from 'crypto'; import { isIP } from 'net'; import QEMUVM from './QEMUVM.js'; import { Canvas, createCanvas, CanvasRenderingContext2D } from 'canvas'; +import hcaptcha from './hcaptcha.js'; export default class WSServer { private Config : IConfig; @@ -40,6 +41,8 @@ export default class WSServer { private turnsAllowed : boolean; // Indefinite turn private indefiniteTurn : User | null; + // Captcha + private captcha : hcaptcha; private ModPerms : number; private VM : QEMUVM; constructor(config : IConfig, vm : QEMUVM) { @@ -69,6 +72,7 @@ export default class WSServer { this.VM = vm; this.VM.on("dirtyrect", (j, x, y) => this.newrect(j, x, y)); this.VM.on("size", (s) => this.newsize(s)); + this.captcha = new hcaptcha(this.Config); } listen() { @@ -131,6 +135,8 @@ export default class WSServer { ip = req.socket.remoteAddress; } var user = new User(ws, ip, this.Config); + if (this.captcha.checkIpValidated(user.IP)) + user.captchaValidated = true; this.clients.push(user); ws.on('close', () => this.connectionClosed(user)); ws.on('message', (e) => { @@ -164,10 +170,14 @@ export default class WSServer { if (msgArr.length < 1) return; switch (msgArr[0]) { case "list": + if (this.Config.hcaptcha.enabled && !client.captchaValidated && this.Config.hcaptcha.whitelist.indexOf(client.IP) === -1) + client.sendMsg(guacutils.encode("captcha", "0", this.Config.hcaptcha.sitekey)); client.sendMsg(guacutils.encode("list", this.Config.collabvm.node, this.Config.collabvm.displayname, await this.getThumbnail())); break; case "connect": - if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) { + if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node + || (this.Config.hcaptcha.enabled && !client.captchaValidated && this.Config.hcaptcha.whitelist.indexOf(client.IP) === -1) + ) { client.sendMsg(guacutils.encode("connect", "0")); return; } @@ -183,6 +193,16 @@ export default class WSServer { if (this.voteInProgress) this.sendVoteUpdate(client); this.sendTurnUpdate(client); break; + case "captcha": + if (client.captchaValidated || msgArr.length !== 2) return; + var result = await this.captcha.validateToken(msgArr[1], client.IP); + if (result) { + client.sendMsg(guacutils.encode("captcha", "1")); + client.captchaValidated = true; + } else { + client.sendMsg(guacutils.encode("captcha", "2")); + } + break; case "rename": if (!client.RenameRateLimit.request()) return; this.renameUser(client, msgArr[1]); diff --git a/src/hcaptcha.ts b/src/hcaptcha.ts new file mode 100644 index 0000000..f619639 --- /dev/null +++ b/src/hcaptcha.ts @@ -0,0 +1,31 @@ +import IConfig from "./IConfig"; +import axios from 'axios'; +import querystring from 'node:querystring'; + +export default class hcaptcha { + Config : IConfig; + ValidIps : string[]; + constructor(config : IConfig) { + this.Config = config; + this.ValidIps = []; + } + checkIpValidated(ip : string) : boolean { + return (this.ValidIps.indexOf(ip) !== -1); + } + validateToken(token : string, ip : string) : Promise { + return new Promise(async (res, rej) => { + var response; + try { + response = await axios.post("https://hcaptcha.com/siteverify", querystring.encode({ + "secret": this.Config.hcaptcha.secret, + "response": token, + "remoteip": ip + })); + } catch (e) {rej(e); return;} + if (response.data.success === true) { + this.ValidIps.push(ip); + } + res(response.data.success); + }) + } +} \ No newline at end of file