Skip to content
Draft
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
115 changes: 115 additions & 0 deletions end2end/tests-new/loopback-psql.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { spawn } from "child_process";
import { resolve } from "path";
import { test } from "node:test";
import { equal, 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/loopback4-psql"
);

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

test("it blocks SQL injection from request body 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] = 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),
}),
]);

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

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

test("it does not block SQL injection from request body 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 = await 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),
});

match(stdout, /Starting agent/);
doesNotMatch(await sqlInjection.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 {
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
1 change: 1 addition & 0 deletions library/sinks/Postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export class Postgres implements Wrapper {
wrapExport(exports.Client.prototype, "query", pkgInfo, {
kind: "sql_op",
inspectArgs: (args) => this.inspectQuery(args),
callbackOnBlock: true,
});
})
.addFileInstrumentation({
Expand Down
65 changes: 65 additions & 0 deletions library/sources/LoopBack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { getContext, updateContext } from "../agent/Context";
import { Hooks } from "../agent/hooks/Hooks";
import { wrapExport } from "../agent/hooks/wrapExport";
import { Wrapper } from "../agent/Wrapper";

export class LoopBack implements Wrapper {
private onBodyParsed(_args: unknown[], returnValue: unknown) {
if (!(returnValue instanceof Promise)) {
return returnValue;
}

returnValue
.then((requestBody: unknown) => {
const context = getContext();
if (!context) {
return;
}

if (
requestBody !== null &&
typeof requestBody === "object" &&
"value" in requestBody
) {
if (requestBody.value) {
updateContext(context, "body", requestBody.value);
}
}
})
.catch(() => {
// Ignore errors
});

return returnValue;
}

wrap(hooks: Hooks) {
hooks
.addPackage("@loopback/rest")
.withVersion("^14.0.0 || ^15.0.0")
.onRequire((exports, pkgInfo) => {
wrapExport(
exports.RequestBodyParser.prototype,
"loadRequestBodyIfNeeded",
pkgInfo,
{
kind: undefined,
modifyReturnValue: (args, returnValue) =>
this.onBodyParsed(args, returnValue),
}
);
})
.addFileInstrumentation({
path: "dist/body-parsers/body-parser.js",
functions: [
{
name: "loadRequestBodyIfNeeded",
nodeType: "MethodDefinition",
operationKind: undefined,
modifyReturnValue: (args, returnValue) =>
this.onBodyParsed(args, returnValue),
},
],
});
}
}
67 changes: 67 additions & 0 deletions sample-apps/loopback4-psql/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage

# nyc test coverage
.nyc_output

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# Typescript v1 declaration files
typings/

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env

# Transpiled JavaScript files from Typescript
/dist

# Cache used by TypeScript's incremental build
*.tsbuildinfo

# Emacs auto-save file
\#*#
6 changes: 6 additions & 0 deletions sample-apps/loopback4-psql/.yo-rc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"@loopback/cli": {
"packageManager": "npm",
"version": "7.0.9"
}
}
3 changes: 3 additions & 0 deletions sample-apps/loopback4-psql/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# loopback4-psql

This is a vulnerable sample application for LoopBack 4 using PostgreSQL as the database.
Loading
Loading