Skip to content

Support h3 #572

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Zen for Node.js 16+ is compatible with:
* ✅ [Fastify](docs/fastify.md) 4.x and 5.x
* ✅ [Koa](docs/koa.md) 3.x and 2.x
* ✅ [NestJS](docs/nestjs.md) 10.x and 11.x
* ✅ [H3](docs/h3.md) 1.8.x and newer 1.x versions

### Database drivers

Expand Down
88 changes: 88 additions & 0 deletions docs/h3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# H3

💡 H3 runs on more JavaScript runtimes than just Node.js. Right now, Zen only supports Node.js. Using `npx listhen` is currently not supported

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 { createApp, toNodeListener } = require("h3");
const { createServer } = require("http");

const app = createApp();

...

// Using `npx listhen` is currently not supported
createServer(toNodeListener(app)).listen(process.env.PORT || 3000);

...

// ...
```

or ESM import style:

```js
import "@aikidosec/firewall";

// ...
```

## 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");

const app = createApp();

// Optional, if you want to use user based rate limiting or block specific users
app.use(defineEventHandler((event) => {
// Get the user from your authentication middleware
// or wherever you store the user
Zen.setUser({
id: "123",
name: "John Doe", // Optional
});

}));

// Call this as early as possible, before other middleware
Zen.addH3Middleware(app);

app.get(...);
```

## Debug mode

If you need to debug the firewall, you can run your express 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 [h3 sample app](../sample-apps/h3-postgres).
111 changes: 111 additions & 0 deletions end2end/tests/h3-postgres.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
const t = require("tap");
const { spawn } = require("child_process");
const { resolve } = require("path");
const timeout = require("../timeout");

const pathToApp = resolve(__dirname, "../../sample-apps/h3-postgres", "app.js");

