SQL Injection Defense — Parameterized Queries
Defends against¶
SQL Injection (SQLi) — catalog search (q) on Marketplace and app sign-in (POST /api/auth/login).
Prerequisites¶
- Local INSECURE-threat-intel-app stack (Docker setup).
- You have already worked through the SQLi attack guide and can trigger the "Found SQL Injection" flag with
' OR '1'='1andanalyst'--. - Editor open on
exercise-files/web-attacks-101/INSECURE-threat-intel-app.
Principle¶
Never build a SQL statement by concatenating user input. Send the query text and the data on separate channels: a query template with placeholders, plus an array of values. The database driver then treats every value as data — never as executable SQL — so a quote, --, or OR 1=1 is matched literally instead of changing the query's structure.
This is parameterization (also called prepared statements or bind variables). It is the only complete fix; input filtering, escaping, and WAFs are compensating controls, not replacements.
Step 1 — Locate the vulnerable code¶
Both sinks live in src/lib/lab/sqli.ts. They interpolate ${...} straight into the SQL string:
``10:31:exercise-files/web-attacks-101/INSECURE-threat-intel-app/src/lib/lab/sqli.ts
/** INTENTIONALLY VULNERABLE — string concatenation SQLi for WA101 lab only. */
export async function unsafeProductSearch(query: string): Promise<LabProduct[]> {
const q = query.trim();
// VULNERABLE:qis interpolated straight into the SQL via ${q} on the WHERE lines
// below, so input like ' OR '1'='1 rewrites the query's logic.
const sql =SELECT id, name, description, price::text AS price
FROM dashboard.lab_products
WHERE name ILIKE '%\({q}%'
OR description ILIKE '%\)%'`;
const result = await pool.query(sql);
return result.rows as LabProduct[];
}
/* INTENTIONALLY VULNERABLE — login bypass via SQLi on lab-only accounts table query. /
export async function unsafeLabLogin(
username: string,
password: string
): Promise<{ username: string } | null> {
// VULNERABLE: username AND password are interpolated into the query (${username},
// ${password}), so analyst'-- comments out the password check entirely.
const sql = SELECT username FROM dashboard.lab_profiles
WHERE username = '${username}' AND password = '${password}' LIMIT 1;
const result = await pool.query(sql);
if (result.rowCount === 0) return null;
return { username: result.rows[0].username as string };
}
The rest of the app already does this correctly — for example `src/lib/db.ts` passes `[panelName]` as the second argument to `pool.query`. We are bringing these two functions up to the same standard.
## Step 2 — Parameterize the catalog search
The `pg` driver supports `$1`, `$2`, … placeholders. Move the user value out of the string and into the values array. The `%…%` wildcards belong to the **value**, not the SQL text:
```ts
export async function safeProductSearch(query: string): Promise<LabProduct[]> {
const q = query.trim();
const like = `%${q}%`;
const sql = `SELECT id, name, description, price::text AS price
FROM dashboard.lab_products
WHERE name ILIKE $1
OR description ILIKE $1`;
const result = await pool.query(sql, [like]);
return result.rows as LabProduct[];
}
Key points:
$1is reused for both columns — one value, referenced twice.- The
%characters are concatenated into the data, so they still work as wildcards, but a'typed by the user is now just a character to search for. - No escaping needed — the driver handles it over the wire protocol.
Step 3 — Fix the login and stop storing plaintext passwords¶
The login query has the same flaw, but it also reveals a second problem: comparing a plaintext password column. Parameterize the lookup, then verify a password hash in application code rather than in SQL:
import { pool } from "../db";
import { timingSafeEqual } from "node:crypto";
// In a real app, verify against a slow hash (bcrypt/argon2), e.g. `import bcrypt from "bcrypt"`.
export async function safeLabLogin(
username: string,
password: string
): Promise<{ username: string } | null> {
const result = await pool.query(
`SELECT username, password_hash FROM dashboard.lab_profiles WHERE username = $1 LIMIT 1`,
[username]
);
if (result.rowCount === 0) return null;
const { username: foundUser, password_hash } = result.rows[0];
// const ok = await bcrypt.compare(password, password_hash);
const ok = verifyHash(password, password_hash); // constant-time compare
return ok ? { username: foundUser as string } : null;
}
Why the password check moves out of SQL:
WHERE username = $1 AND password = $2would still work and be injection-safe, but it forces an exact plaintext match in the database. Authentication logic should compare a salted hash with a constant-time function so the database never holds recoverable passwords.- Looking the user up by
usernameonly (then verifying the secret in code) also gives uniform failure behavior, which limits username-enumeration via timing.
Lab seed data
The seed (docker/init/03-lab-vulnerabilities.sql) stores a plaintext password column to make the attack demonstrable. A production-style fix also changes the schema to password_hash and seeds a hash. For the WA101 exercise, parameterizing the query is the core lesson; the hashing note shows where real apps go further.
Step 4 — Point the app at the safe functions¶
Update Marketplace (src/pages/marketplace.astro) to use safeProductSearch, and sign-in (src/pages/api/auth/login.ts) to use safeLabLogin:
// marketplace.astro
import { safeProductSearch } from '../lib/lab/sqli';
// ...
products = await safeProductSearch(q);
// api/auth/login.ts
import { safeLabLogin } from '../../../lib/lab/sqli';
// ...
const user = await safeLabLogin(username, password);
Step 5 — Verify the fix¶
Rebuild and retest:
| Test | Before | After (fixed) |
|---|---|---|
Search Threat |
Matching products | Same matching products (functionality intact) |
Search ' OR '1'='1 |
All products returned | Zero/normal results — treated as a literal search string |
Sign-in analyst'-- |
Dashboard redirect (login bypass) | 302 to /login?error=1 |
The "Found SQL Injection" lab flag (detectSqliSearch / detectSqliLogin in src/lib/lab/flags.ts) should no longer fire, because the tautology no longer changes the query result. That flag flipping off is your fastest regression check.
In Burp Repeater, resend your saved GET /marketplace?q=' OR '1'='1 and POST /api/auth/login requests — confirm they now behave like ordinary invalid input.
Defense in depth¶
Parameterization fixes the bug. These layers reduce blast radius if a new sink slips in:
- Least-privilege DB account. The app role should not own tables or hold
DROP/CREATE. In the production topology this maps to thewebapp_rwuser, notprojectx_dbadmin. - Allowlist non-data fields. Column and table names,
ORDER BYkeys, andASC/DESCcannot be parameterized — map user choices through a fixed allowlist ({ name: "name", price: "price" }[sort]). - ORMs / query builders (Drizzle, Prisma, Knex) parameterize by default — but
db.raw("... " + input)re-opens the hole. Treat any raw fragment as a review red flag. - Input validation as a secondary check (length, expected charset) for defense in depth and better UX — never as the primary control.
- A WAF (e.g. ModSecurity + OWASP CRS) can catch generic payloads, but attackers bypass signatures; it buys time, it does not fix the code.
Verification checklist¶
- [ ] No
${...}or string+building SQL anywhere insrc/lib/. - [ ] Every
pool.querycall that includes user input passes a values array. - [ ] Tautology and comment payloads return normal results, not full tables / logins.
- [ ] The "Found SQL Injection" flag no longer appears.