← 全部新闻
security5 分钟阅读

SQL injection in AI-generated ORM code: what the model gets wrong

作者:Seaworthy · 2026年3月3日

A dark server room filled with rows of illuminated servers

Photo by Mohammad Rahmani on Unsplash

Why the model reaches for string interpolation

ORMs were invented specifically to stop developers from writing raw SQL by hand. Parameterised queries, prepared statements, query builder APIs: the whole point is that user input never touches the SQL string itself. And yet, every week, I see AI-generated code that looks like this:

// Generated by an LLM when asked to "filter users by search term"
const users = await db.query(
  `SELECT * FROM users WHERE name LIKE '%${searchTerm}%' OR email LIKE '%${searchTerm}%'`
);

That is textbook SQL injection. The template literal drops searchTerm straight into the query string with no escaping, no binding, nothing. If searchTerm is %' OR '1'='1, every row in the table comes back. If it is '; DROP TABLE users; --, you already know how that ends.

Why does this happen? My working theory: LLMs are trained on enormous amounts of tutorial code, Stack Overflow answers, and blog posts. A lot of that code is old, written before query builders were ubiquitous, or written quickly to illustrate a concept rather than ship to production. The model has seen SELECT * FROM users WHERE name = '${input}' ten thousand times. It reproduces the pattern because statistically it fits.

The model also tends to reach for string interpolation when the query is complex. Simple lookups often come out fine. Ask for something with a dynamic ORDER BY or a multi-column LIKE filter and the model loses its footing.

Three patterns that keep showing up

1. Raw query with interpolated input

The most direct form. The developer asks for a search feature; the model produces a raw query method with a template literal.

// Prisma — but bypassing everything Prisma exists to do
const results = await prisma.$queryRaw(
  `SELECT * FROM products WHERE category = '${category}' AND price < ${maxPrice}`
);

$queryRaw accepts a tagged template literal for a reason: Prisma sanitises values passed through the tagged form. Passing an ordinary string instead throws away that protection entirely. The correct version:

const results = await prisma.$queryRaw`
  SELECT * FROM products
  WHERE category = ${category}
  AND price < ${maxPrice}
`;

One character of difference in how the argument is passed. Zero difference in how it looks to a distracted reviewer.

2. Dynamic ORDER BY clause

This one catches people out even when they are otherwise careful. Query builder APIs typically parameterise WHERE values but not column identifiers, because SQL does not allow column names to be bound as parameters.

// TypeORM — user controls the sort column
const sortColumn = req.query.sort; // e.g. "name", but could be anything
const users = await userRepository
  .createQueryBuilder('user')
  .orderBy(`user.${sortColumn}`, 'ASC')
  .getMany();

If sortColumn is name, this works fine. If it is name; DROP TABLE users; --, the query builder faithfully injects that string into the ORDER BY clause. LLMs produce this pattern constantly because they correctly identify that TypeORM needs a string for orderBy and reach for the obvious solution.

The fix is an allowlist:

const ALLOWED_SORT_COLUMNS = ['name', 'email', 'createdAt'] as const;
type SortColumn = typeof ALLOWED_SORT_COLUMNS[number];

function isSortColumn(value: unknown): value is SortColumn {
  return ALLOWED_SORT_COLUMNS.includes(value as SortColumn);
}

const rawSort = req.query.sort;
const sortColumn: SortColumn = isSortColumn(rawSort) ? rawSort : 'createdAt';

const users = await userRepository
  .createQueryBuilder('user')
  .orderBy(`user.${sortColumn}`, 'ASC')
  .getMany();

More code, but the surface area for injection is gone.

3. LIKE wildcards with unsanitised input

Parameterised queries do bind the value in a LIKE clause safely, but they do not escape wildcard characters within the value. If a user searches for 50%, the % is treated as a wildcard and matches everything with a price starting with 50. Usually that is a logic bug rather than a security hole, but in some schemas it can be used to enumerate data.

// Sequelize — wildcards in search term not escaped
const results = await Product.findAll({
  where: {
    name: { [Op.like]: `%${searchTerm}%` }
  }
});

The value is bound (good), but % and _ inside searchTerm are still interpreted as SQL wildcard characters. Escape them before interpolating into the pattern:

function escapeLike(value: string): string {
  return value.replace(/[%_\\]/g, '\\$&');
}

const results = await Product.findAll({
  where: {
    name: { [Op.like]: `%${escapeLike(searchTerm)}%` }
  }
});

What a static analyser looks for

Detecting these patterns programmatically is more tractable than it sounds. The signals are specific:

  • Template literals or string concatenation passed to methods known to accept raw SQL ($queryRaw, query(), execute(), raw())
  • String interpolation inside LIKE patterns, even when the outer call is parameterised
  • orderBy, order, or sort calls where the column name argument is derived from an external variable rather than a literal or an allowlist check
  • Type annotation gaps: a string from req.query flowing directly to a query method without any narrowing

None of these require running the code. They are syntactic patterns visible in the AST. The hard part is reducing false positives: not every template literal in a database call is injection-prone, and not every dynamic orderBy is missing an allowlist. A good analyser builds a simple data-flow graph to check whether external input actually reaches the sink.

Safe rewrites in practice

The rule of thumb I apply in code review: if you can see user-controlled data between backticks in a database call, it needs a second look. Either the method supports tagged template parameterisation (use it), or the value is a column name (use an allowlist), or the value feeds a LIKE pattern (escape wildcards before binding).

None of this is novel. Parameterised queries have been the standard answer to SQL injection since the late 1990s. What is new is that LLMs make it easy to generate a lot of plausible-looking code quickly, and the injection patterns they introduce are subtle enough that they survive code review. A method call that takes a tagged template literal looks almost identical to one that takes a plain string. The difference is invisible until someone sends a malicious payload.

Seaworthy flags this pattern automatically.

本文由 AI 生成,总结了公开可用的来源。

来自 Seaworthy 团队

Seaworthy 会扫描你的代码仓库,发现类似本文提到的各种问题。

安全漏洞、泄露的密钥和配置错误——在部署前捕获。免费运行,无需账户。

$ npx seaworthycode