Skip to content
Tyrone Software
Cyber Security

SQL injection in ORM apps: where it still gets in

Abstract illustration for the article SQL injection in ORM apps: where it still gets in

Code review finds "SELECT ... " + user_input quickly. In applications built on an ORM, the injections that get through are quieter and look like normal code.

What parameterisation covers

A query with placeholders, WHERE email = %s with the value passed separately, sends the value to the database as data. It cannot change the structure of the statement. Modern ORMs do this for filter values automatically.

It covers values only. A placeholder cannot stand in for a table name, a column name or a keyword. That gap is where most modern injections live.

Places to search for

  • Raw escape hatches. In Django: .raw(), .extra(), RawSQL, and cursor.execute() with an f-string or % formatting. In other frameworks, the equivalents. Search for them and read each one. The safe form is cursor.execute(sql, params).
  • User-controlled column names. A sort parameter like ?sort=price becomes ORDER BY price. If the value goes into a raw query, ?sort=price; DROP ... or a subquery that extracts data one bit at a time is possible. The ORM's own order_by() validates field names and raises an error for unknown ones, but a raw query does not. Map user input through an allowlist: {'price': 'price', 'newest': 'created_at'}.
  • Identifiers built dynamically. Table-per-tenant designs and reporting features often build identifiers from data. Use the driver's identifier quoting (psycopg.sql.Identifier in psycopg) rather than string formatting.
  • LIKE wildcards. Passing % or _ inside a LIKE pattern is not code injection, but a user can craft patterns that force expensive scans. ORMs' contains lookups escape wildcards; hand-written LIKE '%' || %s || '%' does not.
  • JSON and full-text operators. Path expressions and search query syntax (to_tsquery accepts operators and can throw errors on malformed input) are their own small languages. Use the safer variants, such as websearch_to_tsquery in PostgreSQL, and validate paths.
  • Second-order injection. A value stored safely today is read back later and concatenated into a query by a different job. Treat data from your own database as untrusted when it goes into SQL text.
Table showing which parts of a SQL statement can be parameterised
Most injections in ORM code sit in the second row.

Reduce the damage

Assume some injection exists and limit what it can do. Give the application a database user with only the privileges it needs, no SUPERUSER, no FILE or COPY ... PROGRAM rights, and separate read-only credentials for reporting code. Errors that echo the SQL to the browser make blind injection much easier, so keep debug output off in production.

Static analysis (Semgrep and Bandit have rules for raw SQL formatting) finds most of the code patterns cheaply. Add it to CI, and treat a new use of a raw query as something a reviewer must sign off.

The NoSQL version of the same mistake

Document databases have their own version of this problem, and "we use an ODM, not raw SQL" does not rule it out. MongoDB queries are built from data structures, not strings, so there is no quote character to escape, but operators such as $ne, $gt and $regex are themselves just keys in that data structure. A login handler that builds a filter from parsed request JSON without restricting its shape, for example {"username": user_input, "password": password_input} where password_input arrives as the object {"$ne": null} instead of a string, can end up asking "does a user exist whose password is not null", which is true for almost every account. The fix mirrors the SQL case: validate the type and shape of every field pulled from user input before it becomes part of a query, and do not pass a parsed JSON body into a query builder unfiltered on the assumption that "it is just data, not a string" makes it safe.

Keep reading

Technologies

  • Python
  • PHP
  • Laravel
  • Django
  • PostgreSQL
  • MySQL
  • HTML5
  • CSS3
  • Bootstrap