diff --git a/env.example b/env.example new file mode 100644 index 0000000..d162505 --- /dev/null +++ b/env.example @@ -0,0 +1,16 @@ +PORT=8082 +HOST=0.0.0.0 +DATABASE_URL="postgresql://adm_cowch:Bluish-Tycoon4-Cuddly@localhost:5432/websocketTutorial?sslmode=require&channel_binding=require" + +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_USER=adm_cowch +DATABASE_PASSWORD=Bluish-Tycoon4-Cuddly +DATABASE_DB=websocketTutorial + +# Run Arcjet in development . +ARCJET_ENV=development +# Arcjet key for your site (from ). +# More info: . +ARCJET_KEY="ajkey_01kgraw410ey49fv7r7jzxbzcc" +ARCJET_MODE="" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cfa742c..52c0918 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@arcjet/inspect": "^1.1.0", + "@arcjet/node": "^1.1.0", "@dotenvx/dotenvx": "^1.52.0", "drizzle-orm": "^0.45.1", "express": "^5.2.1", @@ -28,6 +30,182 @@ "typescript": "^5.9.3" } }, + "node_modules/@arcjet/analyze": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/analyze/-/analyze-1.1.0.tgz", + "integrity": "sha512-rbgjTjk4PvoRh8wweerD5Qr0G/xar0XWNiRTvzcmrnJofuhESLjWD87p3Y3Od307gzex8Whtu+jA0LvII1H4DA==", + "license": "Apache-2.0", + "dependencies": { + "@arcjet/analyze-wasm": "1.1.0", + "@arcjet/protocol": "1.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/analyze-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/analyze-wasm/-/analyze-wasm-1.1.0.tgz", + "integrity": "sha512-CrzSBa9rQFQRfIUofm2FuwVjlnCdWK8hQz3uxbMqYFFLeGPAQftM2noHM7P2p+2Rq6j1p6u462RIpjg2Y2rhXQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/body": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/body/-/body-1.1.0.tgz", + "integrity": "sha512-ijMcy71ezPtu9aj29Cde6McBd634ugtqD26ufF8r/fb7YH/eYsWJQS4LZBnkEdgangAfb2VO1VEUpKwGSyZmqA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/cache": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/cache/-/cache-1.1.0.tgz", + "integrity": "sha512-TFt5PdlQVOngGTL+Q02NLGtv6Ks15m68a5Jksk9rEDM8vVzwoFE6KXawPNPm0pcwow4a/C+GcYwYprDm7MpXlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/duration": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/duration/-/duration-1.1.0.tgz", + "integrity": "sha512-22MVyEnJgRv4RpFol6LefKAjh2ei7ekBIkawPZi+F0YNQtGPTTJP7r+gLT615oUcZFptBxiuogsXk6zhFycSsA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/env/-/env-1.1.0.tgz", + "integrity": "sha512-q6GLj4HRBJZy12EHtqre1ihvSJMikl5jwenzIMjBkkAIuyl0bpOpMpnIuaP5bJ4dkVLTpsicIcEYn6aFGZsXOA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/headers/-/headers-1.1.0.tgz", + "integrity": "sha512-V4PVy84gxc30g8CGZaLl//QAogdaAKJ2Sbtjccd5iUmxeUXXdqW3L0/xwm0xCebwB8rxojwhG7CzD3uv781Zdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/inspect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/inspect/-/inspect-1.1.0.tgz", + "integrity": "sha512-yaBGbG/iwfpR/mfyiomLGzePjUr7YFzLi2qss/TjDLcX6TvQ4nW+HqOidGzAE5IVqdtl13TG9vD2f2tv9AkIog==", + "license": "Apache-2.0", + "dependencies": { + "@arcjet/protocol": "1.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/ip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/ip/-/ip-1.1.0.tgz", + "integrity": "sha512-mxOqZ/EVhO3bN7NBr36yURyFLE9KEXUzW+Pbq3U9HogqjMoNA4g3rkomVo3cNWhJcNCHNMRKplWbFsvVuyRdHQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/logger": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/logger/-/logger-1.1.0.tgz", + "integrity": "sha512-QQkDpdevbDOK8Ojy7REQap6y3J0fC1/5D/TE7hlC41Y2Gz6oyXIYzbLxEoyCYkfzMeOnMthHAXXwzI+E2gvdgg==", + "license": "Apache-2.0", + "dependencies": { + "@arcjet/sprintf": "1.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/node": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/node/-/node-1.1.0.tgz", + "integrity": "sha512-89WpKP3xwvpLuKPhN7hWMAER76EnI6f5tUydVMm+LJF1TKJcZt80L91vEO4nTQU+is28BR57P4/IF7q+voDQSQ==", + "license": "Apache-2.0", + "dependencies": { + "@arcjet/body": "1.1.0", + "@arcjet/env": "1.1.0", + "@arcjet/headers": "1.1.0", + "@arcjet/ip": "1.1.0", + "@arcjet/logger": "1.1.0", + "@arcjet/protocol": "1.1.0", + "@arcjet/transport": "1.1.0", + "arcjet": "1.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/protocol": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/protocol/-/protocol-1.1.0.tgz", + "integrity": "sha512-evjUsyOh04bQgH9lWm/xIyqF3C8XkiKvRa/QYuUvMYEpe/J1hndHaOZqzqlzQqkm44cbM5l8s+06SpHyaG/iFg==", + "license": "Apache-2.0", + "dependencies": { + "@arcjet/cache": "1.1.0", + "@bufbuild/protobuf": "2.11.0", + "@connectrpc/connect": "2.1.1", + "typeid-js": "1.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/runtime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/runtime/-/runtime-1.1.0.tgz", + "integrity": "sha512-v/7I9n3l9ZHBWGOoSBIxhEZ5WhcLPToWWXz8HBn30H1UFzholJCSDvIKesTogSt/EmxEUVDwxnHFZxZh+EX9Zw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/sprintf": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/sprintf/-/sprintf-1.1.0.tgz", + "integrity": "sha512-YA19YrUYnEPxVPS5O41MlkynVLEtBVeu/eqrXGckQHmS7PuyNZEO2h8ZU+6G+ppua9igZUEzgYFVIh6UJRjWpQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/stable-hash": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/stable-hash/-/stable-hash-1.1.0.tgz", + "integrity": "sha512-JdeLw2r7f4sJKRi48BAypjcqgwlMf8LgcVZs3xH+CjE0N9DAxkl1AY/6Wt03WOz4S0FahC/amzCVNMJ6TlnQfA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@arcjet/transport": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@arcjet/transport/-/transport-1.1.0.tgz", + "integrity": "sha512-nv/9HSroy4UxndxFYF39St4t8yXTTKGnaKXqPDhRGBHwPGPMLvLHxvXD4bguDWu5iMSKGazsJFJx/RD/7SwG9Q==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.11.0", + "@connectrpc/connect": "2.1.1", + "@connectrpc/connect-node": "2.1.1", + "@connectrpc/connect-web": "2.1.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@biomejs/biome": { "version": "2.3.13", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz", @@ -191,6 +369,44 @@ "node": ">=14.21.3" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@connectrpc/connect": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz", + "integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.1.1.tgz", + "integrity": "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.1" + } + }, + "node_modules/@connectrpc/connect-web": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.1.tgz", + "integrity": "sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.1" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", @@ -1215,6 +1431,24 @@ "node": ">= 14" } }, + "node_modules/arcjet": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arcjet/-/arcjet-1.1.0.tgz", + "integrity": "sha512-YRzr8kfTLdSdp45W5Xrzf1+TMAvDYELD2D2Gg6jXgLZl0PjM/SoK3TMCTz/CMxohBx2AV17/wSM24YjcDEoHcg==", + "license": "Apache-2.0", + "dependencies": { + "@arcjet/analyze": "1.1.0", + "@arcjet/cache": "1.1.0", + "@arcjet/duration": "1.1.0", + "@arcjet/headers": "1.1.0", + "@arcjet/protocol": "1.1.0", + "@arcjet/runtime": "1.1.0", + "@arcjet/stable-hash": "1.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -3211,6 +3445,15 @@ "node": ">= 0.6" } }, + "node_modules/typeid-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typeid-js/-/typeid-js-1.2.0.tgz", + "integrity": "sha512-t76ZucAnvGC60ea/HjVsB0TSoB0cw9yjnfurUgtInXQWUI/VcrlZGpO23KN3iSe8yOGUgb1zr7W7uEzJ3hSljA==", + "license": "Apache-2.0", + "dependencies": { + "uuid": "^10.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3241,6 +3484,19 @@ "node": ">= 0.8" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index a60001c..7c89531 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "author": "", "license": "ISC", "dependencies": { + "@arcjet/inspect": "^1.1.0", + "@arcjet/node": "^1.1.0", "@dotenvx/dotenvx": "^1.52.0", "drizzle-orm": "^0.45.1", "express": "^5.2.1", diff --git a/src/index.js b/src/index.js index 59508df..3304d3e 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import http from "http"; // routes import { matchRouter } from "./routes/matches.route.js"; import { attachWebsocketServer } from "./ws/server.js"; +import { securityMiddleware } from "./utils/arkjet.js"; const PORT = process.env.PORT || 8081; const HOST = process.env.HOST || "0.0.0.0"; @@ -18,6 +19,9 @@ app.get("/", (_, res) => { res.send("Hello from express server!"); }); +// middleware +app.use(securityMiddleware()); + app.use("/matches", matchRouter); const { broadcastMatchCreated } = attachWebsocketServer(server); diff --git a/src/utils/arkjet.js b/src/utils/arkjet.js new file mode 100644 index 0000000..6a5097a --- /dev/null +++ b/src/utils/arkjet.js @@ -0,0 +1,57 @@ +import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/node"; + +const arcjetKey = process.env.ARCJET_KEY; +const arcjetMODE = process.env.ARCJET_MODE === "DRY_RUN" ? "DRY_RUN" : "LIVE"; + +if (!arcjetKey) throw new Error("ARKJET_KEY environment variable is missing."); + +export const httpArkjet = arcjetKey + ? arcjet({ + key: arcjetKey, + rules: [ + shield({ mode: arcjetMODE }), + detectBot({ + mode: arcjetMODE, + allow: ["CATEGORY:SEARCH_ENGINE", "CATEGORY:PREVIEW"], + }), + slidingWindow({ mode: arcjetMODE, interval: "10s", max: 50 }), + ], + }) + : null; + +export const wsArkjet = arcjetKey + ? arcjet({ + key: arcjetKey, + rules: [ + shield({ mode: arcjetMODE }), + detectBot({ + mode: arcjetMODE, + allow: ["CATEGORY:SEARCH_ENGINE", "CATEGORY:PREVIEW"], + }), + slidingWindow({ mode: arcjetMODE, interval: "2s", max: 5 }), + ], + }) + : null; + +export const securityMiddleware = () => { + return async (req, res, next) => { + if (!httpArkjet) return next; + + try { + const decision = await httpArkjet.protect(req); + + if (decision.isDenied()) { + if (decision.reason.isRateLimit()) { + return res.status(429).json({ error: "Too Many request" }); + } + + return res.status(403).json({ error: "Forbidden" }); + } + } catch (e) { + console.error("ArkJet middleware error", e); + res.status(503).json({ error: "Service Unavailable" }); + } + + next(); + }; +}; diff --git a/src/ws/server.js b/src/ws/server.js index c16e827..4440f81 100644 --- a/src/ws/server.js +++ b/src/ws/server.js @@ -1,4 +1,5 @@ import { WebSocket, WebSocketServer } from "ws"; +import { wsArkjet } from "../utils/arkjet.js"; const sendJson = (socket, payload) => { if (socket.readyState !== WebSocket.OPEN) { @@ -10,7 +11,7 @@ const sendJson = (socket, payload) => { const broadcast = (wss, payload) => { for (const client of wss.clients) { - if (client.readyState !== WebSocket.OPEN) return; + if (client.readyState !== WebSocket.OPEN) continue; client.send(JSON.stringify(payload)); } @@ -23,12 +24,48 @@ export const attachWebsocketServer = (server) => { maxPayload: 1024 * 1024, // 1mb }); - wss.on("connection", (socket) => { + wss.on("connection", async (socket, req) => { + if (wsArkjet) { + try { + const desision = await wsArkjet.protect(req); + + if (desision.isDenied) { + const code = desision.reason.isRateLimit() ? 1013 : 1008; + const reason = desision.reason.isRateLimit() + ? "Rate limit exceedeed" + : "Access denied"; + + socket.close(code, reason); + return; + } + } catch (e) { + console.error("WS connection error", e); + socket.close(1011, "Server security error"); + return; + } + } + socket.isAlive = true; + socket.on("pong", () => { + socket.isAlive = true; + }); sendJson(socket, { type: "welcome" }); socket.on("error", console.error); }); + const interval = setInterval(() => { + wss.clients.forEach((ws) => { + if (ws.isAlive === false) { + ws.terminate(); + return; + } + ws.isAlive = false; + ws.ping(); + }); + }, 30 * 1000); + + wss.on("close", () => clearInterval(interval)); + function broadcastMatchCreated(match) { broadcast(wss, { type: "match_created", data: match }); } diff --git a/testScripts/testRateLimits.js b/testScripts/testRateLimits.js new file mode 100644 index 0000000..e851d60 --- /dev/null +++ b/testScripts/testRateLimits.js @@ -0,0 +1,56 @@ +// Import the built-in HTTP module from Node.js +// This lets us make HTTP requests without curl or extra packages +import http from "http"; + +// How many times we want to hit the endpoint +const TOTAL_REQUESTS = 60; + +// Counter to keep track of how many requests we've made +let count = 0; + +// The URL we are testing +const url = "http://localhost:8082/matches"; + +// test websocket rate limit +const testWS = () => { + for (let i = 0; i < 10; i++) { + const ws = new WebSocket("ws://localhost:8082/ws"); + ws.onopen = () => console.info(`Socket ${i} opened`); + ws.onclose = (e) => + console.info(`Socket ${i} closed: ${e.code} ${e.reason}`); + } +}; + +// This function makes ONE HTTP request +function makeRequest() { + count++; + + // Create an HTTP GET request + const req = http.get(url, (res) => { + // res.statusCode is the HTTP response code (200, 404, 500, etc) + console.info(res.statusCode); + + // We don't care about the response body, so just discard it + res.resume(); + + // If we haven't hit our limit yet, make another request + if (count < TOTAL_REQUESTS) { + makeRequest(); + } + }); + + // If something goes wrong (server down, connection refused, etc) + req.on("error", (err) => { + console.error("Request failed:", err.message); + + // Still continue so we always try 60 times + if (count < TOTAL_REQUESTS) { + makeRequest(); + } + }); + + testWS(); +} + +// Start the first request +makeRequest();