SQL Injection Prevention skill for Codex

Bind every value, allowlist every column. Codex sweeps the codebase for interpolated SQL and the ORM escape hatches that quietly reintroduce it, such as $queryRawUnsafe, text() with f-strings, Django extra() and Sequelize literal. It binds every value, maps sort and filter identifiers through allowlists, fixes dynamic SQL inside stored procedures, narrows the DB role, and adds payload tests proving each query treats input as data.

  • Security
  • $sql-injection-prevention
  • 173 lines
  • 2 files
  • ~1,800 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- sql-injection-prevention

Installs to ~/.agents/skills/sql-injection-prevention/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $sql-injection-prevention, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Eliminates SQL injection by finding string-built queries and unsafe ORM escape hatches, converting them to bound parameters, and allowlisting identifiers such as sort columns and table names that cannot be parameterized. Covers Prisma $queryRawUnsafe, SQLAlchemy text(), Django raw/extra, Sequelize literal, knex.raw, ActiveRecord, JDBC, Go database/sql and dynamic SQL in stored procedures. Use when reviewing database code, fixing a reported injection, or adding search, filter and sort features.

Use it when

  • Reviewing database access code or a diff that adds raw SQL
  • Fixing a reported SQL injection
  • Adding user-controlled search, sort, filter or pagination
  • Auditing stored procedures that build dynamic SQL

Use something else when

How to check the result

Each fixed endpoint has a test sending quote and stacked-query payloads that returns no extra rows and leaves table counts unchanged, and re-running the search patterns finds only commented constant-only cases.

The mistake it prevents

Trying to bind a column name or ORDER BY direction as a parameter, failing, and falling back to string interpolation instead of an allowlist.

~/.agents/skills/sql-injection-prevention/SKILL.md

--- name: sql-injection-prevention description: Eliminates SQL injection by finding string-built queries and unsafe ORM escape hatches, converting them to bound parameters, and allowlisting… license: MIT ---

Done well means: every value reaches the database as a bound parameter, every identifier or keyword built from input comes from a hard-coded allowlist, the app’s DB role could not drop or read other tenants’ tables even if one query were injectable, and a test proves the fixed query treats a payload as data.

Find the candidates

  1. Read AGENTS.md for the ORM, driver and test command.
  2. Search for query strings built with concatenation, interpolation or formatting:
# Interpolation into SQL-looking strings
rg -n -i "(select|insert|update|delete|where|order by)[^\"'\`]*[\"'\`]\s*(\+|%|\.format\(|\$\{)" -g '!node_modules' -g '!*.min.js'
rg -n -i "f[\"'](select|insert|update|delete|with)\b" --type py
rg -n "fmt\.Sprintf\(\s*\"(SELECT|INSERT|UPDATE|DELETE)" --type go
# ORM escape hatches
rg -n "\$queryRawUnsafe|\$executeRawUnsafe|Prisma\.raw\(|sequelize\.literal|Sequelize\.literal|knex\.raw|\.whereRaw|\.orderByRaw|\.extra\(|RawSQL\(|\.raw\(|text\(f|createNativeQuery|createQuery\(\"[^\"]*\"\s*\+|find_by_sql|\.order\(params" -g '!node_modules'
  1. If Semgrep is available, semgrep --config p/sql-injection finds more patterns (registry rules need network on first download; see $semgrep-sast).
  2. Trace each hit to its inputs. Values from request params, headers, cookies, files, queues, other services and previously stored rows (second-order injection) are untrusted.

Fix by what is being inserted

Inserted thingFix
A value (string, number, date)Bound parameter
A list for IN (...)Driver array support (= ANY($1) in Postgres) or generate one placeholder per item
Column or table nameMap from an allowlist dict; never pass input through
Sort direction"desc" if x == "desc" else "asc"
LIMIT / OFFSETParse to int, clamp, then bind (or format the int)
LIKE patternEscape %, _ and the escape char, then bind
Whole WHERE clause from a query languageParse into an AST and generate SQL with the query builder

Correct patterns

Python (DB-API, psycopg 3, SQLAlchemy 2):

cur.execute("SELECT * FROM orders WHERE customer_id = %s AND status = %s", (cid, status))

from sqlalchemy import text, select
session.execute(text("SELECT * FROM orders WHERE id = :id"), {"id": order_id})

SORTABLE = {"created": Order.created_at, "total": Order.total}
col = SORTABLE.get(sort_key, Order.created_at)
stmt = select(Order).order_by(col.desc() if direction == "desc" else col.asc())

Node:

