- Beveiliging
- SQL
- OWASP
SQL-injectie voorkomen in Node.js, Python en Go
Kwetsbare en opgeloste SQL-fragmenten voor pg, Prisma, psycopg, SQLAlchemy en Go database/sql, plus veilige dynamische ORDER BY en IN-lijsten. CWE-89.
· 7 min. leestijd · Lina Source LLC
SQL-injectie (CWE-89) is een van de oudste bugs op het web en nog altijd een van de schadelijkste. De oorzaak is nooit veranderd: gebruikersinvoer wordt in de tekst van een query geplakt, waardoor de database data niet van code kan onderscheiden. De fix is evenmin veranderd: stuur de query en de waarden apart en laat de driver ze binden.
Wat wel is veranderd, is waar de bug zit. De meeste teams gebruiken een ORM voor alledaagse queries, dus injectie duikt nu op in de nooduitgangen: de ruwe query voor een rapport, het zoekendpoint met dynamische sortering, het migratiescript, de admintool. Deze gids loopt de kwetsbare en de opgeloste versie door in drie ecosystemen en behandelt daarna de twee gevallen die parameters alleen niet oplossen.
De impact blijft zelden beperkt tot één tabel. Een enkele injecteerbare query draait meestal met de volledige databaserechten van de applicatie, dus hij kan de data van elke klant lezen, inclusief wachtwoordhashes en API-tokens, rijen wijzigen en op sommige databases het bestandssysteem of andere servers bereiken. Blinde technieken halen data bit voor bit binnen, zelfs als het resultaat van de query nooit aan de gebruiker wordt getoond. Een endpoint dat alleen true of false teruggeeft, is dus nog steeds te misbruiken.
Waarom parameters werken en escapen niet
Bij een geparametriseerde query stuurt de driver de SQL-tekst met placeholders, en reizen de waarden mee als aparte data. De database parset het statement voordat hij de waarde ooit ziet, dus een aanhalingsteken of puntkomma in de invoer is gewoon een teken in een string. Handmatig escapen probeert een waarde veilig te maken om in SQL-tekst te plakken, en faalt op encodings, numerieke contexten, identifiers en elk randgeval waar je niet aan hebt gedacht. Niet escapen, maar binden.
Node.js: pg en Prisma
Bij node-postgres is het kwetsbare patroon een template literal die aan query wordt meegegeven. De fix is een $1-placeholder plus een array met waarden. Prisma is standaard veilig, maar heeft twee raw-API’s, en maar één daarvan is veilig bij interpolatie.
import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";
const pool = new Pool();
const prisma = new PrismaClient();
// Kwetsbaar: invoer wordt onderdeel van de SQL-tekst
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);
// Opgelost: waarde wordt als parameter gebonden
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);
// Prisma: $queryRaw is een tagged template, interpolaties worden parameters
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;
// Prisma: $queryRawUnsafe met concatenatie is injecteerbaar
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = '" + email + "'"
);
// Moet je $queryRawUnsafe gebruiken, geef waarden dan als argumenten mee
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = $1",
email
);Een subtiele valkuil in Prisma: bouw je eerst de querystring op en geef je die aan $queryRaw, dan ben je de bescherming kwijt. De veiligheid komt van de tagged-template-syntaxis. Moet je fragmenten samenstellen, gebruik dan Prisma.sql en Prisma.join, die waarden als parameters behouden. Dezelfde regel geldt voor andere tagged-template-bibliotheken zoals postgres.js en slonik: de tag maakt interpolatie veilig, dus een vooraf samengestelde querystring die als gewone waarde wordt meegegeven, omzeilt die bescherming.
Python: psycopg en SQLAlchemy
In Python zijn de gevaarlijke gereedschappen f-strings, de %-operator en str.format toegepast op SQL. psycopg gebruikt %s-placeholders, die op stringformattering lijken maar het niet zijn: de waarden gaan in het tweede argument van execute, nooit via de %-operator. De text()-constructie van SQLAlchemy is veilig als je benoemde bind-parameters gebruikt, en onveilig als je waarden in de string formatteert.
Eén detail van psycopg zet mensen op het verkeerde been: de placeholder is %s (of %(name)s voor benoemde waarden), ongeacht het kolomtype, en je zet er nooit aanhalingstekens omheen. Schrijf je '%s' met aanhalingstekens, dan wordt de parameter weer onderdeel van een stringliteral en breekt de query. Hetzelfde geldt voor benoemde parameters in SQLAlchemy: :status, niet ':status'.
from sqlalchemy import text
# psycopg: kwetsbaar
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")
# psycopg: opgelost, waarden apart meegegeven
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))
# SQLAlchemy text(): kwetsbaar
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))
# SQLAlchemy text(): opgelost met een bind-parameter
conn.execute(
text("SELECT id FROM orders WHERE status = :status"),
{"status": status},
)Go: database/sql
Go’s database/sql ondersteunt placeholders standaard, maar de syntaxis hangt af van de driver: $1 voor PostgreSQL-drivers zoals pgx, ? voor MySQL en SQLite. Het kwetsbare patroon is fmt.Sprintf of stringconcatenatie om de WHERE-clausule op te bouwen. Omdat Go statisch getypeerd is, ligt de aanname voor de hand dat een int-parameter niet gevaarlijk kan zijn. Voor de waarde zelf klopt dat, maar de gewoonte om queries met Sprintf op te bouwen verspreidt zich naar de stringparameters ernaast, dus houd elke query op placeholders.
// Kwetsbaar: stringconcatenatie
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)
// Opgelost: placeholder plus argument (PostgreSQL-syntaxis; MySQL gebruikt ?)
var u User
err = db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
// niet gevonden
}De nooduitgangen van ORM’s
ORM’s beschermen de query builder, niet elke methode van de ORM. Dit zijn de plekken waar je in elke codebase als eerste moet kijken:
- Prisma: $queryRawUnsafe en $executeRawUnsafe, en elke $queryRaw-aanroep die een vooraf opgebouwde string krijgt in plaats van een tagged template.
- Sequelize en TypeORM: sequelize.query, .where()-aanroepen van de query builder met een samengevoegde string, en ruwe order- of group-clausules.
- Knex: knex.raw en whereRaw met geïnterpoleerde waarden in plaats van ?-bindings.
- SQLAlchemy: text() met f-strings, en literal_column() of kolomnamen die uit invoer komen.
- Django: .raw(), .extra() en cursor.execute met geformatteerde strings.
- GORM: Where, Order en Raw aangeroepen met de uitvoer van fmt.Sprintf in plaats van argumenten.
Dynamische ORDER BY: parameters helpen niet
Placeholders binden waarden, geen identifiers. Je kunt niet ORDER BY $1 schrijven en een kolomnaam meegeven; de database zou dan op een constante sorteren. Een sorteerbare tabel drijft ontwikkelaars dus terug naar het opbouwen van strings, en precies daar keert injectie terug. Dezelfde beperking geldt voor tabelnamen, schemanamen en SQL-sleutelwoorden zoals ASC en DESC.
Het veilige patroon is een allowlist die sorteersleutels uit de gebruikersinterface koppelt aan bekende kolomnamen. De invoer selecteert een item; ze wordt nooit SQL. De richting krijgt dezelfde behandeling: koppel die aan een vaste ASC of DESC.
var sortColumns = map[string]string{
"created": "created_at",
"name": "name",
"price": "price_cents",
}
col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
col = "created_at"
}
dir := "ASC"
if r.URL.Query().Get("dir") == "desc" {
dir = "DESC"
}
// Veilig: col en dir kunnen alleen waarden uit de code hierboven zijn
query := fmt.Sprintf(
"SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1",
col, dir,
)
rows, err := db.QueryContext(ctx, query, limit)Heb je echt een dynamische identifier nodig die niet in een allowlist past, gebruik dan de identifier-quoting van de driver in plaats van je eigen oplossing. In psycopg is dat sql.SQL(...).format(sql.Identifier(name)); in pgx is het pgx.Identifier{name}.Sanitize(). Een allowlist blijft beter, want quoting maakt de naam veilig, maar niet per se geldig of toegestaan. Met een gequote identifier kan de aanroeper nog steeds sorteren of filteren op elke kolom in de tabel, ook kolommen die je nooit wilde blootstellen, zoals een wachtwoordhash of een interne score. Sorteren op een verborgen kolom kan de waarden ervan lekken via de volgorde van de resultaten, één vergelijking tegelijk.
IN-lijsten zonder strings op te bouwen
Filteren op een lijst ID’s is het andere veelgehoorde excuus voor concatenatie. Elke grote stack heeft een veilige manier om dit te doen:
- PostgreSQL met pg of pgx: geef de hele lijst mee als één array-parameter en schrijf WHERE id = ANY($1). De driver verstuurt hem als Postgres-array.
- Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` wordt uitgevouwen tot één placeholder per element.
- SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), en geef daarna een lijst mee.
- Go met MySQL: genereer de placeholderstring op basis van de lengte van de lijst (strings.Repeat("?,", n), ingekort) en geef de waarden als argumenten mee. Alleen de placeholders worden gegenereerd, nooit de waarden.
- Weiger lege lijsten voordat de query draait, en begrens de lijstgrootte zodat het endpoint niet kan worden gebruikt om enorme statements op te bouwen.
Defense in depth
Parametriseren is de fix. Deze maatregelen beperken de schade als er toch een query doorheen glipt:
- Maak verbinding met een databaserol die alleen de rechten heeft die de app nodig heeft. De webapp heeft zelden DROP, ALTER of toegang tot andere schema’s nodig.
- Stuur geen ruwe databasefouten terug naar clients. Error-based injectie is erop gebouwd dat die zichtbaar zijn.
- Valideer types aan de grens: een ID die een integer of UUID hoort te zijn, moet als zodanig worden geparset voordat hij de datalaag bereikt.
- Stel een statement timeout in; die beperkt zowel tijdgebaseerde blinde injectie als op hol geslagen queries.
De injecties vinden die je al hebt
Zoek eerst naar de hierboven genoemde nooduitgangen en daarna naar SQL-sleutelwoorden in de buurt van stringformattering: SELECT of WHERE in template literals, f-strings, Sprintf of concatenatie met +. Elke treffer is correct gebonden, opgebouwd uit een allowlist, of een bug. Sla codepaden die intern lijken niet over: CSV-importers, cronjobs en admindashboards verwerken vaak invoer die oorspronkelijk van een gebruiker kwam, alleen één stap verwijderd. Waarden die je uit je eigen database terugleest, kunnen een payload bevatten die eerder veilig is opgeslagen en later in een query wordt geplakt; dat heet second-order injectie. CodeAuditAgent volgt dat spoor over bestanden heen in openbare GitHub-repository’s en geplakte code, en rapporteert CWE-89-bevindingen met de geciteerde query, de manier waarop invoer die bereikt en een gepatchte versie.
De regel om te onthouden is kort: waarden gaan in parameters, identifiers komen uit een allowlist, en niets uit het request wordt ooit in SQL-tekst geplakt.