t.test("it blocks in blocking mode", (t) => {
const server = spawn(`node`, [pathToApp], {
env: {
...process.env,
AIKIDO_DEBUG: "true",
AIKIDO_BLOCKING: "true",
PORT: "4000",
},
});

server.on("close", () => {
t.end();
});

server.on("error", (err) => {
t.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
timeout(4000)
.then(() => {
return Promise.all([
fetch(
`http://127.0.0.1:4000/?petname=${encodeURIComponent("Njuska'); DELETE FROM cats_3;-- H")}`,
{
signal: AbortSignal.timeout(5000),
}
),
fetch("http://127.0.0.1:4000/?petname=Njuska", {
signal: AbortSignal.timeout(5000),
}),
]);
})
.then(([sqlInjection, normalSearch]) => {
t.equal(sqlInjection.status, 500);
t.equal(normalSearch.status, 200);
t.match(stdout, /Starting agent/);
t.match(stdout, /Zen has blocked an SQL injection/);
})
.catch((error) => {
t.fail(error.message);
})
.finally(() => {
server.kill();
});
});

t.test("it does not block in dry mode", (t) => {
const server = spawn(`node`, [pathToApp], {
env: { ...process.env, AIKIDO_DEBUG: "true", PORT: "4001" },
});

server.on("close", () => {
t.end();
});

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
timeout(4000)
.then(() =>
Promise.all([
fetch(
`http://127.0.0.1:4001/?petname=${encodeURIComponent("Njuska'); DELETE FROM cats_3;-- H")}`,
{
signal: AbortSignal.timeout(5000),
}
),
fetch("http://127.0.0.1:4001/?petname=Njuska", {
signal: AbortSignal.timeout(5000),
}),
])
)
.then(([sqlInjection, normalSearch]) => {
t.equal(sqlInjection.status, 200);
t.equal(normalSearch.status, 200);
t.match(stdout, /Starting agent/);
t.notMatch(stdout, /Zen has blocked an SQL injection/);
})
.catch((error) => {
t.fail(error.message);
})
.finally(() => {
server.kill();
});
});
2 changes: 2 additions & 0 deletions library/agent/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { Fastify } from "../sources/Fastify";
import { Koa } from "../sources/Koa";
import { ClickHouse } from "../sinks/ClickHouse";
import { Prisma } from "../sinks/Prisma";
import { H3 } from "../sources/H3";

function getLogger(): Logger {
if (isDebugging()) {
Expand Down Expand Up @@ -141,6 +142,7 @@ export function getWrappers() {
new ClickHouse(),
new Prisma(),
// new Function(), Disabled because functionName.constructor === Function is false after patching global
new H3(),
];
}

Expand Down
69 changes: 69 additions & 0 deletions library/helpers/formDataToPlainObject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as t from "tap";
import { formDataToPlainObject } from "./formDataToPlainObject";

t.test(
"simple",
{
skip: !globalThis.FormData
? "This Node.js version does not support FormData yet"
: false,
},
async (t) => {
const formData = new FormData();
formData.append("abc", "123");
formData.append("another", "42");
formData.append("hello", "world");

t.same(formDataToPlainObject(formData), {
abc: "123",
another: "42",
hello: "world",
});
}
);

t.test(
"with arrays",
{
skip: !globalThis.FormData
? "This Node.js version does not support FormData yet"
: false,
},
async (t) => {
const formData = new FormData();
formData.append("abc", "123");
formData.append("arr", "1");
formData.append("arr", "2");
formData.append("arr", "3");

t.same(formDataToPlainObject(formData), {
abc: "123",
arr: ["1", "2", "3"],
});
}
);

t.test(
"binary data",
{
skip:
!globalThis.FormData || !globalThis.File
? "This Node.js version does not support FormData or File yet"
: false,
},
async (t) => {
const formData = new FormData();
formData.append("abc", "123");
formData.append("arr", "2");
formData.append("arr", "3");
formData.append(
"file",
new File(["hello"], "hello.txt", { type: "text/plain" })
);

t.same(formDataToPlainObject(formData), {
abc: "123",
arr: ["2", "3"],
});
}
);
27 changes: 27 additions & 0 deletions library/helpers/formDataToPlainObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export function formDataToPlainObject(formData: FormData) {
const object: Map<string, unknown> = new Map();
formData.forEach((value, key) => {
if (typeof value !== "string") {
return;
}

if (object.has(key)) {
// If the key already exists, treat it as an array
const entry = object.get(key);

if (Array.isArray(entry)) {
// If it's already an array, just push the new value
entry.push(value);
return;
}

// Convert it to an array
object.set(key, [object.get(key), value]);
return;
}

object.set(key, value);
});

return Object.fromEntries(object);
}
3 changes: 3 additions & 0 deletions library/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { addHapiMiddleware } from "./middleware/hapi";
import { addFastifyHook } from "./middleware/fastify";
import { addKoaMiddleware } from "./middleware/koa";
import { isESM } from "./helpers/isESM";
import { addH3Middleware } from "./middleware/h3";

const supported = isFirewallSupported();
const shouldEnable = shouldEnableFirewall();
Expand All @@ -33,6 +34,7 @@ export {
addHapiMiddleware,
addFastifyHook,
addKoaMiddleware,
addH3Middleware,
};

// Required for ESM / TypeScript default export support
Expand All @@ -46,4 +48,5 @@ export default {
addHapiMiddleware,
addFastifyHook,
addKoaMiddleware,
addH3Middleware,
};
40 changes: 40 additions & 0 deletions library/middleware/h3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { shouldBlockRequest } from "./shouldBlockRequest";
/** TS_EXPECT_TYPES_ERROR_OPTIONAL_DEPENDENCY **/
import type { H3Event, App } from "h3";
import { escapeHTML } from "../helpers/escapeHTML";

/**
* Calling this function will setup rate limiting and user blocking for the provided H3 app.
* Attacks will still be blocked by Zen if you do not call this function.
* Execute this function as early as possible in your H3 app, but after the middleware that sets the user.
*/
export function addH3Middleware(app: App) {
const handler = function zenMiddleware(event: H3Event) {
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)})`;
}

event.node.res.statusCode = 429;
event.node.res.setHeader("content-type", "text/plain");
return message;
}

if (result.type === "blocked") {
event.node.res.statusCode = 403;
event.node.res.setHeader("content-type", "text/plain");
return "You are blocked by Zen.";
}
}
};

// eslint-disable-next-line camelcase
handler.__is_handler__ = true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why this is added? Can't find this in h3 codebase?
Wouldn't it be okay to require something from h3 so that we don't have to do this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only on mobile right now, but it was added in H3 codebase. Importing is always a bit problematic with bundlers, no?


// @ts-expect-error Ignore
app.use(handler);
}
Loading
Loading