// pg
await pool.query("SELECT * FROM users WHERE email = $1", [email]);
// Prisma: tagged template binds values; the Unsafe variants do not
await prisma.$queryRaw`SELECT * FROM "User" WHERE email = ${email}`;
// knex: ?? binds identifiers, ? binds values (still allowlist the identifier)
await knex.raw("SELECT * FROM ?? WHERE id = ?", [allowedTable, id]);

Java (JDBC / JPA):

PreparedStatement ps = conn.prepareStatement("SELECT * FROM accounts WHERE owner = ?");
ps.setString(1, owner);
em.createQuery("SELECT a FROM Account a WHERE a.owner = :owner", Account.class)
  .setParameter("owner", owner);

Go: placeholders depend on the driver: $1 for pgx and lib/pq, ? for MySQL and SQLite.

row := db.QueryRowContext(ctx, "SELECT id FROM users WHERE email = $1", email)

Rails:

User.where(email: params[:email])
User.where("name LIKE ?", "%#{User.sanitize_sql_like(q)}%")
ORDERS = { "created" => :created_at, "total" => :total }.freeze
Order.order(ORDERS.fetch(params[:sort], :created_at) => (params[:dir] == "asc" ? :asc : :desc))

Traps inside “safe” tools

  • Prisma $queryRawUnsafe(sql, ...values) is safe only for the positional values; any interpolation into sql is injectable. Composing fragments needs Prisma.sql and Prisma.join, not string building. Prisma.raw() inserts text verbatim.
  • SQLAlchemy text(f"... {x}") is raw SQL. text() is only safe with :name binds.
  • Django .extra(), RawSQL() and .raw() accept params lists; f-strings inside them are injectable. QuerySet.order_by(user_input) lets users sort by any field, including related sensitive ones, so allowlist.
  • Sequelize literal(), where: sequelize.where(sequelize.literal(...)) and the replacements option with string concatenation around it.
  • TypeORM .where("name = '" + name + "'") instead of .where("name = :name", { name }).
  • sql.Named, %s placeholders, and ? differ by driver; using Python % string formatting with a %s placeholder is a classic false fix.
  • Bound parameters do not protect identifiers or keywords; the driver will quote a bound column name as a string literal or reject it.

Dynamic SQL in the database

-- PL/pgSQL: %I quotes identifiers, values go through USING
EXECUTE format('SELECT count(*) FROM %I WHERE owner_id = $1', tbl) INTO n USING owner;

-- SQL Server: sp_executesql with typed parameters, QUOTENAME for identifiers
EXEC sp_executesql N'SELECT * FROM dbo.Orders WHERE CustomerId = @c', N'@c int', @c = @CustomerId;

EXEC('... ' + @input) and EXECUTE 'SELECT ... ' || input are injectable even inside a stored procedure.

Limit the blast radius

  • App DB role: only DML on its own schema; no superuser, no CREATE, no FILE (MySQL), no pg_read_server_files, no xp_cmdshell.
  • Separate read-only role for reporting endpoints.
  • Disable stacked queries where the driver allows (most do by default; MySQL multipleStatements: false in mysql2).
  • Return generic errors to clients; detailed SQL errors in responses make exploitation far faster.
  • Postgres row-level security as a tenant boundary: $postgres-row-level-security.

Verification

For each fixed query, add a test that sends a payload and asserts it is treated as data:

def test_search_treats_payload_as_text(client, seed_orders):
    r = client.get("/orders", params={"q": "x' OR '1'='1", "sort": "id; DROP TABLE orders"})
    assert r.status_code in (200, 400)
    assert r.json()["items"] == []            # no rows leaked
    assert Order.objects.count() == len(seed_orders)

Re-run the searches above; remaining hits must each have a comment explaining why the interpolated part is a constant. Dynamic scanners like sqlmap are only for targets the user owns or has written authorisation to test, and never against production without explicit confirmation.

Output template

## SQL injection review: <scope>
| # | File:line | Query builder | Untrusted input | Severity | Fix |
|---|---|---|---|---|---|
| 1 | api/search.ts:58 | $queryRawUnsafe | req.query.sort | Critical | Allowlist map + Prisma.sql |
DB role privileges: <findings>
Remaining raw SQL (reviewed constant-only): <list>

Gotchas

  • Second-order injection: a value stored safely and later concatenated into a query in a batch job or admin report.
  • JSON and array operators: building data->>' + key + ' from input is injection.
  • Escaping functions (mysql_real_escape_string, manual quote doubling) fail on charset tricks and numeric contexts. Replace them with binds.
  • ORMs in “raw” mode inside migrations or seed scripts get reused in runtime code later; review them too.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "SQL Injection Prevention"
  short_description: "Bind every value, allowlist every column"
  default_prompt: "$sql-injection-prevention Audit this repo for SQL injection, especially raw queries in the ORM"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem