Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- βœ… [LoopBack 4](docs/loopback4.md) with `@loopback/rest` 15.x and 14.x

### Database drivers

Expand Down
97 changes: 97 additions & 0 deletions docs/loopback4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# LoopBack 4

At the very beginning of your `index.ts` file, add the following line:

```js
import "@aikidosec/firewall"; // <-- Include this before any other code or imports

// ...
```

> [!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 npm start
```

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, you can create a new middleware.

```ts
// src/middleware/zen.middleware.ts
import { shouldBlockRequest } from "@aikidosec/firewall";
import { HttpErrors, type Middleware } from "@loopback/rest";

export const zenMiddleware: Middleware = async (middlewareCtx, next) => {
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: ${result.ip})`;
}

throw new HttpErrors.TooManyRequests(message);
}

if (result.type === "blocked") {
throw new HttpErrors.Forbidden("You are blocked by Zen.");
}
}

return next();
};
```

After creating the middleware, add it to your app:

```ts
// src/applications.ts

export class Loopback4Application extends BootMixin(
ServiceMixin(RepositoryMixin(RestApplication))
) {
constructor(options: ApplicationConfig = {}) {
super(options);
this.sequence(MySequence);
this.bodyParser(JsonBodyParser);

// ...

this.middleware(zenMiddleware); // <- Add this line and import the middleware at the top of the file

// ...
}
// ...
}
```

## Debug mode

If you need to debug the firewall, you can run your NestJS app with the environment variable `AIKIDO_DEBUG` set to `true`:

```sh
AIKIDO_DEBUG=true npm start
```

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 [LoopBack 4 example app](../sample-apps/loopback4-psql).
146 changes: 146 additions & 0 deletions end2end/tests-new/loopback-psql.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { spawn } from "child_process";
import { resolve } from "path";
import { before, test } from "node:test";
import { equal, match, doesNotMatch } from "node:assert";
import { getRandomPort } from "./utils/get-port.mjs";
import { timeout } from "./utils/timeout.mjs";
import { spawnSync } from "node:child_process";

const pathToAppDir = resolve(
import.meta.dirname,
"../../sample-apps/loopback4-psql"
);

const port = await getRandomPort();
const port2 = await getRandomPort();

before(async () => {
const { stderr } = spawnSync(`npm`, ["run", "build"], {
cwd: pathToAppDir,
});

if (stderr && stderr.toString().length > 0) {
throw new Error(`Failed to build: ${stderr.toString()}`);
}
});

test("it blocks SQL injection in blocking mode", async () => {
const server = spawn(`node`, ["dist/index.js"], {
cwd: pathToAppDir,
env: {
...process.env,
AIKIDO_DEBUG: "true",
AIKIDO_BLOCKING: "true",
PORT: port,
HOST: "127.0.0.1",
},
});

try {
server.on("error", (err) => {
throw err;
});

let stdout = "";
server.stdout.on("data", (data) => {
stdout += data.toString();
});

let stderr = "";
server.stderr.on("data", (data) => {
stderr += data.toString();
});

await timeout(3000);

const [sqlInjection, normalRequest, sqlInjectionPath, normalRequestPath] =
await Promise.all([
fetch(`http://127.0.0.1:${port}/insecure-sql`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "admin'); DROP TABLE users;-- -",
}),
signal: AbortSignal.timeout(5000),
}),
fetch(`http://127.0.0.1:${port}/insecure-sql`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin" }),
signal: AbortSignal.timeout(5000),
}),
fetch(
`http://127.0.0.1:${port}/insecure-sql/${encodeURIComponent("admin' OR '1'='1")}`,
{ signal: AbortSignal.timeout(5000) }
),
fetch(`http://127.0.0.1:${port}/insecure-sql/admin`, {
signal: AbortSignal.timeout(5000),
}),
]);

equal(sqlInjection.status, 500);
equal(normalRequest.status, 200);
equal(sqlInjectionPath.status, 500);
equal(normalRequestPath.status, 200);
match(stdout, /Starting agent/);

match(stderr, /Zen has blocked an SQL injection/);
} finally {
server.kill();
}
});

test("it does not block SQL injection in monitoring mode", async () => {
const server = spawn(`node`, ["dist/index.js"], {
cwd: pathToAppDir,
env: {
...process.env,
AIKIDO_DEBUG: "true",
AIKIDO_BLOCKING: "false",
PORT: port2,
HOST: "127.0.0.1",
},
});

try {
server.on("error", (err) => {
throw err;
});

let stdout = "";
server.stdout.on("data", (data) => {
stdout += data.toString();
});

let stderr = "";
server.stderr.on("data", (data) => {
stderr += data.toString();
});

await timeout(3000);

const [sqlInjection, sqlInjectionPath] = await Promise.all([
fetch(`http://127.0.0.1:${port2}/insecure-sql`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "admin'); DROP TABLE users;-- -",
}),
signal: AbortSignal.timeout(5000),
}),
fetch(
`http://127.0.0.1:${port2}/insecure-sql/${encodeURIComponent("admin' OR '1'='1")}`,
{ signal: AbortSignal.timeout(5000) }
),
]);

match(stdout, /Starting agent/);
doesNotMatch(await sqlInjection.text(), /Zen has blocked an SQL injection/);
doesNotMatch(
await sqlInjectionPath.text(),
/Zen has blocked an SQL injection/
);
} finally {
server.kill();
}
});
41 changes: 30 additions & 11 deletions library/agent/hooks/wrapExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export type InterceptorObject = {
// This will be used to collect stats
// For sources, this will often be undefined
kind: OperationKind | undefined;
// When true, if blocking is triggered and the last argument is a function,
// call it with the error instead of throwing synchronously.
// Needed for callback-based APIs (e.g. pg.Client.query(sql, params, cb))
// where a synchronous throw escapes Promise chains and crashes the process.
callbackOnBlock?: boolean;
};

/**
Expand Down Expand Up @@ -69,17 +74,31 @@ export function wrapExport(
}
}

inspectArgs.call(
// @ts-expect-error We don't now the type of this
this,
args,
interceptors.inspectArgs,
context,
agent,
pkgInfo,
methodName || "",
interceptors.kind
);
try {
Comment thread
timokoessler marked this conversation as resolved.
inspectArgs.call(
// @ts-expect-error We don't now the type of this
this,
args,
interceptors.inspectArgs,
context,
agent,
pkgInfo,
methodName || "",
interceptors.kind
);
} catch (blockError) {
if (interceptors.callbackOnBlock) {
// Find the last function argument and call it with the error.
for (let i = args.length - 1; i >= 0; i--) {
if (typeof args[i] === "function") {
const cb = args[i] as Function;
process.nextTick(() => cb(blockError));
return undefined;
}
}
}
throw blockError;
}
}

// Run modifyArgs interceptor if provided
Expand Down
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 { Postgresjs } from "../sinks/Postgresjs";
import { Fastify } from "../sources/Fastify";
import { Koa } from "../sources/Koa";
import { Restify } from "../sources/Restify";
import { LoopBack } from "../sources/LoopBack";
import { ClickHouse } from "../sinks/ClickHouse";
import { Prisma } from "../sinks/Prisma";
import { AwsSDKVersion2 } from "../sinks/AwsSDKVersion2";
Expand Down Expand Up @@ -175,6 +176,7 @@ export function getWrappers() {
new AwsSDKVersion2(),
new AiSDK(),
new GoogleGenAi(),
new LoopBack(),
];
}

Expand Down
Loading
Loading