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
39 changes: 39 additions & 0 deletions src/lib/db/secure-query/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# secure-query

Parameterized query helpers for the local SQLite layer.

## Why this exists

Several historical call sites built SQL by concatenating user input directly
into the statement text, e.g.:

```ts
db.prepare(`SELECT * FROM users WHERE name LIKE '%${term}%'`).all();
```

This is a classic **SQL injection** vulnerability: an input like
`'; DROP TABLE users; --` changes the meaning of the query rather than acting
as data.

## The fix

Always bind dynamic values as parameters (`?`) and never interpolate them into
the SQL text.

- `QueryBuilder` — fluent builder that emits `?` placeholders for every value
and validates table/column identifiers against a strict allow-list.
- `UserRepository` — data-access layer that uses bound parameters for all
lookups, searches, and inserts.
- `sanitize` — `escapeLike`, `stripControlChars`, and `safeIdentifier` for the
rare cases where a value cannot be bound (LIKE fragments, dynamic
identifiers).

## Rules

1. Prefer bound parameters (`?`) for **every** dynamic value.
2. Identifiers (table/column names) cannot be bound — validate them with
`safeIdentifier` / `assertIdentifier`.
3. Escape LIKE wildcards with `escapeLike` and pair with `ESCAPE '\'`.
4. Never build SQL with template-literal interpolation of user input.

See `test/lib/db/secure-query.test.ts` for regression coverage.
79 changes: 79 additions & 0 deletions src/lib/db/secure-query/query-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Database, Statement } from "node:sqlite";

/**
* A minimal, parameterized query builder.
*
* Historically several call sites built SQL by string concatenation, e.g.
*
* db.exec(`SELECT * FROM users WHERE name = '${name}'`)
*
* which is vulnerable to SQL injection. This builder forces every dynamic
* value through a bound parameter so user input can never alter the query
* structure.
*/
export interface BoundQuery {
readonly sql: string;
readonly params: readonly unknown[];
}

/** Identifiers (table/column names) cannot be parameterized, so they are
* validated against a strict allow-list pattern instead. */
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;

export function assertIdentifier(name: string): string {
if (!IDENTIFIER_RE.test(name)) {
throw new Error(`Unsafe SQL identifier: ${JSON.stringify(name)}`);
}
return name;
}

export class QueryBuilder {
#table: string;
#wheres: { column: string; value: unknown }[] = [];
#limit: number | null = null;

constructor(table: string) {
this.#table = assertIdentifier(table);
}

static from(table: string): QueryBuilder {
return new QueryBuilder(table);
}

/** Add an equality predicate. The value is always bound, never inlined. */
where(column: string, value: unknown): this {
this.#wheres.push({ column: assertIdentifier(column), value });
return this;
}

limit(n: number): this {
if (!Number.isInteger(n) || n < 0) {
throw new Error(`Invalid LIMIT: ${n}`);
}
this.#limit = n;
return this;
}

build(): BoundQuery {
const params: unknown[] = [];
let sql = `SELECT * FROM ${this.#table}`;
if (this.#wheres.length > 0) {
const clauses = this.#wheres.map(({ column }) => {
return `${column} = ?`;
});
sql += ` WHERE ${clauses.join(" AND ")}`;
for (const { value } of this.#wheres) {
params.push(value);
}
}
if (this.#limit !== null) {
sql += ` LIMIT ?`;
params.push(this.#limit);
}
return { sql, params };
}

Check warning on line 75 in src/lib/db/secure-query/query-builder.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

`QueryBuilder.prepare()` returns a Statement with unbound placeholders, losing its params

