Skip to content
DemoReal data from our own test repository, cernis-intelligence/agentcernis-webhook-test. A PR review that found a SQL injection replays on a loop, sped up. Nothing here is saved.Back to the siteGet started
Agent Cernis
criticalhigh confidenceCWE-89sql injectionOpen· stale

SQL injection in GET /users: unsanitized req.query.email interpolated into SQL and run via simple query protocol

server.js:6Path confirmed

Impact

Unauthenticated attackers can read the users table (id, email) and, via stacked statements, run arbitrary SQL within the DB role's privileges, including writes and DDL if permitted. Exploitable cross-site from a victim browser.

Existing mitigations: Immediate: restrict port 3000 to trusted networks or put it behind authentication, and run the app's DB role with SELECT on only the required columns. Durable: replace string interpolation with a parameterized query ($1 placeholder and values array), validate email format, and add a global error handler. Add a regression test and a SAST rule that flags template-literal or concatenated SQL.

Attack path

4 steps

Entry point: GET /users handler; req.query.email is attacker-controlled with no authentication required

  1. Step 1: server.js:6Entry
    GET /users handler; req.query.email is attacker-controlled with no authentication required
    5app.get("/health", (_req, res) => res.send("ok"));
    6app.get("/users", async (req, res) => {
    7 const { rows } = await pool.query(`SELECT id, email FROM users WHERE email = '${req.query.email}'`);
  2. Step 2: server.js:6
    Route handler receives attacker-controlled query string; no middleware, validation, or authentication exists (no app.use anywhere in the file)
    5app.get("/health", (_req, res) => res.send("ok"));
    6app.get("/users", async (req, res) => {
    7 const { rows } = await pool.query(`SELECT id, email FROM users WHERE email = '${req.query.email}'`);
  3. Step 3: server.js:7
    req.query.email is concatenated directly into a template-literal SQL string with no escaping or parameter binding
    6app.get("/users", async (req, res) => {
    7 const { rows } = await pool.query(`SELECT id, email FROM users WHERE email = '${req.query.email}'`);
    8 res.json(rows);
  4. Step 4: server.js:7Sink
    pool.query(string) with no values array sends the string via the simple query protocol, so stacked statements (';') execute
    6app.get("/users", async (req, res) => {
    7 const { rows } = await pool.query(`SELECT id, email FROM users WHERE email = '${req.query.email}'`);
    8 res.json(rows);

Preconditions

None beyond network reachability to HTTP port 3000 (app.listen binds all interfaces). No authentication, session, WAF, or input validation exists in server.js. Attacker sends GET /users with a crafted email parameter, including array form (email[]=) which also concatenates into an injectable string. Pool uses environment-provided Postgres credentials.

Proof of concept

Exploit narrative
# Boolean bypass: returns all rows
curl -G 'http://HOST:3000/users' --data-urlencode "email=' OR '1'='1"

# UNION read (adjust column count/types to the real SELECT list)
curl -G 'http://HOST:3000/users' --data-urlencode "email=' UNION SELECT id, current_user FROM users --"

# Stacked statement (simple query protocol): proves multi-statement execution; use a harmless statement in tests, e.g. SELECT pg_sleep(5)
curl -G 'http://HOST:3000/users' --data-urlencode "email='; SELECT pg_sleep(5); --"

Path confirmed via call_trace: Indexed trace GET /health → sql at server.js:7 (pool).

Fix

1. Change the handler to a parameterized query: const { rows } = await pool.query('SELECT id, email FROM users WHERE email = $1', [req.query.email]); Use the exact original SELECT list and table name from line 7. 2. Coerce and validate input: reject if typeof req.query.email !== 'string' (returns 400), so arrays and objects cannot reach the query. 3. Optionally validate email format and length before querying. 4. Do not build SQL with template literals or + anywhere in the codebase; keep all values in the params array. 5. Add a global Express error handler so DB errors are not reflected to clients. 6. Grant the app DB role least privilege (SELECT on users only; no DDL). 7. Re-run the PoC payloads and confirm they return 400 or empty results with no DB error text.

The fix must guarantee

  • User-controlled email values are never concatenated into SQL text; they are bound as parameters.
  • Non-string email inputs (arrays, objects) are rejected with 400 before any query runs.
  • Queries containing quotes, comment markers, or semicolons match literally and cannot alter query structure.
  • Database errors are not returned to the client.
  • The app DB role cannot execute DDL or write to the users table unless explicitly required.

It must not break

  • A valid email lookup returns the same row shape and status (200) as before the fix.
  • An email with no match returns the same empty-result behavior as before.
  • Missing email parameter behavior is unchanged or explicitly documented if changed.
  • Other routes and the pool configuration remain functional.
  • Application startup and port binding are unaffected.

Regression test

// Jest + supertest style sketch
const request = require('supertest');
const app = require('./server'); // export app without calling listen on import

describe('GET /users SQL injection regression', () => {
  const payloads = ["' OR '1'='1", "' UNION SELECT id, current_user FROM users --", "'; SELECT pg_sleep(5); --"];
  test.each(payloads)('payload %s is treated as a literal email', async (p) => {
    const start = Date.now();
    const res = await request(app).get('/users').query({ email: p });
    expect(res.status).toBe(200);
    expect(res.body).toEqual([]);
    expect(Date.now() - start).toBeLessThan(2000);
  });
  test('array email is rejected', async () => {
    const res = await request(app).get('/users?email[]=a&email[]=b');
    expect(res.status).toBe(400);
  });
  test('known email still returns a match', async () => {
    const res = await request(app).get('/users').query({ email: 'seeded@example.com' });
    expect(res.status).toBe(200);
    expect(res.body.length).toBe(1);
  });
});
Fix prompt for your coding agent

Paste into Claude Code, Cursor, or any coding agent. It carries the attack path, the guarantees, and the regression test.

Fix this critical-severity security issue: SQL injection in GET /users: unsanitized req.query.email interpolated into SQL and run via simple query protocol (CWE-89).

## What's wrong
Unauthenticated attackers can read the users table (id, email) and, via stacked statements, run arbitrary SQL within the DB role's privileges, including writes and DDL if permitted. Exploitable cross-site from a victim browser.

Something already softens the blow, but this still needs fixing: Immediate: restrict port 3000 to trusted networks or put it behind authentication, and run the app's DB role with SELECT on only the required columns. Durable: replace string interpolation with a parameterized query ($1 placeholder and values array), validate email format, and add a global error handler. Add a regression test and a SAST rule that flags template-literal or concatenated SQL.
Untrusted input enters through GET /users handler; req.query.email is attacker-controlled with no authentication required (`server.js:6`) and reaches `server.js:7`.
Who can pull this off: None beyond network reachability to HTTP port 3000 (app.listen binds all interfaces). No authentication, session, WAF, or input validation exists in server.js. Attacker sends GET /users with a crafted email parameter, including array form (email[]=) which also concatenates into an injectable string. Pool uses environment-provided Postgres credentials.

## Path from input to impact
1. `server.js:6` — GET /users handler; req.query.email is attacker-controlled with no authentication required

````
app.get("/health", (_req, res) => res.send("ok"));
app.get("/users", async (req, res) => {
  const { rows } = await pool.query(`SELECT id, email FROM users WHERE email = '${req.query.email}'`);
````

2. `server.js:6` — Route handler receives attacker-controlled query string; no middleware, validation, or authentication exists (no app.use anywhere in the file)

````
app.get("/health", (_req, res) => res.send("ok"));
app.get("/users", async (req, res) => {
  const { rows } = await pool.query(`SELECT id, email FROM users WHERE email = '${req.query.email}'`);
````

3. `server.js:7` — req.query.email is concatenated directly into a template-literal SQL string with no escaping or parameter binding

````
app.get("/users", async (req, res) => {
  const { rows } = await pool.query(`SELECT id, email FROM users WHERE email = '${req.query.email}'`);
  res.json(rows);
````

4. `server.js:7` — pool.query(string) with no values array sends the string via the simple query protocol, so stacked statements (';') execute

````
app.get("/users", async (req, res) => {
  const { rows } = await pool.query(`SELECT id, email FROM users WHERE email = '${req.query.email}'`);
  res.json(rows);
````

## Proof it's real
```
# Boolean bypass: returns all rows
curl -G 'http://HOST:3000/users' --data-urlencode "email=' OR '1'='1"

# UNION read (adjust column count/types to the real SELECT list)
curl -G 'http://HOST:3000/users' --data-urlencode "email=' UNION SELECT id, current_user FROM users --"

# Stacked statement (simple query protocol): proves multi-statement execution; use a harmless statement in tests, e.g. SELECT pg_sleep(5)
curl -G 'http://HOST:3000/users' --data-urlencode "email='; SELECT pg_sleep(5); --"
```

## How to close it
1. Change the handler to a parameterized query:
   const { rows } = await pool.query('SELECT id, email FROM users WHERE email = $1', [req.query.email]);
   Use the exact original SELECT list and table name from line 7.
2. Coerce and validate input: reject if typeof req.query.email !== 'string' (returns 400), so arrays and objects cannot reach the query.
3. Optionally validate email format and length before querying.
4. Do not build SQL with template literals or + anywhere in the codebase; keep all values in the params array.
5. Add a global Express error handler so DB errors are not reflected to clients.
6. Grant the app DB role least privilege (SELECT on users only; no DDL).
7. Re-run the PoC payloads and confirm they return 400 or empty results with no DB error text.

## The fix has to
- User-controlled email values are never concatenated into SQL text; they are bound as parameters.
- Non-string email inputs (arrays, objects) are rejected with 400 before any query runs.
- Queries containing quotes, comment markers, or semicolons match literally and cannot alter query structure.
- Database errors are not returned to the client.
- The app DB role cannot execute DDL or write to the users table unless explicitly required.

## Leave alone
- A valid email lookup returns the same row shape and status (200) as before the fix.
- An email with no match returns the same empty-result behavior as before.
- Missing email parameter behavior is unchanged or explicitly documented if changed.
- Other routes and the pool configuration remain functional.
- Application startup and port binding are unaffected.

## Prove it with this test
```
// Jest + supertest style sketch
const request = require('supertest');
const app = require('./server'); // export app without calling listen on import

describe('GET /users SQL injection regression', () => {
  const payloads = ["' OR '1'='1", "' UNION SELECT id, current_user FROM users --", "'; SELECT pg_sleep(5); --"];
  test.each(payloads)('payload %s is treated as a literal email', async (p) => {
    const start = Date.now();
    const res = await request(app).get('/users').query({ email: p });
    expect(res.status).toBe(200);
    expect(res.body).toEqual([]);
    expect(Date.now() - start).toBeLessThan(2000);
  });
  test('array email is rejected', async () => {
    const res = await request(app).get('/users?email[]=a&email[]=b');
    expect(res.status).toBe(400);
  });
  test('known email still returns a match', async () => {
    const res = await request(app).get('/users').query({ email: 'seeded@example.com' });
    expect(res.status).toBe(200);
    expect(res.body.length).toBe(1);
  });
});
```

Start by getting the regression test to fail against the code as it stands today. Then apply the narrowest change that shuts the attack path, and confirm that same test — plus the rest of the suite — now passes.