Skip to content
Merged
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
9 changes: 9 additions & 0 deletions library/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Object.hasOwn is available since Node 16.
// See https://github.com/microsoft/TypeScript/issues/44253
interface ObjectConstructor {
hasOwn<K extends PropertyKey, T extends object>(
o: T,
v: K
): K extends keyof T ? true : false;
hasOwn<T extends object>(o: T, v: keyof T): true;
}
112 changes: 106 additions & 6 deletions library/sinks/BetterSQLite3.idor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,115 @@ t.test("IDOR protection for BetterSQLite3", async (t) => {
excludedTables: ["migrations"],
});

await t.test("allows query with tenant filter", async () => {
const rows = runWithContext(context, () => {
return db
.prepare("SELECT petname FROM cats_idor_sqlite WHERE tenant_id = ?")
.all(["org_123"]);
await t.test(
"allows query with tenant filter using array params",
async () => {
const rows = runWithContext(context, () => {
return db
.prepare("SELECT petname FROM cats_idor_sqlite WHERE tenant_id = ?")
.all(["org_123"]);
});
t.same(rows, []);
}
);

await t.test(
"allows query with tenant filter using individual params",
async () => {
const rows = runWithContext(context, () => {
return db
.prepare("SELECT petname FROM cats_idor_sqlite WHERE tenant_id = ?")
.all("org_123");
});
t.same(rows, []);
}
);

await t.test(
"blocks query with wrong tenant ID using individual params",
async () => {
const error = t.throws(() => {
runWithContext(context, () => {
return db
.prepare(
"SELECT petname FROM cats_idor_sqlite WHERE tenant_id = ?"
)
.all("org_456");
});
});

t.ok(error instanceof Error);
if (error instanceof Error) {
t.match(
error.message,
"filters 'tenant_id' with value 'org_456' but tenant ID is 'org_123'"
);
}
}
);

await t.test(
"allows query with tenant filter using :name param",
async () => {
const rows = runWithContext(context, () => {
return db
.prepare(
"SELECT petname FROM cats_idor_sqlite WHERE tenant_id = :tenant_id"
)
.all({ tenant_id: "org_123" });
});
t.same(rows, []);
}
);

await t.test("blocks query with wrong :name param", async () => {
const error = t.throws(() => {
runWithContext(context, () => {
return db
.prepare(
"SELECT petname FROM cats_idor_sqlite WHERE tenant_id = :tenant_id"
)
.all({ tenant_id: "org_456" });
});
});
t.same(rows, []);

t.ok(error instanceof Error);
if (error instanceof Error) {
t.match(
error.message,
"filters 'tenant_id' with value 'org_456' but tenant ID is 'org_123'"
);
}
});

await t.test(
"allows query with tenant filter using @name param",
async () => {
const rows = runWithContext(context, () => {
return db
.prepare(
"SELECT petname FROM cats_idor_sqlite WHERE tenant_id = @tenant_id"
)
.all({ tenant_id: "org_123" });
});
t.same(rows, []);
}
);

await t.test(
"allows query with tenant filter using $name param",
async () => {
const rows = runWithContext(context, () => {
return db
.prepare(
"SELECT petname FROM cats_idor_sqlite WHERE tenant_id = $tenant_id"
)
.all({ tenant_id: "org_123" });
});
t.same(rows, []);
}
);

await t.test("blocks query without tenant filter", async () => {
const error = t.throws(() => {
runWithContext(context, () => {
Expand Down
26 changes: 24 additions & 2 deletions library/sinks/BetterSQLite3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PackageFunctionInstrumentationInstruction } from "../agent/hooks/i
import { InterceptorResult } from "../agent/hooks/InterceptorResult";
import { wrapExport } from "../agent/hooks/wrapExport";
import { Wrapper } from "../agent/Wrapper";
import { isPlainObject } from "../helpers/isPlainObject";
import { checkContextForIdor } from "../vulnerabilities/idor/checkContextForIdor";
import { checkContextForPathTraversal } from "../vulnerabilities/path-traversal/checkContextForPathTraversal";
import { checkContextForSqlInjection } from "../vulnerabilities/sql-injection/checkContextForSqlInjection";
Expand Down Expand Up @@ -46,8 +47,12 @@ export class BetterSQLite3 implements Wrapper {
typeof statement.source === "string"
) {
const sql = statement.source;
const params =
args.length > 0 && Array.isArray(args[0]) ? args[0] : undefined;
// better-sqlite3 accepts params as an array: .all([v1, v2])
// or as individual arguments: .all(v1, v2)
let params: unknown[] | undefined;
if (args.length > 0) {
params = Array.isArray(args[0]) ? args[0] : args;
}

return this.inspectSQLCommand(sql, context, operation, params);
}
Expand Down Expand Up @@ -84,12 +89,29 @@ export class BetterSQLite3 implements Wrapper {
placeholderNumber: number | undefined,
params: unknown[] | undefined
): unknown {
// ? placeholder (positional)
if (placeholder === "?" && placeholderNumber !== undefined && params) {
if (placeholderNumber < params.length) {
return params[placeholderNumber];
}
}

// Named params (:name, @name, $name) — better-sqlite3 accepts an object
if (
params &&
params.length === 1 &&
isPlainObject(params[0]) &&
placeholder.length > 1
) {
const prefix = placeholder[0];
if (prefix === ":" || prefix === "@" || prefix === "$") {
const key = placeholder.substring(1);
if (Object.hasOwn(params[0], key)) {
return params[0][key];
}
}
}

return undefined;
}

Expand Down
5 changes: 3 additions & 2 deletions library/sinks/Postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ export class Postgres implements Wrapper {
params: unknown[] | undefined
): unknown {
// Postgres uses $1, $2, etc. (1-based)
if (placeholder.startsWith("$") && params) {
const index = parseInt(placeholder.substring(1), 10) - 1;
const match = placeholder.match(/^\$(\d+)$/);
if (match && params) {
const index = parseInt(match[1], 10) - 1;
if (index >= 0 && index < params.length) {
return params[index];
}
Expand Down
4 changes: 4 additions & 0 deletions library/vulnerabilities/idor/IdorAnalysisResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ export type FilterColumn = {
value: string;
/** 0-based position of a `?` placeholder in the query (MySQL-style) */
placeholder_number?: number;
/** Whether the value is a placeholder (e.g. `?`, `$1`) rather than a literal */
is_placeholder: boolean;
};

export type InsertColumn = {
column: string;
value: string;
/** 0-based position of a `?` placeholder in the query (MySQL-style) */
placeholder_number?: number;
/** Whether the value is a placeholder (e.g. `?`, `$1`) rather than a literal */
is_placeholder: boolean;
};

export type SqlQueryResult = {
Expand Down
Loading