SQL injection in agent-written code

Query placeholders can hold a value but not a column name, so the sort and filter features agents are asked to add are where raw strings come back.

Aevral,

In plain words

Picture a form at a bank counter with a box that says "amount". A careful clerk reads whatever is in the box as a number. A careless clerk reads the whole form out loud to the vault as one instruction, so a customer who writes "50, and also open safe 12" in the amount box gets both. SQL injection is the careless clerk: the application glues text from a user into a database command, and the database cannot tell the user's text from the developer's.

The standard defence is old and reliable: parameterized queries, where the command and the user's values travel separately and the database treats the values as data only. Most code written today, by people or agents, uses them by default. The interesting question is where that default breaks.

Why agents write it: a placeholder cannot hold a column name

Placeholders bind values, not identifiers or keywords. The database accepts WHERE org_id = $1 with the id sent separately, but it will not accept ORDER BY $1 as a column name. So when an agent is asked to let users sort or filter by any column, the parameterized query it would normally write cannot express the feature, and the smallest diff that passes the test builds that part of the query as a string.

Two habits make it likelier. Agents copy the nearest example in the codebase, so one raw query becomes the template for the next. And the test the agent writes passes a normal column name, which works, so the suite is green and the diff looks careful: the tenant id is still a placeholder, only the sort key is not.

The shapes it takes in a pull request

  • An identifier built from input

    A sort column, sort direction, table name or field list taken from the request and interpolated into the SQL text, because no placeholder can carry it.

  • The ORM escape hatch

    A raw-query call that accepts a finished string, such as Prisma's $queryRawUnsafe, knex.raw or sequelize.query given a template literal, or SQLAlchemy text() built from an f-string.

  • A search box glued into LIKE

    A search feature that writes the user's term straight into a LIKE clause instead of binding the whole pattern as one parameter.

  • An IN list joined by hand

    A list of ids from the request joined with commas and pasted into IN ( ), because the driver's array binding was not obvious.

Worked example, an illustration

A sortable invoice list that interpolates the sort key.

The request: let users sort the invoice table by any column. The ORM's query builder took a fixed order, so the agent switched to a raw query and put the sort parameter in the ORDER BY. The org id stayed a parameter, so the diff reads as careful.

src/routes/invoices.ts+6 -4
app.get("/api/invoices", requireUser, async (req, res) => {  const invoices = await prisma.invoice.findMany({    where: { orgId: req.session.orgId },    orderBy: { createdAt: "desc" },  });  const sort = String(req.query.sort ?? "created_at");  const invoices = await prisma.$queryRawUnsafe(    `SELECT * FROM invoices WHERE org_id = $1 ORDER BY ${sort} DESC`,    req.session.orgId,  );  res.json(invoices);});
Illustration written for this page, not from a customer repository. The attacker never needs a second statement. A sort value such as a CASE expression wrapped around a subquery changes the order of the rows they are allowed to see depending on a yes-or-no question about data they are not allowed to see, such as another organization's records. Asked often enough, those yes-or-no answers spell the data out.

What an Aevral finding on it looks like

On a reviewed pull request, a finding is an inline comment pinned to the added line, next to an advisory Check that never blocks the pull request. This is an illustration of that shape for the diff above.

AevralIllustration

src/routes/invoices.ts, added line: `SELECT * FROM invoices WHERE org_id = $1 ORDER BY ${sort} DESC`

req.query.sort is interpolated into the ORDER BY of a raw query. Placeholders cannot bind identifiers, so the value reaches the SQL text as written, and a subquery in the sort parameter can read rows outside the caller's organization.

Missing control: An allowlist that maps the sort parameter to a fixed set of column names before it reaches the query.

Fix with your agent
In src/routes/invoices.ts, the added $queryRawUnsafe call builds ORDER BY from req.query.sort. Replace it with prisma.invoice.findMany, keep where: { orgId: req.session.orgId }, and choose orderBy from a fixed map of allowed sort keys, falling back to createdAt. Add a test that sends sort=(SELECT 1) and expects the default order.

Check this finding against the code before changing anything. Make the smallest change that restores the control, add a test for it, and stop before commit or push.

A finding is a lead with evidence, not a confirmation. You or your agent check it against the code; Aevral never applies or merges a change. See handing a finding to your coding agent.

How to fix it

The fix is not escaping the sort value. It is never letting the value be SQL at all: the request picks a key, and the code maps that key to a column name it wrote itself. Anything not in the map falls back to the default. With the column chosen from constants, the ORM's own builder can express the query again and the raw call goes away.

src/routes/invoices.ts (safe version)
const SORTABLE = new Map<string, "createdAt" | "amount" | "dueDate">([
  ["created_at", "createdAt"],
  ["amount", "amount"],
  ["due_date", "dueDate"],
]);

app.get("/api/invoices", requireUser, async (req, res) => {
  const column = SORTABLE.get(String(req.query.sort)) ?? "createdAt";
  const invoices = await prisma.invoice.findMany({
    where: { orgId: req.session.orgId },
    orderBy: { [column]: "desc" },
  });
  res.json(invoices);
});

What this page does not claim

  • Aevral's PR security review, live on install, looks for SQL injection on the diff of the pull requests it reviews, as one class among several. Looking for a class does not mean finding each instance of it: a review can miss one, and a quiet review is not proof that the code is clean.
  • The whole-repo scan reads authorization, IDOR, and business-logic access control only. It does not read SQL injection; on this class, the pull request is where Aevral looks.
  • No class-specific performance evidence for SQL injection is published. The receipts page reports named runs of the whole PR review eval suite, not a result for this class.
  • The diff, the finding and the fix on this page are illustrations written for this page. They are not output from a customer repository.
  • This page covers SQL built as a string in application code. It does not cover stored procedures that build dynamic SQL inside the database, or NoSQL query injection, which have their own shapes.

Sources

CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'); OWASP SQL Injection Prevention Cheat Sheet; OWASP Query Parameterization Cheat Sheet; Prisma documentation: raw queries.

Read next

How IDOR happens in multi-tenant code; Command injection in agent-written code; PR security review.

Other classes


Security review, handled.

One GitHub App. Reviews start when the App is installed. A Check on each pull request it reviews, with inline comments when there is a grounded finding. Free tier live: public repos free, 500/org/month, 25 private reviews a month. Paid plans are live in the console.

Install the GitHub AppLog in

For professional use. By installing, you confirm you can act for the account or organization that owns it, and you accept the Terms and DPA on its behalf.