From 1df42349ff3890e05c72063c0e98b0e7cf0ba6df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 11 Jun 2026 15:20:02 +0200 Subject: [PATCH 1/6] Add ElysiaJS support --- library/agent/Agent.ts | 1 + library/agent/protect.ts | 2 + library/middleware/elysia.ts | 35 +++ library/package-lock.json | 244 +++++++++++++++++ library/package.json | 6 +- library/sources/Elysia.test.ts | 268 +++++++++++++++++++ library/sources/Elysia.ts | 100 +++++++ library/sources/elysia/contextFromRequest.ts | 32 +++ library/sources/elysia/wrapRequestHandler.ts | 15 ++ 9 files changed, 701 insertions(+), 2 deletions(-) create mode 100644 library/middleware/elysia.ts create mode 100644 library/sources/Elysia.test.ts create mode 100644 library/sources/Elysia.ts create mode 100644 library/sources/elysia/contextFromRequest.ts create mode 100644 library/sources/elysia/wrapRequestHandler.ts diff --git a/library/agent/Agent.ts b/library/agent/Agent.ts index 9dfd4aea1..72a7a0ec0 100644 --- a/library/agent/Agent.ts +++ b/library/agent/Agent.ts @@ -618,6 +618,7 @@ export class Agent { "express", "fastify", "hono", + "elysia", "koa", "@hapi/hapi", "restify", diff --git a/library/agent/protect.ts b/library/agent/protect.ts index ac882065f..f80541e00 100644 --- a/library/agent/protect.ts +++ b/library/agent/protect.ts @@ -16,6 +16,7 @@ import { createCloudFunctionWrapper, FunctionsFramework, } from "../sources/FunctionsFramework"; +import { Elysia } from "../sources/Elysia"; import { Hono } from "../sources/Hono"; import { HTTPServer } from "../sources/HTTPServer"; import { createLambdaWrapper } from "../sources/Lambda"; @@ -151,6 +152,7 @@ export function getWrappers() { new Path(), new HTTPServer(), new Hono(), + new Elysia(), new GraphQL(), new OpenAI(), new Mistral(), diff --git a/library/middleware/elysia.ts b/library/middleware/elysia.ts new file mode 100644 index 000000000..4f5f6c0f5 --- /dev/null +++ b/library/middleware/elysia.ts @@ -0,0 +1,35 @@ +import { shouldBlockRequest } from "./shouldBlockRequest"; +import { escapeHTML } from "../helpers/escapeHTML"; +/** TS_EXPECT_TYPES_ERROR_OPTIONAL_DEPENDENCY **/ +import type { AnyElysia } from "elysia"; + +/** + * Calling this function will setup rate limiting and user blocking for the provided Elysia app. + * Attacks will still be blocked by Zen if you do not call this function. + * Execute this function as early as possible in your Elysia app, but after the hook that sets the user. + */ +export function addElysiaPlugin(app: AnyElysia) { + app.onBeforeHandle(() => { + const result = shouldBlockRequest(); + + if (result.block) { + if (result.type === "ratelimited") { + let message = "You are rate limited by Zen."; + if (result.trigger === "ip" && result.ip) { + message += ` (Your IP: ${escapeHTML(result.ip)})`; + } + + return new Response(message, { + status: 429, + headers: { + "Retry-After": result.retryAfterSeconds.toString(), + }, + }); + } + + if (result.type === "blocked") { + return new Response("You are blocked by Zen.", { status: 403 }); + } + } + }); +} diff --git a/library/package-lock.json b/library/package-lock.json index f73a8b45b..2c9888ab3 100644 --- a/library/package-lock.json +++ b/library/package-lock.json @@ -15,6 +15,7 @@ "@anthropic-ai/sdk": "^0.56.0", "@aws-sdk/client-bedrock-runtime": "3.929.0", "@clickhouse/client": "^1.7.0", + "@elysia/node": "^1.4.6", "@fastify/cookie": "^11.0.2", "@google-cloud/functions-framework": "^5.0.0", "@google-cloud/pubsub": "^5.0.0", @@ -55,6 +56,7 @@ "better-sqlite3": "^12.10.0", "bson-objectid": "^2.0.4", "cookie-parser": "^1.4.6", + "elysia": "^1.4.28", "express": "^5.0.0", "express-async-handler": "^1.2.0", "express-v4": "npm:express@^4.0.0", @@ -2714,6 +2716,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@clickhouse/client": { "version": "1.15.0", "dev": true, @@ -2741,6 +2755,20 @@ "node": ">=12" } }, + "node_modules/@elysia/node": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@elysia/node/-/node-1.4.6.tgz", + "integrity": "sha512-UqtpX6E2uz3KJ9c/MWBJ4/3WDpUN15CzNWPUS/QbGMqO6Cmjqluh+pZqW5Yaday1+HL/JADj+zG8BjtHvRun2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crossws": "^0.4.5", + "srvx": "^0.11.5" + }, + "peerDependencies": { + "elysia": ">= 1.4.0" + } + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.5", "dev": true, @@ -4694,6 +4722,14 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "dev": true, @@ -5958,6 +5994,33 @@ "@tapjs/core": "1.5.4" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -7884,6 +7947,21 @@ "node": ">= 8" } }, + "node_modules/crossws": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.4.6.tgz", + "integrity": "sha512-/Wxe9Z007EbJ496j88nToZEvyPZ8PY/wjZJ18Agh/GCA9cYHyLbxtrpdFlFzAw3TV20F0SUYGl0g6PzChbwUrg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "srvx": ">=0.11.5" + }, + "peerDependenciesMeta": { + "srvx": { + "optional": true + } + } + }, "node_modules/csv": { "version": "6.4.1", "dev": true, @@ -8138,6 +8216,35 @@ "dev": true, "license": "MIT" }, + "node_modules/elysia": { + "version": "1.4.28", + "resolved": "https://registry.npmjs.org/elysia/-/elysia-1.4.28.tgz", + "integrity": "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^1.1.1", + "exact-mirror": "^0.2.7", + "fast-decode-uri-component": "^1.0.1", + "memoirist": "^0.4.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">= 0.34.0 < 1", + "@types/bun": ">= 1.2.0", + "exact-mirror": ">= 0.0.9", + "file-type": ">= 20.0.0", + "openapi-types": ">= 12.0.0", + "typescript": ">= 5.0.0" + }, + "peerDependenciesMeta": { + "@types/bun": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "dev": true, @@ -8295,6 +8402,21 @@ "assert-plus": "^1.0.0" } }, + "node_modules/exact-mirror": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/exact-mirror/-/exact-mirror-0.2.7.tgz", + "integrity": "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sinclair/typebox": "^0.34.15" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + } + } + }, "node_modules/execa": { "version": "5.1.1", "dev": true, @@ -9051,6 +9173,26 @@ "node": ">= 8" } }, + "node_modules/file-type": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz", + "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "dev": true, @@ -11090,6 +11232,13 @@ "node": ">= 0.8" } }, + "node_modules/memoirist": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/memoirist/-/memoirist-0.4.0.tgz", + "integrity": "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg==", + "dev": true, + "license": "MIT" + }, "node_modules/memory-pager": { "version": "1.5.0", "dev": true, @@ -12788,6 +12937,14 @@ } } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/opener": { "version": "1.5.2", "dev": true, @@ -15891,6 +16048,19 @@ "node": ">= 0.6" } }, + "node_modules/srvx": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.16.tgz", + "integrity": "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw==", + "dev": true, + "license": "MIT", + "bin": { + "srvx": "bin/srvx.mjs" + }, + "engines": { + "node": ">=20.16.0" + } + }, "node_modules/sshpk": { "version": "1.18.0", "dev": true, @@ -16103,6 +16273,24 @@ ], "license": "MIT" }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/stubs": { "version": "3.0.0", "dev": true, @@ -16580,6 +16768,48 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/token-types/node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/tr46": { "version": "5.1.1", "dev": true, @@ -16865,6 +17095,20 @@ "node": ">=14.17" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/library/package.json b/library/package.json index 73b8c50c0..7f12e74c3 100644 --- a/library/package.json +++ b/library/package.json @@ -59,6 +59,7 @@ "@anthropic-ai/sdk": "^0.56.0", "@aws-sdk/client-bedrock-runtime": "3.929.0", "@clickhouse/client": "^1.7.0", + "@elysia/node": "^1.4.6", "@fastify/cookie": "^11.0.2", "@google-cloud/functions-framework": "^5.0.0", "@google-cloud/pubsub": "^5.0.0", @@ -73,8 +74,6 @@ "@koa/router-v11": "npm:@koa/router@^11.0.0", "@koa/router-v12": "npm:@koa/router@^12.0.0", "@koa/router-v13": "npm:@koa/router@^13.0.0", - "mistralai-v1": "npm:@mistralai/mistralai@^1.11.0", - "mistralai-v2": "npm:@mistralai/mistralai@^2.1.2", "@prisma/client": "^5.22.0", "@sinonjs/fake-timers": "^11.2.2", "@types/aws-lambda": "^8.10.131", @@ -101,6 +100,7 @@ "better-sqlite3": "^12.10.0", "bson-objectid": "^2.0.4", "cookie-parser": "^1.4.6", + "elysia": "^1.4.28", "express": "^5.0.0", "express-async-handler": "^1.2.0", "express-v4": "npm:express@^4.0.0", @@ -118,6 +118,8 @@ "koa-v3": "npm:koa@^3.0.0", "mariadb-v3.4": "npm:mariadb@3.4.4", "mariadb-v3.5": "npm:mariadb@^3.5.1", + "mistralai-v1": "npm:@mistralai/mistralai@^1.11.0", + "mistralai-v2": "npm:@mistralai/mistralai@^2.1.2", "mongodb": "^6.16.0", "mongodb-v4": "npm:mongodb@^4.0.0", "mongodb-v5": "npm:mongodb@^5.0.0", diff --git a/library/sources/Elysia.test.ts b/library/sources/Elysia.test.ts new file mode 100644 index 000000000..d0cb826fa --- /dev/null +++ b/library/sources/Elysia.test.ts @@ -0,0 +1,268 @@ +import * as t from "tap"; +import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting"; +import { Token } from "../agent/api/Token"; +import { setUser } from "../agent/context/user"; +import { Elysia as ElysiaInternal } from "./Elysia"; +import { HTTPServer } from "./HTTPServer"; +import { getMajorNodeVersion } from "../helpers/getNodeVersion"; +import { getContext } from "../agent/Context"; +import { isLocalhostIP } from "../helpers/isLocalhostIP"; +import { createTestAgent } from "../helpers/createTestAgent"; +import { addElysiaPlugin } from "../middleware/elysia"; +import { fetch } from "../helpers/fetch"; +import { FetchListsAPIForTesting } from "../agent/api/FetchListsAPIForTesting"; + +const agent = createTestAgent({ + token: new Token("123"), + api: new ReportingAPIForTesting({ + success: true, + endpoints: [ + { + method: "GET", + route: "/rate-limited", + forceProtectionOff: false, + rateLimiting: { + windowSizeInMS: 2000, + maxRequests: 2, + enabled: true, + }, + }, + ], + blockedUserIds: ["567"], + configUpdatedAt: 0, + heartbeatIntervalInMS: 10 * 60 * 1000, + allowedIPAddresses: ["4.3.2.1"], + excludedUserIdsFromRateLimiting: [], + }), + fetchListsAPI: new FetchListsAPIForTesting({ + blockedIPAddresses: [ + { + key: "geoip/Belgium;BE", + source: "geoip", + description: "geo restrictions", + ips: ["1.3.2.0/24"], + }, + ], + blockedUserAgents: "hacker|attacker", + allowedIPAddresses: [], + monitoredIPAddresses: [], + monitoredUserAgents: "", + userAgentDetails: [ + { + key: "hacker", + pattern: "hacker", + }, + ], + }), +}); +agent.start([new ElysiaInternal(), new HTTPServer()]); + +const skip = + getMajorNodeVersion() < 18 ? "Elysia does not support Node.js < 18" : false; + +const PORT = 9871; + +let server: { stop(): void | Promise }; + +t.before(async () => { + if (skip) return; + + const { Elysia } = require("elysia") as typeof import("elysia"); + const { node } = require("@elysia/node") as typeof import("@elysia/node"); + + const app = new Elysia({ adapter: node() }); + + app.use((app) => { + app.onBeforeHandle(({ request, path }: any) => { + if (path.startsWith("/user/blocked")) { + setUser({ id: "567" }); + } else if (path.startsWith("/user")) { + setUser({ id: "123" }); + } + + const userId = request.headers.get("x-user-id"); + if (userId) { + setUser({ id: userId }); + } + }); + return app; + }); + + addElysiaPlugin(app); + + app.get("/", () => getContext()); + app.post("/json", () => getContext()); + app.post("/text", () => getContext()); + app.get("/user", () => getContext()); + app.get("/user/blocked", () => getContext()); + app.get("/rate-limited", () => "OK"); + app.get("/cats/:id", () => getContext()); + + server = await new Promise((resolve) => app.listen(PORT, resolve)); +}); + +t.after(async () => { + await server?.stop(); +}); + +const opts = { skip }; + +t.test("it adds context from request for GET", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/?title=test`), + method: "GET", + headers: { + accept: "application/json", + cookie: "session=123", + }, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + query: { title: "test" }, + cookies: { session: "123" }, + headers: { accept: "application/json", cookie: "session=123" }, + source: "elysia", + route: "/", + }); + + t.ok(isLocalhostIP(body.remoteAddress)); +}); + +t.test("it parses X-Forwarded-For header for IP address", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/?title=test`), + method: "GET", + headers: { + "X-Forwarded-For": "1.2.3.4", + }, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + query: { title: "test" }, + source: "elysia", + route: "/", + remoteAddress: "1.2.3.4", + }); +}); + +t.test("it adds JSON body to context", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/json`), + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ title: "test" }), + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "POST", + body: { title: "test" }, + source: "elysia", + route: "/json", + }); +}); + +t.test("it adds text body to context", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/text`), + method: "POST", + headers: { + "content-type": "text/plain", + }, + body: "hello world", + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "POST", + body: "hello world", + source: "elysia", + route: "/text", + }); +}); + +t.test("it sets the user in the context", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/user`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + source: "elysia", + route: "/user", + user: { id: "123" }, + }); +}); + +t.test("it blocks user", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/user/blocked`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 403); + t.equal(response.body, "You are blocked by Zen."); +}); + +t.test("it rate limits based on IP address", opts, async (t) => { + const makeRequest = () => + fetch({ + url: new URL(`http://127.0.0.1:${PORT}/rate-limited`), + method: "GET", + headers: { "X-Forwarded-For": "1.2.3.4" }, + timeoutInMS: 500, + }); + + const r1 = await makeRequest(); + t.equal(r1.statusCode, 200); + + const r2 = await makeRequest(); + t.equal(r2.statusCode, 200); + + const r3 = await makeRequest(); + t.equal(r3.statusCode, 429); + t.match(r3.body, "You are rate limited by Zen."); +}); + +t.test("ip blocking works", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/`), + headers: { + "X-Forwarded-For": "1.3.2.4", // Blocked IP + }, + }); + t.equal(response.statusCode, 403); + t.match(response.body, "geo restrictions"); +}); + +t.test("it captures route parameters", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/cats/123`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + source: "elysia", + route: "/cats/:number", + routeParams: { id: "123" }, + }); +}); diff --git a/library/sources/Elysia.ts b/library/sources/Elysia.ts new file mode 100644 index 000000000..e5f6eeb97 --- /dev/null +++ b/library/sources/Elysia.ts @@ -0,0 +1,100 @@ +import { Hooks } from "../agent/hooks/Hooks"; +import { Wrapper } from "../agent/Wrapper"; +import { wrapRequestHandler } from "./elysia/wrapRequestHandler"; +import { wrapExport } from "../agent/hooks/wrapExport"; + +const METHODS = [ + "get", + "post", + "put", + "delete", + "options", + "patch", + "all", + "on", + // Include lifecycle hooks so middleware added via onBeforeHandle gets context + "onBeforeHandle", +] as const; + +export class Elysia implements Wrapper { + private wrapArgs(args: unknown[]) { + return args.map((arg) => { + if (typeof arg !== "function") { + return arg; + } + + return wrapRequestHandler( + arg as Parameters[0] + ); + }); + } + + wrap(hooks: Hooks) { + hooks + .addPackage("elysia") + .withVersion("^1.4.0") + .onRequire((exports, pkgInfo) => { + const newExports = Object.create(exports); + + // Elysia's CJS bundle defines lazy getters for all exports + // Accessing exports.Elysia during module initialization (which happens during + // circular dependency loading) does not work. + // + // We define a lazy getter on newExports.Elysia so prototype wrapping is + // deferred until the first time user code accesses the class. + + let instrumented = false; + Object.defineProperty(newExports, "Elysia", { + configurable: true, + enumerable: true, + get: () => { + const ElysiaClass = exports.Elysia; + if (!ElysiaClass) { + return ElysiaClass; + } + + if (!instrumented) { + instrumented = true; + METHODS.forEach((method) => { + if (typeof ElysiaClass.prototype[method] === "function") { + wrapExport(ElysiaClass.prototype, method, pkgInfo, { + kind: undefined, + modifyArgs: (args) => this.wrapArgs(args), + }); + } + }); + } + + return ElysiaClass; + }, + }); + + return newExports; + }) + .addMultiFileInstrumentation( + [ + "dist/index.js", // CJS + "dist/index.mjs", // ESM + ], + [ + { + nodeType: "MethodDefinition", + name: "add", + operationKind: undefined, + modifyArgs: (args) => { + // args: [method, path, handle, localHook?, options?] + // handle is always at index 2 + return args.map((arg, i) => { + if (i === 2 && typeof arg === "function") { + return wrapRequestHandler( + arg as Parameters[0] + ); + } + return arg; + }); + }, + }, + ] + ); + } +} diff --git a/library/sources/elysia/contextFromRequest.ts b/library/sources/elysia/contextFromRequest.ts new file mode 100644 index 000000000..4146f3be1 --- /dev/null +++ b/library/sources/elysia/contextFromRequest.ts @@ -0,0 +1,32 @@ +import type { Context as ElysiaContext } from "elysia"; +import { getContext, type Context } from "../../agent/Context"; +import { getIPAddressFromRequest } from "../../helpers/getIPAddressFromRequest"; +import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; + +export function contextFromRequest(ctx: ElysiaContext): Context { + const existingContext = getContext(); + + const cookies = Object.fromEntries( + Object.entries(ctx.cookie) + .map(([k, v]) => [k, v.value]) + .filter(([_, v]) => typeof v === "string") + ); + + return { + method: ctx.request.method, + remoteAddress: + existingContext?.remoteAddress || + getIPAddressFromRequest({ + headers: ctx.headers, + remoteAddress: undefined, // Not possible in Node.js with Elysia + }), + body: ctx.body, + url: ctx.request.url, + headers: ctx.headers, + routeParams: ctx.params, + query: ctx.query, + cookies: cookies, + source: "elysia", + route: buildRouteFromURL(ctx.request.url), + }; +} diff --git a/library/sources/elysia/wrapRequestHandler.ts b/library/sources/elysia/wrapRequestHandler.ts new file mode 100644 index 000000000..85373fd2b --- /dev/null +++ b/library/sources/elysia/wrapRequestHandler.ts @@ -0,0 +1,15 @@ +import type { Context as ElysiaContext } from "elysia"; +import { runWithContext } from "../../agent/Context"; +import { contextFromRequest } from "./contextFromRequest"; + +type ElysiaHandler = (ctx: ElysiaContext) => unknown; + +export function wrapRequestHandler(handler: ElysiaHandler): ElysiaHandler { + return async (ctx: ElysiaContext) => { + const context = contextFromRequest(ctx); + + return await runWithContext(context, async () => { + return await handler(ctx); + }); + }; +} From 0806e36f64bde2f3e7e47c7fe5e10acfd6a5f5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 11 Jun 2026 17:32:25 +0200 Subject: [PATCH 2/6] Improve wrapping, add a lot more tests --- library/sources/Elysia.test.ts | 320 ++++++++++++++++++- library/sources/Elysia.ts | 69 ++-- library/sources/elysia/contextFromRequest.ts | 12 +- scripts/tests-esm.mjs | 3 + 4 files changed, 366 insertions(+), 38 deletions(-) diff --git a/library/sources/Elysia.test.ts b/library/sources/Elysia.test.ts index d0cb826fa..27fc98d34 100644 --- a/library/sources/Elysia.test.ts +++ b/library/sources/Elysia.test.ts @@ -90,6 +90,20 @@ t.before(async () => { addElysiaPlugin(app); + app.onRequest((ctx) => { + const path = new URL(ctx.request.url).pathname; + if (path === "/test-on-request") { + return getContext(); + } + }); + + app.on("beforeHandle", ({ request }) => { + const path = new URL(request.url).pathname; + if (path === "/test-on-before-handle") { + return getContext(); + } + }); + app.get("/", () => getContext()); app.post("/json", () => getContext()); app.post("/text", () => getContext()); @@ -97,11 +111,14 @@ t.before(async () => { app.get("/user/blocked", () => getContext()); app.get("/rate-limited", () => "OK"); app.get("/cats/:id", () => getContext()); + app.route("MKCALENDAR", "/calendars", () => getContext()); + + app.get("/test-on-before-handle", (ctx) => "Not called"); server = await new Promise((resolve) => app.listen(PORT, resolve)); }); -t.after(async () => { +t.teardown(async () => { await server?.stop(); }); @@ -266,3 +283,304 @@ t.test("it captures route parameters", opts, async (t) => { routeParams: { id: "123" }, }); }); + +t.test("it works with custom request methods", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/calendars`), + method: "MKCALENDAR", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "MKCALENDAR", + source: "elysia", + route: "/calendars", + }); +}); + +t.test("it works with on('request') handler", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/test-on-request`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + source: "elysia", + route: "/test-on-request", + }); +}); + +t.test("it returns 404 for non-existent route", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/test-on-request-does-not-exist`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 404); +}); + +t.test("it works with on('beforeHandle') handler", opts, async (t) => { + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT}/test-on-before-handle`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + source: "elysia", + route: "/test-on-before-handle", + }); +}); + +t.test("app with prefix", opts, async (t) => { + const { Elysia } = require("elysia") as typeof import("elysia"); + const { node } = require("@elysia/node") as typeof import("@elysia/node"); + + const app = new Elysia({ adapter: node(), prefix: "/prefix" }); + + app.get("/test", () => getContext()); + + const _server = await new Promise((resolve) => + app.listen(PORT + 1, resolve) + ); + + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT + 1}/prefix/test`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + source: "elysia", + route: "/prefix/test", + }); + + await _server?.stop(); +}); + +t.test("app with only route", opts, async (t) => { + const { Elysia } = require("elysia") as typeof import("elysia"); + const { node } = require("@elysia/node") as typeof import("@elysia/node"); + + const app = new Elysia({ adapter: node(), prefix: "/prefix" }); + + app.route("MKCALENDAR", "/test", () => getContext()); + + const _server = await new Promise((resolve) => + app.listen(PORT + 2, resolve) + ); + + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT + 2}/prefix/test`), + method: "MKCALENDAR", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "MKCALENDAR", + source: "elysia", + route: "/prefix/test", + }); + + await _server?.stop(); +}); + +t.test("app with only onRequest handler", opts, async (t) => { + const { Elysia } = require("elysia") as typeof import("elysia"); + const { node } = require("@elysia/node") as typeof import("@elysia/node"); + + const app = new Elysia({ adapter: node(), prefix: "/prefix" }); + + app.onRequest((ctx) => { + const path = new URL(ctx.request.url).pathname; + if (path === "/prefix/test") { + return getContext(); + } + }); + + const _server = await new Promise((resolve) => + app.listen(PORT + 3, resolve) + ); + + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT + 3}/prefix/test`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + source: "elysia", + route: "/prefix/test", + }); + + await _server?.stop(); +}); + +t.test("it supports groups", opts, async (t) => { + const { Elysia } = require("elysia") as typeof import("elysia"); + const { node } = require("@elysia/node") as typeof import("@elysia/node"); + + const app = new Elysia({ adapter: node() }); + + app.group("/group", (group) => group.get("/test", () => getContext())); + + const _server = await new Promise((resolve) => + app.listen(PORT + 4, resolve) + ); + + const response = await fetch({ + url: new URL(`http://127.0.01:${PORT + 4}/group/test?title=test`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + source: "elysia", + route: "/group/test", + query: { title: "test" }, + }); + + await _server?.stop(); +}); + +t.test("it supports streams", opts, async (t) => { + const { Elysia } = require("elysia") as typeof import("elysia"); + const { node } = require("@elysia/node") as typeof import("@elysia/node"); + + const app = new Elysia({ adapter: node() }); + + app.get("stream", function* test() { + yield 1; + yield 2; + yield "\n"; + yield getContext(); + }); + + const _server = await new Promise((resolve) => + app.listen(PORT + 5, resolve) + ); + + const response = await fetch({ + url: new URL(`http://127.0.01:${PORT + 5}/stream`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body.split("\n").slice(-1)[0]); + t.match(body, { + method: "GET", + source: "elysia", + route: "/stream", + query: {}, + }); + + await _server?.stop(); +}); + +t.test("it works with .on with multiple handlers", opts, async (t) => { + const { Elysia } = require("elysia") as typeof import("elysia"); + const { node } = require("@elysia/node") as typeof import("@elysia/node"); + + const app = new Elysia({ adapter: node() }); + + app.on("request", [ + (ctx) => { + const path = new URL(ctx.request.url).pathname; + if (path === "/test-on-request") { + return getContext(); + } + }, + (ctx) => { + const path = new URL(ctx.request.url).pathname; + if (path === "/test-on-request-2") { + return getContext(); + } + }, + ]); + + const _server = await new Promise((resolve) => + app.listen(PORT + 6, resolve) + ); + + const response = await fetch({ + url: new URL(`http://127.0.0.1:${PORT + 6}/test-on-request`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + source: "elysia", + route: "/test-on-request", + }); + + const response2 = await fetch({ + url: new URL(`http://127.0.0.1:${PORT + 6}/test-on-request-2`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response2.statusCode, 200); + const body2 = JSON.parse(response2.body); + t.match(body2, { + method: "GET", + source: "elysia", + route: "/test-on-request-2", + }); + + await _server?.stop(); +}); + +t.test("it works with app using another", opts, async (t) => { + const { Elysia } = require("elysia") as typeof import("elysia"); + const { node } = require("@elysia/node") as typeof import("@elysia/node"); + + const app1 = new Elysia({ adapter: node() }).get("/test", () => getContext()); + + const app2 = new Elysia({ adapter: node() }).use(app1); + + const _server = await new Promise((resolve) => + app2.listen(PORT + 7, resolve) + ); + + const response = await fetch({ + url: new URL(`http://127.0.01:${PORT + 7}/test`), + method: "GET", + headers: {}, + timeoutInMS: 500, + }); + t.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + t.match(body, { + method: "GET", + source: "elysia", + route: "/test", + query: {}, + }); + + await _server?.stop(); +}); diff --git a/library/sources/Elysia.ts b/library/sources/Elysia.ts index e5f6eeb97..037d7bd1f 100644 --- a/library/sources/Elysia.ts +++ b/library/sources/Elysia.ts @@ -3,19 +3,6 @@ import { Wrapper } from "../agent/Wrapper"; import { wrapRequestHandler } from "./elysia/wrapRequestHandler"; import { wrapExport } from "../agent/hooks/wrapExport"; -const METHODS = [ - "get", - "post", - "put", - "delete", - "options", - "patch", - "all", - "on", - // Include lifecycle hooks so middleware added via onBeforeHandle gets context - "onBeforeHandle", -] as const; - export class Elysia implements Wrapper { private wrapArgs(args: unknown[]) { return args.map((arg) => { @@ -29,6 +16,29 @@ export class Elysia implements Wrapper { }); } + private wrapOnArgs(args: unknown[]) { + return args.map((arg) => { + if (typeof arg === "function") { + return wrapRequestHandler( + arg as Parameters[0] + ); + } + + if (Array.isArray(arg)) { + return arg.map((item) => { + if (typeof item === "function") { + return wrapRequestHandler( + item as Parameters[0] + ); + } + return item; + }); + } + + return arg; + }); + } + wrap(hooks: Hooks) { hooks .addPackage("elysia") @@ -55,13 +65,13 @@ export class Elysia implements Wrapper { if (!instrumented) { instrumented = true; - METHODS.forEach((method) => { - if (typeof ElysiaClass.prototype[method] === "function") { - wrapExport(ElysiaClass.prototype, method, pkgInfo, { - kind: undefined, - modifyArgs: (args) => this.wrapArgs(args), - }); - } + wrapExport(ElysiaClass.prototype, "add", pkgInfo, { + kind: undefined, + modifyArgs: (args) => this.wrapArgs(args), + }); + wrapExport(ElysiaClass.prototype, "on", pkgInfo, { + kind: undefined, + modifyArgs: (args) => this.wrapOnArgs(args), }); } @@ -81,18 +91,13 @@ export class Elysia implements Wrapper { nodeType: "MethodDefinition", name: "add", operationKind: undefined, - modifyArgs: (args) => { - // args: [method, path, handle, localHook?, options?] - // handle is always at index 2 - return args.map((arg, i) => { - if (i === 2 && typeof arg === "function") { - return wrapRequestHandler( - arg as Parameters[0] - ); - } - return arg; - }); - }, + modifyArgs: (args) => this.wrapArgs(args), + }, + { + nodeType: "MethodDefinition", + name: "on", + operationKind: undefined, + modifyArgs: (args) => this.wrapOnArgs(args), }, ] ); diff --git a/library/sources/elysia/contextFromRequest.ts b/library/sources/elysia/contextFromRequest.ts index 4146f3be1..8580dcd69 100644 --- a/library/sources/elysia/contextFromRequest.ts +++ b/library/sources/elysia/contextFromRequest.ts @@ -6,11 +6,13 @@ import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; export function contextFromRequest(ctx: ElysiaContext): Context { const existingContext = getContext(); - const cookies = Object.fromEntries( - Object.entries(ctx.cookie) - .map(([k, v]) => [k, v.value]) - .filter(([_, v]) => typeof v === "string") - ); + const cookies = ctx.cookie + ? Object.fromEntries( + Object.entries(ctx.cookie) + .map(([k, v]) => [k, v.value]) + .filter(([_, v]) => typeof v === "string") + ) + : {}; return { method: ctx.request.method, diff --git a/scripts/tests-esm.mjs b/scripts/tests-esm.mjs index 76b626923..0a2e580ec 100644 --- a/scripts/tests-esm.mjs +++ b/scripts/tests-esm.mjs @@ -207,6 +207,9 @@ for await (const entry of testFiles) { testCb.params.push({ type: "Identifier", name: "t" }); } break; + case "teardown": + node.callee = { type: "Identifier", name: "after" }; + break; case "beforeEach": case "before": case "after": From 9c061e5e0286e8774827c35b5a4d524fcefdfa81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 11 Jun 2026 17:42:53 +0200 Subject: [PATCH 3/6] Fix broken codecov action --- .github/workflows/unit-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index e74c6f7aa..e633fb7e6 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -115,7 +115,7 @@ jobs: command: npm run test:esm - name: Upload coverage - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: ./library/.tap/report/lcov.info,./.esm-tests/tests/lcov.info use_oidc: true From 1c1675d5dd950cf89361f25df565f6fc30b88bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 11 Jun 2026 18:18:40 +0200 Subject: [PATCH 4/6] Add e2e tests --- end2end/tests-new/elysia-pg-esm.test.mjs | 142 +++++ sample-apps/elysiajs-pg-esm/app.ts | 35 ++ sample-apps/elysiajs-pg-esm/db.ts | 22 + sample-apps/elysiajs-pg-esm/package-lock.json | 492 ++++++++++++++++++ sample-apps/elysiajs-pg-esm/package.json | 19 + 5 files changed, 710 insertions(+) create mode 100644 end2end/tests-new/elysia-pg-esm.test.mjs create mode 100644 sample-apps/elysiajs-pg-esm/app.ts create mode 100644 sample-apps/elysiajs-pg-esm/db.ts create mode 100644 sample-apps/elysiajs-pg-esm/package-lock.json create mode 100644 sample-apps/elysiajs-pg-esm/package.json diff --git a/end2end/tests-new/elysia-pg-esm.test.mjs b/end2end/tests-new/elysia-pg-esm.test.mjs new file mode 100644 index 000000000..5b60767e2 --- /dev/null +++ b/end2end/tests-new/elysia-pg-esm.test.mjs @@ -0,0 +1,142 @@ +import { spawn } from "child_process"; +import { resolve } from "path"; +import { test } from "node:test"; +import { equal, fail, match, doesNotMatch } from "node:assert"; +import { getRandomPort } from "./utils/get-port.mjs"; +import { timeout } from "./utils/timeout.mjs"; + +const pathToAppDir = resolve( + import.meta.dirname, + "../../sample-apps/elysiajs-pg-esm" +); + +const port = await getRandomPort(); +const port2 = await getRandomPort(); + +test("it blocks request in blocking mode", async () => { + const server = spawn( + `node`, + ["-r", "@aikidosec/firewall/instrument", "./app.ts"], + { + cwd: pathToAppDir, + env: { + ...process.env, + AIKIDO_DEBUG: "true", + AIKIDO_BLOCK: "true", + PORT: port.toString(), + }, + } + ); + + try { + server.on("error", (err) => { + fail(err.message); + }); + + let stdout = ""; + server.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + let stderr = ""; + server.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + // Wait for the server to start + await timeout(2000); + + const [sqlInjection, normalAdd] = await Promise.all([ + fetch(`http://127.0.0.1:${port}/add`, { + method: "POST", + body: JSON.stringify({ name: "Njuska'); DELETE FROM cats_7;-- H" }), + headers: { + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(5000), + }), + fetch(`http://127.0.0.1:${port}/add`, { + method: "POST", + body: JSON.stringify({ name: "Miau" }), + headers: { + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(5000), + }), + ]); + + equal(sqlInjection.status, 500); + equal(normalAdd.status, 200); + match(stdout, /Starting agent/); + match(stdout, /Zen has blocked an SQL injection/); + } catch (err) { + fail(err); + } finally { + server.kill(); + } +}); + +test("it does not block request in monitoring mode", async () => { + const server = spawn( + `node`, + ["-r", "@aikidosec/firewall/instrument", "./app.ts", port2], + { + cwd: pathToAppDir, + env: { + ...process.env, + AIKIDO_DEBUG: "true", + AIKIDO_BLOCK: "false", + PORT: port2.toString(), + }, + } + ); + + try { + server.on("error", (err) => { + fail(err.message); + }); + + let stdout = ""; + server.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + let stderr = ""; + server.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + // Wait for the server to start + await timeout(2000); + + const [sqlInjection, normalAdd] = await Promise.all([ + fetch(`http://127.0.0.1:${port2}/add`, { + method: "POST", + body: JSON.stringify({ + name: "Njuska'); DELETE FROM cats_7;-- H", + }), + headers: { + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(5000), + }), + fetch(`http://127.0.0.1:${port2}/add`, { + method: "POST", + body: JSON.stringify({ name: "Miau" }), + headers: { + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(5000), + }), + ]); + + equal(sqlInjection.status, 200); + equal(normalAdd.status, 200); + match(stdout, /Starting agent/); + doesNotMatch(stdout, /Zen has blocked an SQL injection/); + } catch (err) { + fail(err); + } finally { + server.kill(); + } +}); diff --git a/sample-apps/elysiajs-pg-esm/app.ts b/sample-apps/elysiajs-pg-esm/app.ts new file mode 100644 index 000000000..369a71cd9 --- /dev/null +++ b/sample-apps/elysiajs-pg-esm/app.ts @@ -0,0 +1,35 @@ +import { Elysia, t } from "elysia"; +import { node } from "@elysia/node"; +import { createConnection } from "./db.ts"; + +const db = await createConnection(); + +new Elysia({ adapter: node() }) + .get("/", () => "Hello world") + .post( + "/add", + async ({ body, status }) => { + // Insecure + await db.query(`INSERT INTO cats_7 (petname) VALUES ('${body.name}');`); + return status(200, "OK"); + }, + { + body: t.Object({ + name: t.String(), + }), + } + ) + .get("/clear", async ({ status }) => { + await db.query("DELETE FROM cats_7;"); + return status(200, "Table cleared"); + }) + .listen(process.env.PORT || 4000, ({ hostname, port }) => { + console.log(`Server is running at ${hostname}:${port}`); + }); + +for (const sig of ["SIGINT", "SIGTERM"]) { + process.on(sig, async () => { + await db.end(); + process.exit(0); + }); +} diff --git a/sample-apps/elysiajs-pg-esm/db.ts b/sample-apps/elysiajs-pg-esm/db.ts new file mode 100644 index 000000000..ce53c6ab9 --- /dev/null +++ b/sample-apps/elysiajs-pg-esm/db.ts @@ -0,0 +1,22 @@ +import pg from "pg"; +const { Client } = pg; + +export async function createConnection(): Promise { + const client = new Client({ + user: "root", + host: "127.0.0.1", + database: "main_db", + password: "password", + port: 27016, + }); + + await client.connect(); + await client.query(` + CREATE TABLE IF NOT EXISTS cats_7 ( + petname varchar(255), + comment varchar(255) + ); + `); + + return client; +} diff --git a/sample-apps/elysiajs-pg-esm/package-lock.json b/sample-apps/elysiajs-pg-esm/package-lock.json new file mode 100644 index 000000000..cb20a23b9 --- /dev/null +++ b/sample-apps/elysiajs-pg-esm/package-lock.json @@ -0,0 +1,492 @@ +{ + "name": "elysiajs-pg-esm", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elysiajs-pg-esm", + "dependencies": { + "@aikidosec/firewall": "file:../../build", + "@elysia/node": "^1.4.6", + "elysia": "^1.4.28", + "pg": "^8.13.3" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@types/pg": "^8.20.0", + "typescript": "^6.0.3" + } + }, + "../../build": { + "name": "@aikidosec/firewall", + "version": "0.0.0", + "license": "AGPL-3.0-or-later", + "engines": { + "node": ">=16" + } + }, + "node_modules/@aikidosec/firewall": { + "resolved": "../../build", + "link": true + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@elysia/node": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@elysia/node/-/node-1.4.6.tgz", + "integrity": "sha512-UqtpX6E2uz3KJ9c/MWBJ4/3WDpUN15CzNWPUS/QbGMqO6Cmjqluh+pZqW5Yaday1+HL/JADj+zG8BjtHvRun2g==", + "license": "MIT", + "dependencies": { + "crossws": "^0.4.5", + "srvx": "^0.11.5" + }, + "peerDependencies": { + "elysia": ">= 1.4.0" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "license": "MIT", + "peer": true + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crossws": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.4.6.tgz", + "integrity": "sha512-/Wxe9Z007EbJ496j88nToZEvyPZ8PY/wjZJ18Agh/GCA9cYHyLbxtrpdFlFzAw3TV20F0SUYGl0g6PzChbwUrg==", + "license": "MIT", + "peerDependencies": { + "srvx": ">=0.11.5" + }, + "peerDependenciesMeta": { + "srvx": { + "optional": true + } + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/elysia": { + "version": "1.4.28", + "resolved": "https://registry.npmjs.org/elysia/-/elysia-1.4.28.tgz", + "integrity": "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.1.1", + "exact-mirror": "^0.2.7", + "fast-decode-uri-component": "^1.0.1", + "memoirist": "^0.4.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">= 0.34.0 < 1", + "@types/bun": ">= 1.2.0", + "exact-mirror": ">= 0.0.9", + "file-type": ">= 20.0.0", + "openapi-types": ">= 12.0.0", + "typescript": ">= 5.0.0" + }, + "peerDependenciesMeta": { + "@types/bun": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/exact-mirror": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/exact-mirror/-/exact-mirror-0.2.7.tgz", + "integrity": "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg==", + "license": "MIT", + "peerDependencies": { + "@sinclair/typebox": "^0.34.15" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + } + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/file-type": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz", + "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/memoirist": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/memoirist/-/memoirist-0.4.0.tgz", + "integrity": "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/srvx": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.16.tgz", + "integrity": "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw==", + "license": "MIT", + "bin": { + "srvx": "bin/srvx.mjs" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "peer": true, + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/sample-apps/elysiajs-pg-esm/package.json b/sample-apps/elysiajs-pg-esm/package.json new file mode 100644 index 000000000..ebc19518a --- /dev/null +++ b/sample-apps/elysiajs-pg-esm/package.json @@ -0,0 +1,19 @@ +{ + "name": "elysiajs-pg-esm", + "private": true, + "type": "module", + "scripts": { + "start": "node --require @aikidosec/firewall/instrument ./app.ts" + }, + "dependencies": { + "@aikidosec/firewall": "file:../../build", + "@elysia/node": "^1.4.6", + "elysia": "^1.4.28", + "pg": "^8.13.3" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@types/pg": "^8.20.0", + "typescript": "^6.0.3" + } +} From ee7339cb214e9ccd2f70830aeb19969e9815aef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 11 Jun 2026 19:02:35 +0200 Subject: [PATCH 5/6] Change middleware and add docs --- README.md | 1 + docs/elysiajs.md | 74 +++++++++++++++++++++++++++ library/index.ts | 3 ++ library/middleware/elysia.ts | 47 ++++++++--------- library/sources/Elysia.test.ts | 6 +-- sample-apps/elysiajs-pg-esm/README.md | 3 ++ sample-apps/elysiajs-pg-esm/app.ts | 2 + 7 files changed, 108 insertions(+), 28 deletions(-) create mode 100644 docs/elysiajs.md create mode 100644 sample-apps/elysiajs-pg-esm/README.md diff --git a/README.md b/README.md index 18f73935c..967203aea 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Zen for Node.js 16+ is compatible with: - ✅ [Koa](docs/koa.md) 3.x and 2.x - ✅ [NestJS](docs/nestjs.md) 10.x and 11.x - ✅ [Restify](docs/restify.md) 11.x, 10.x, 9.x and 8.x +- ✅ [ElysiaJS](docs/elysiajs.md) 1.x (minimum 1.4.0) ### Database drivers diff --git a/docs/elysiajs.md b/docs/elysiajs.md new file mode 100644 index 000000000..c7d42f2fe --- /dev/null +++ b/docs/elysiajs.md @@ -0,0 +1,74 @@ +# ElysiaJS + +💡 ElysiaJS runs on more JavaScript runtimes than just Node.js. Right now, Zen only supports Node.js. + +At the very beginning of your app.js file, add the following line: + +```js +require("@aikidosec/firewall"); // <-- Include this before any other code or imports + +const { Elysia } = require("elysia"); + +new Elysia({ adapter: node() }).get("/", () => "Hello World").listen(3000); + +// ... +``` + +or using `import` syntax: + +```js +import "@aikidosec/firewall"; + +// ... +``` + +> [!NOTE] +> Many TypeScript projects use `import` syntax but still compile to CommonJS — in that case, the setup above works as-is. If your app runs as **native ESM** at runtime (e.g. `"type": "module"` in package.json), see [ESM setup](./esm.md) for additional steps. + +## Blocking mode + +By default, the firewall will run in non-blocking mode. When it detects an attack, the attack will be reported to Aikido if the environment variable `AIKIDO_TOKEN` is set and continue executing the call. + +You can enable blocking mode by setting the environment variable `AIKIDO_BLOCK` to `true`: + +```sh +AIKIDO_BLOCK=true node app.js +``` + +It's recommended to enable this on your staging environment for a considerable amount of time before enabling it on your production environment (e.g. one week). + +## Rate limiting and user blocking + +If you want to add the rate limiting feature to your app, modify your code like this: + +```js +const Zen = require("@aikidosec/firewall"); + +new Elysia({ adapter: node() }) + .onBeforeHandle(Zen.elysiaHandler) // <-- Add this line + .get("/", () => "Hello World") + .listen(3000); +``` + +## Debug mode + +If you need to debug the firewall, you can run your ElysiaJS app with the environment variable `AIKIDO_DEBUG` set to `true`: + +```sh +AIKIDO_DEBUG=true node app.js +``` + +This will output debug information to the console (e.g. if the agent failed to start, no token was found, unsupported packages, ...). + +## Preventing prototype pollution + +Zen can also protect your application against prototype pollution attacks. + +Read [Protect against prototype pollution](./prototype-pollution.md) to learn how to set it up. + +That's it! Your app is now protected by Zen. +If you want to see a full example, check our [ElysiaJS sample app](../sample-apps/elysiajs-pg-esm). + +## Graceful shutdown + +It is recommended to add a shutdown handler to your app to ensure that no statistics are lost when the app is stopped. You can find more information [here](./graceful-shutdown.md). diff --git a/library/index.ts b/library/index.ts index 8c476c354..f0cb7248c 100644 --- a/library/index.ts +++ b/library/index.ts @@ -22,6 +22,7 @@ import { colorText } from "./helpers/colorText"; import { warnBox } from "./helpers/warnBox"; import { isPreloaded } from "./helpers/isPreloaded"; import { warnIfEntrypointIsModule } from "./helpers/warnIfEntrypointIsModule"; +import { elysiaHandler } from "./middleware/elysia"; // Prevent logging twice / trying to start agent twice if (!isNewHookSystemUsed()) { @@ -73,6 +74,7 @@ export { fastifyHook, addKoaMiddleware, addRestifyMiddleware, + elysiaHandler, setRateLimitGroup, shutdown, setTenantId, @@ -93,6 +95,7 @@ export default { fastifyHook, addKoaMiddleware, addRestifyMiddleware, + elysiaHandler, setRateLimitGroup, shutdown, setTenantId, diff --git a/library/middleware/elysia.ts b/library/middleware/elysia.ts index 4f5f6c0f5..efa555e85 100644 --- a/library/middleware/elysia.ts +++ b/library/middleware/elysia.ts @@ -1,35 +1,32 @@ import { shouldBlockRequest } from "./shouldBlockRequest"; import { escapeHTML } from "../helpers/escapeHTML"; /** TS_EXPECT_TYPES_ERROR_OPTIONAL_DEPENDENCY **/ -import type { AnyElysia } from "elysia"; +import type { OptionalHandler } from "elysia"; /** - * Calling this function will setup rate limiting and user blocking for the provided Elysia app. - * Attacks will still be blocked by Zen if you do not call this function. - * Execute this function as early as possible in your Elysia app, but after the hook that sets the user. + * Adding this handler using app.onBeforeHandle(elysiaHandler) will setup rate limiting and user blocking for the provided Elysia app. + * Attacks will still be blocked by Zen if you do not add this handler. */ -export function addElysiaPlugin(app: AnyElysia) { - app.onBeforeHandle(() => { - const result = shouldBlockRequest(); +export const elysiaHandler: OptionalHandler = () => { + const result = shouldBlockRequest(); - if (result.block) { - if (result.type === "ratelimited") { - let message = "You are rate limited by Zen."; - if (result.trigger === "ip" && result.ip) { - message += ` (Your IP: ${escapeHTML(result.ip)})`; - } - - return new Response(message, { - status: 429, - headers: { - "Retry-After": result.retryAfterSeconds.toString(), - }, - }); + if (result.block) { + if (result.type === "ratelimited") { + let message = "You are rate limited by Zen."; + if (result.trigger === "ip" && result.ip) { + message += ` (Your IP: ${escapeHTML(result.ip)})`; } - if (result.type === "blocked") { - return new Response("You are blocked by Zen.", { status: 403 }); - } + return new Response(message, { + status: 429, + headers: { + "Retry-After": result.retryAfterSeconds.toString(), + }, + }); + } + + if (result.type === "blocked") { + return new Response("You are blocked by Zen.", { status: 403 }); } - }); -} + } +}; diff --git a/library/sources/Elysia.test.ts b/library/sources/Elysia.test.ts index 27fc98d34..d6753331c 100644 --- a/library/sources/Elysia.test.ts +++ b/library/sources/Elysia.test.ts @@ -8,7 +8,7 @@ import { getMajorNodeVersion } from "../helpers/getNodeVersion"; import { getContext } from "../agent/Context"; import { isLocalhostIP } from "../helpers/isLocalhostIP"; import { createTestAgent } from "../helpers/createTestAgent"; -import { addElysiaPlugin } from "../middleware/elysia"; +import { elysiaHandler } from "../middleware/elysia"; import { fetch } from "../helpers/fetch"; import { FetchListsAPIForTesting } from "../agent/api/FetchListsAPIForTesting"; @@ -58,7 +58,7 @@ const agent = createTestAgent({ agent.start([new ElysiaInternal(), new HTTPServer()]); const skip = - getMajorNodeVersion() < 18 ? "Elysia does not support Node.js < 18" : false; + getMajorNodeVersion() < 20 ? "Elysia does not support Node.js < 20" : false; const PORT = 9871; @@ -88,7 +88,7 @@ t.before(async () => { return app; }); - addElysiaPlugin(app); + app.onBeforeHandle(elysiaHandler); app.onRequest((ctx) => { const path = new URL(ctx.request.url).pathname; diff --git a/sample-apps/elysiajs-pg-esm/README.md b/sample-apps/elysiajs-pg-esm/README.md new file mode 100644 index 000000000..3f89fc30a --- /dev/null +++ b/sample-apps/elysiajs-pg-esm/README.md @@ -0,0 +1,3 @@ +# elysiajs-pg-esm + +WARNING: This application contains security issues and should not be used in production (or taken as an example of how to write secure code). diff --git a/sample-apps/elysiajs-pg-esm/app.ts b/sample-apps/elysiajs-pg-esm/app.ts index 369a71cd9..3523c371e 100644 --- a/sample-apps/elysiajs-pg-esm/app.ts +++ b/sample-apps/elysiajs-pg-esm/app.ts @@ -1,11 +1,13 @@ import { Elysia, t } from "elysia"; import { node } from "@elysia/node"; import { createConnection } from "./db.ts"; +import Zen from "@aikidosec/firewall"; const db = await createConnection(); new Elysia({ adapter: node() }) .get("/", () => "Hello world") + .onBeforeHandle(Zen.elysiaHandler) .post( "/add", async ({ body, status }) => { From 40fc117cc44d037f757ab3aa9ed507d0f6402c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 12 Jun 2026 09:26:08 +0200 Subject: [PATCH 6/6] Fix types and codequality comments --- library/middleware/elysia.ts | 4 +--- library/sources/elysia/contextFromRequest.ts | 24 ++++++++++++-------- library/sources/elysia/wrapRequestHandler.ts | 4 ++-- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/library/middleware/elysia.ts b/library/middleware/elysia.ts index efa555e85..55e47ccd4 100644 --- a/library/middleware/elysia.ts +++ b/library/middleware/elysia.ts @@ -1,13 +1,11 @@ import { shouldBlockRequest } from "./shouldBlockRequest"; import { escapeHTML } from "../helpers/escapeHTML"; -/** TS_EXPECT_TYPES_ERROR_OPTIONAL_DEPENDENCY **/ -import type { OptionalHandler } from "elysia"; /** * Adding this handler using app.onBeforeHandle(elysiaHandler) will setup rate limiting and user blocking for the provided Elysia app. * Attacks will still be blocked by Zen if you do not add this handler. */ -export const elysiaHandler: OptionalHandler = () => { +export const elysiaHandler: () => Response | void = () => { const result = shouldBlockRequest(); if (result.block) { diff --git a/library/sources/elysia/contextFromRequest.ts b/library/sources/elysia/contextFromRequest.ts index 8580dcd69..ef7e82342 100644 --- a/library/sources/elysia/contextFromRequest.ts +++ b/library/sources/elysia/contextFromRequest.ts @@ -6,14 +6,6 @@ import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; export function contextFromRequest(ctx: ElysiaContext): Context { const existingContext = getContext(); - const cookies = ctx.cookie - ? Object.fromEntries( - Object.entries(ctx.cookie) - .map(([k, v]) => [k, v.value]) - .filter(([_, v]) => typeof v === "string") - ) - : {}; - return { method: ctx.request.method, remoteAddress: @@ -27,8 +19,22 @@ export function contextFromRequest(ctx: ElysiaContext): Context { headers: ctx.headers, routeParams: ctx.params, query: ctx.query, - cookies: cookies, + cookies: convertCookies(ctx.cookie), source: "elysia", route: buildRouteFromURL(ctx.request.url), }; } + +function convertCookies( + cookies: ElysiaContext["cookie"] +): Record { + if (!cookies) { + return {}; + } + + return Object.fromEntries( + Object.entries(cookies) + .map(([k, v]) => [k, v.value]) + .filter(([_, v]) => typeof v === "string") + ); +} diff --git a/library/sources/elysia/wrapRequestHandler.ts b/library/sources/elysia/wrapRequestHandler.ts index 85373fd2b..a94903274 100644 --- a/library/sources/elysia/wrapRequestHandler.ts +++ b/library/sources/elysia/wrapRequestHandler.ts @@ -8,8 +8,8 @@ export function wrapRequestHandler(handler: ElysiaHandler): ElysiaHandler { return async (ctx: ElysiaContext) => { const context = contextFromRequest(ctx); - return await runWithContext(context, async () => { - return await handler(ctx); + return await runWithContext(context, () => { + return handler(ctx); }); }; }