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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- sql-injection-prevention Installs to ~/.agents/skills/sql-injection-prevention/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install sql-injection-prevention The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project sql-injection-prevention Installs to .agents/skills/sql-injection-prevention/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/sql-injection-prevention.zipunzip -o sql-injection-prevention.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) sql-injection-prevention PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The query is safe but slow: use sql-query-review
- You want a broad application review against all OWASP categories: use owasp-top-10-review
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.
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
- Read
AGENTS.mdfor the ORM, driver and test command. - 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'
- If Semgrep is available,
semgrep --config p/sql-injectionfinds more patterns (registry rules need network on first download; see$semgrep-sast). - 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 thing | Fix |
|---|---|
| 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 name | Map from an allowlist dict; never pass input through |
| Sort direction | "desc" if x == "desc" else "asc" |
LIMIT / OFFSET | Parse to int, clamp, then bind (or format the int) |
LIKE pattern | Escape %, _ and the escape char, then bind |
| Whole WHERE clause from a query language | Parse 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 intosqlis injectable. Composing fragments needsPrisma.sqlandPrisma.join, not string building.Prisma.raw()inserts text verbatim. - SQLAlchemy
text(f"... {x}")is raw SQL.text()is only safe with:namebinds. - 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 thereplacementsoption with string concatenation around it. - TypeORM
.where("name = '" + name + "'")instead of.where("name = :name", { name }). sql.Named,%splaceholders, and?differ by driver; using Python%string formatting with a%splaceholder 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, noFILE(MySQL), nopg_read_server_files, noxp_cmdshell. - Separate read-only role for reporting endpoints.
- Disable stacked queries where the driver allows (most do by default; MySQL
multipleStatements: falsein 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.yaml
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.