`prepare()` calls `this.build().sql` but discards the accompanying `params` array, returning a `Statement` that still contains unbound `?` placeholders. Callers who use this method instead of `build()` have no way to retrieve the parameters to bind at execution time, so the statement will either error at runtime or execute with missing/incorrect bindings. This is a latent correctness and API-design defect — not a SQL-injection regression, since the emitted SQL only ever contains `?` placeholders and never interpolated user input.
prepare(db: Database): Statement {
return db.prepare(this.build().sql);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Prepare drops bound parameters

Medium Severity

QueryBuilder.prepare calls build() to produce SQL with ? placeholders, but only passes build().sql to db.prepare and discards build().params. Any execution through this helper runs an unprepared statement unless the caller separately re-builds and binds parameters, which the method does not expose.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f75635d. Configure here.

Comment on lines +76 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The QueryBuilder.prepare() method discards query parameters, causing prepared statements to execute with NULL for all placeholders and return incorrect results.
Severity: HIGH

Suggested Fix

Update the prepare method in query-builder.ts to pass the parameters to the database. Change db.prepare(this.build().sql) to db.prepare(this.build().sql).bind(...this.build().params) or a similar method that correctly binds the parameters from the build() result.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/lib/db/secure-query/query-builder.ts#L76-L78

Potential issue: The `QueryBuilder.prepare()` method builds a SQL string with `?`
placeholders but silently discards the corresponding `params` array. It returns a
`Statement` with no bound parameters. Any caller using this method will execute the
query with `NULL` for every placeholder, which will produce incorrect or empty results
instead of the intended parameterized query. For example, a query like
`QueryBuilder.from("users").where("id", 42).prepare(db).get()` would execute `SELECT *
FROM users WHERE id = ?` with the placeholder incorrectly treated as `NULL`.

Did we get this right? 👍 / 👎 to inform future reviews.

}
38 changes: 38 additions & 0 deletions src/lib/db/secure-query/sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Helpers for safely handling values that must appear inside SQL.
*
* The golden rule: prefer bound parameters (`?`) over any of these helpers.
* They exist only for the rare cases where a value genuinely cannot be bound
* (e.g. a LIKE pattern fragment or a dynamic identifier).
*/

/**
* Escape the special characters used by SQLite's `LIKE` operator so that
* user-supplied search text is treated literally instead of as wildcards.
*
* Use together with `ESCAPE '\\'`, e.g.
*
* db.prepare("SELECT * FROM t WHERE name LIKE ? ESCAPE '\\'")
* .all(`%${escapeLike(input)}%`)
*/
export function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
}

/**
* Reject obviously dangerous control characters that have no business in a
* search term. This is a defense-in-depth measure on top of parameterization,
* not a replacement for it.
*/
export function stripControlChars(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\x00-\x1f]/g, "");
}

/** Validate a column/table identifier against a strict allow-list. */
export function safeIdentifier(name: string): string {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new Error(`Refusing to interpolate unsafe identifier: ${name}`);
}
return name;
}
62 changes: 62 additions & 0 deletions src/lib/db/secure-query/user-repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Database } from "node:sqlite";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The UserRepository class calls a .prepare() method that does not exist on the project's standard Database wrapper, which will cause a runtime crash.
Severity: CRITICAL

Suggested Fix

Refactor UserRepository to use the available methods on the project's Database wrapper from src/lib/db/sqlite.ts, such as .query(). Alternatively, add a .prepare() method to the project's Database wrapper to align its interface with the raw node:sqlite database object.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/lib/db/secure-query/user-repository.ts#L1

Potential issue: The `UserRepository` class imports the `Database` type from
`node:sqlite` and its methods call `this.#db.prepare(...)`. However, the project's
actual `Database` wrapper class, defined in `src/lib/db/sqlite.ts` and used throughout
the application, does not have a `.prepare()` method. If an instance of the project's
`Database` wrapper is passed to `UserRepository`, all of its methods (`findById`,
`searchByName`, `insert`) will throw a `TypeError: this.#db.prepare is not a function`
at runtime.

Did we get this right? 👍 / 👎 to inform future reviews.


import { QueryBuilder } from "./query-builder";
import { escapeLike, stripControlChars } from "./sanitize";

/**
* Data-access layer for the local `users` cache table.
*
* SECURITY NOTE — SQL injection fix:
* The previous implementation of `searchByName` interpolated the caller's
* input directly into the SQL text:
*
* const rows = db
* .prepare(`SELECT * FROM users WHERE name LIKE '%${term}%'`)
* .all();
*
* A term such as `' OR '1'='1` (or worse, `'; DROP TABLE users; --`) would
* change the meaning of the statement. All queries below now use bound
* parameters so the input is treated strictly as data.
*/
export interface UserRow {
id: number;
name: string;
email: string;
}

