Skip to content
Open
Show file tree
Hide file tree
Changes from all 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).
144 changes: 144 additions & 0 deletions end2end/tests-new/loopback-psql.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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 () => {
spawnSync(`npm`, ["run", "migrate"], {
cwd: pathToAppDir,
stdio: "inherit",
stderr: "inherit",
});
});

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();
}
});
60 changes: 59 additions & 1 deletion library/agent/hooks/wrapExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import * as t from "tap";
import { wrapExport } from "./wrapExport";
import { LoggerForTesting } from "../logger/LoggerForTesting";
import { Token } from "../api/Token";
import { bindContext } from "../Context";
import { bindContext, runWithContext } from "../Context";
import { createTestAgent } from "../../helpers/createTestAgent";

const logger = new LoggerForTesting();

createTestAgent({
logger,
token: new Token("123"),
block: true,
});

t.test("Inspect args", async (t) => {
Expand Down Expand Up @@ -205,3 +206,60 @@ t.test("Wrap default export", async (t) => {
t.same(patched("input"), "input");
t.ok(executedCallback);
});

t.test("it calls callback on block if callbackOnBlock is set", async (t) => {
const toWrap = {
test(input: string, callback: (err: Error | null) => void) {
callback(null);
},
};

wrapExport(
toWrap,
"test",
{ name: "test", type: "external" },
{
kind: "outgoing_http_op",
inspectArgs: () => {
return {
operation: "http.get",
kind: "ssrf",
source: "body",
pathsToPayload: [""],
metadata: {},
payload: "foo",
};
},
callbackOnBlock: true,
}
);

await runWithContext(
{
remoteAddress: "::1",
method: "POST",
url: "http://localhost:4000",
query: {},
body: undefined,
headers: {},
cookies: {},
routeParams: {},
source: "express",
route: "/posts/:id",
},
async () => {
await new Promise((resolve) => {
toWrap.test("input", (err) => {
t.ok(err instanceof Error);
if (err instanceof Error) {
t.match(
err.message,
"Zen has blocked a server-side request forgery: http.get(...)"
);
}
resolve(null);
});
});
}
);
});
40 changes: 29 additions & 11 deletions library/agent/hooks/wrapExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { InterceptorResult } from "./InterceptorResult";
import type { PartialWrapPackageInfo } from "./WrapPackageInfo";
import { wrapDefaultOrNamed } from "./wrapDefaultOrNamed";
import { onInspectionInterceptorResult } from "./onInspectionInterceptorResult";
import { getCallbackFunctionFromArgs } from "../../helpers/getCallbackFunctionFromArgs";

export type InspectArgsInterceptor = (
args: unknown[],
Expand Down Expand Up @@ -34,6 +35,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 +75,29 @@ 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 (error) {
if (interceptors.callbackOnBlock) {
// Find the last function argument and call it with the error.
const cbFunc = getCallbackFunctionFromArgs(args);
if (cbFunc) {
process.nextTick(() => cbFunc(error));
return undefined;
}
}
throw error;
}
}

// Run modifyArgs interceptor if provided
Expand Down
Loading
Loading