export class UserRepository {
#db: Database;

constructor(db: Database) {
this.#db = db;
}

/** Look up a single user by id using a bound parameter. */
findById(id: number): UserRow | undefined {
const { sql, params } = QueryBuilder.from("users")
.where("id", id)
.limit(1)
.build();

Check warning on line 39 in src/lib/db/secure-query/user-repository.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[QZ4-4HH] `QueryBuilder.prepare()` returns a Statement with unbound placeholders, losing its params (additional location)

`prepare()` calls `this.build().sql` but discards the accompanying `params` array, returning a `Statement` that still contains unbound `?` placeholders. Callers who use this method instead of `build()` have no way to retrieve the parameters to bind at execution time, so the statement will either error at runtime or execute with missing/incorrect bindings. This is a latent correctness and API-design defect — not a SQL-injection regression, since the emitted SQL only ever contains `?` placeholders and never interpolated user input.
return this.#db.prepare(sql).get(...params) as UserRow | undefined;
}

/**
* Case-insensitive substring search over the user name.
*
* The search term is bound as a parameter and the LIKE wildcards are
* escaped, so the caller can never inject SQL or unintended wildcards.
*/
searchByName(term: string): UserRow[] {
const needle = `%${escapeLike(stripControlChars(term))}%`;
return this.#db
.prepare("SELECT * FROM users WHERE name LIKE ? ESCAPE '\\' ORDER BY name")
.all(needle) as UserRow[];
}

/** Insert a user. Every column value is bound, never concatenated. */
insert(user: Omit<UserRow, "id">): void {
this.#db
.prepare("INSERT INTO users (name, email) VALUES (?, ?)")
.run(user.name, user.email);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Repository uses wrong DB API

High Severity

UserRepository calls this.#db.prepare(...) and is typed against node:sqlite's Database, but every other SQLite access in this repo goes through src/lib/db/sqlite.ts, whose Database wrapper only exposes query, not prepare. Passing getRawDatabase() or getDatabase() here would throw at runtime because prepare is not on that wrapper.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f75635d. Configure here.

}
}
68 changes: 68 additions & 0 deletions test/lib/db/secure-query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Secure Query Tests
*
* Regression tests for the SQL injection fix in the secure-query helpers.
* These assert that dynamic values are always parameterized and that
* malicious input cannot alter the structure of a generated statement.
*/

import { describe, expect, test } from "vitest";
import {
assertIdentifier,
QueryBuilder,
} from "../../../src/lib/db/secure-query/query-builder.js";
import {
escapeLike,
safeIdentifier,
stripControlChars,
} from "../../../src/lib/db/secure-query/sanitize.js";

describe("secure-query/query-builder", () => {
test("binds where values instead of inlining them", () => {
const { sql, params } = QueryBuilder.from("users")
.where("name", "' OR '1'='1")
.build();
expect(sql).toBe("SELECT * FROM users WHERE name = ?");
expect(params).toEqual(["' OR '1'='1"]);
});

test("injection payload is data, never SQL", () => {
const payload = "'; DROP TABLE users; --";
const { sql, params } = QueryBuilder.from("users")
.where("email", payload)
.build();
// The payload must not appear anywhere in the SQL text.
expect(sql).not.toContain("DROP TABLE");
expect(sql).toContain("email = ?");
expect(params).toEqual([payload]);
});

test("limit is bound and validated", () => {
const { sql, params } = QueryBuilder.from("users").limit(5).build();
expect(sql).toBe("SELECT * FROM users LIMIT ?");
expect(params).toEqual([5]);
expect(() => QueryBuilder.from("users").limit(-1)).toThrow();
});

test("rejects unsafe identifiers", () => {
expect(() => QueryBuilder.from("users; DROP TABLE users")).toThrow();
expect(() => QueryBuilder.from("users").where("1=1", 1)).toThrow();
expect(assertIdentifier("user_name")).toBe("user_name");
});
});

describe("secure-query/sanitize", () => {
test("escapeLike neutralizes wildcards", () => {
expect(escapeLike("100%_off")).toBe("100\\%\\_off");
expect(escapeLike("a\\b")).toBe("a\\\\b");
});

test("stripControlChars removes control bytes", () => {
expect(stripControlChars("ab cd")).toBe("abcd");
});

test("safeIdentifier guards interpolation", () => {
expect(safeIdentifier("created_at")).toBe("created_at");
expect(() => safeIdentifier("created_at; --")).toThrow();
});
});
Loading