CodeAuditAgent
Alle Artikel
  • Sicherheit
  • SQL
  • OWASP

SQL-Injection verhindern in Node.js, Python und Go

Verwundbare und behobene SQL-Snippets für pg, Prisma, psycopg, SQLAlchemy und Go database/sql, dazu sicheres ORDER BY und IN-Listen. Zugeordnet zu CWE-89.

· 7 Min. Lesezeit · Lina Source LLC

SQL-Injection (CWE-89) ist einer der ältesten Fehler im Web und nach wie vor einer der folgenschwersten. Die Ursache hat sich nie geändert: Benutzereingaben werden in den Text einer Abfrage eingefügt, sodass die Datenbank Daten nicht von Code unterscheiden kann. Auch die Lösung ist dieselbe geblieben: Senden Sie Abfrage und Werte getrennt, und lassen Sie den Treiber die Werte binden.

Verändert hat sich, wo der Fehler steckt. Die meisten Teams nutzen für alltägliche Abfragen ein ORM, daher taucht Injection heute an den Notausgängen auf: in der Raw-Query für einen Bericht, im Such-Endpunkt mit dynamischer Sortierung, im Migrationsskript, im Admin-Tool. Dieser Leitfaden zeigt die verwundbare und die behobene Variante in drei Ökosystemen und behandelt anschließend die beiden Fälle, die sich mit Parametern allein nicht lösen lassen.

Der Schaden beschränkt sich selten auf eine Tabelle. Eine einzige injizierbare Abfrage läuft meist mit den vollen Datenbankrechten der Anwendung. Sie kann also die Daten sämtlicher Kunden lesen, einschließlich Passwort-Hashes und API-Tokens, Zeilen ändern und bei manchen Datenbanken sogar auf das Dateisystem oder andere Server zugreifen. Blinde Techniken extrahieren Daten Bit für Bit, selbst wenn das Ergebnis der Abfrage dem Benutzer nie angezeigt wird. Auch ein Endpunkt, der nur true oder false zurückgibt, ist daher ausnutzbar.

Warum Parameter funktionieren und Escaping nicht

Bei einer parametrisierten Abfrage sendet der Treiber den SQL-Text mit Platzhaltern, und die Werte werden als separate Daten übertragen. Die Datenbank parst die Anweisung, bevor sie den Wert überhaupt sieht, sodass ein Anführungszeichen oder Semikolon in der Eingabe nur ein Zeichen in einem String ist. Manuelles Escaping versucht, einen Wert sicher in SQL-Text einfügbar zu machen, und scheitert an Zeichenkodierungen, numerischen Kontexten, Bezeichnern und jedem Sonderfall, an den Sie nicht gedacht haben. Nicht escapen, sondern binden.

Node.js: pg und Prisma

Bei node-postgres ist das verwundbare Muster ein Template-Literal, das an query übergeben wird. Die Lösung ist ein $1-Platzhalter mit einem Werte-Array. Prisma ist standardmäßig sicher, hat aber zwei Raw-APIs, und nur eine davon ist mit Interpolation sicher.

import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";

const pool = new Pool();
const prisma = new PrismaClient();

// Verwundbar: Die Eingabe wird Teil des SQL-Texts
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);

// Behoben: Der Wert wird als Parameter gebunden
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);

// Prisma: $queryRaw ist ein Tagged Template, Interpolationen werden zu Parametern
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;

// Prisma: $queryRawUnsafe mit Verkettung ist injizierbar
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = '" + email + "'"
);

// Wenn Sie $queryRawUnsafe verwenden müssen, übergeben Sie Werte als Argumente
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = $1",
  email
);

Eine subtile Prisma-Falle: Wer den Query-String zuerst zusammenbaut und dann an $queryRaw übergibt, verliert den Schutz. Die Sicherheit entsteht durch die Tagged-Template-Syntax. Wenn Sie Fragmente zusammensetzen müssen, verwenden Sie Prisma.sql und Prisma.join, die Werte als Parameter erhalten. Dieselbe Regel gilt für andere Tagged-Template-Bibliotheken wie postgres.js und slonik: Erst das Tag macht die Interpolation sicher, ein vorab zusammengesetzter Query-String, der als einfacher Wert übergeben wird, umgeht es.

Python: psycopg und SQLAlchemy

In Python sind die gefährlichen Werkzeuge f-Strings, der %-Operator und str.format, angewendet auf SQL. psycopg verwendet %s-Platzhalter, die wie String-Formatierung aussehen, aber keine sind: Die Werte gehören in das zweite Argument von execute, niemals durch den %-Operator. Das text()-Konstrukt von SQLAlchemy ist sicher, wenn Sie benannte Bind-Parameter verwenden, und unsicher, wenn Sie Werte in den String hineinformatieren.

Ein psycopg-Detail sorgt immer wieder für Verwirrung: Der Platzhalter lautet unabhängig vom Spaltentyp %s (bzw. %(name)s für benannte Werte), und Sie setzen niemals Anführungszeichen darum. Wer '%s' mit Anführungszeichen schreibt, macht den Parameter wieder zum Teil eines String-Literals und zerstört die Abfrage. Dasselbe gilt für benannte Parameter in SQLAlchemy: :status, nicht ':status'.

from sqlalchemy import text

# psycopg: verwundbar
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")

# psycopg: behoben, Werte werden separat übergeben
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))

# SQLAlchemy text(): verwundbar
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))

# SQLAlchemy text(): behoben mit einem Bind-Parameter
conn.execute(
    text("SELECT id FROM orders WHERE status = :status"),
    {"status": status},
)

Go: database/sql

Gos database/sql unterstützt Platzhalter nativ, die Syntax hängt aber vom Treiber ab: $1 für PostgreSQL-Treiber wie pgx, ? für MySQL und SQLite. Das verwundbare Muster ist fmt.Sprintf oder String-Verkettung, um die WHERE-Klausel zu bauen. Weil Go statisch typisiert ist, liegt die Annahme nahe, ein int-Parameter könne nicht gefährlich sein. Das stimmt für den Wert selbst, aber die Gewohnheit, Abfragen mit Sprintf zu bauen, greift auf die String-Parameter daneben über. Bleiben Sie deshalb bei jeder Abfrage bei Platzhaltern.

// Verwundbar: String-Verkettung
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)

// Behoben: Platzhalter plus Argument (PostgreSQL-Syntax; MySQL verwendet ?)
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) {
    // nicht gefunden
}

Die Notausgänge der ORMs

ORMs schützen den Query-Builder, nicht jede Methode des ORM. An diesen Stellen sollten Sie in jeder Codebasis zuerst suchen:

  • Prisma: $queryRawUnsafe und $executeRawUnsafe sowie jeder $queryRaw-Aufruf, der einen vorgefertigten String statt eines Tagged Templates erhält.
  • Sequelize und TypeORM: sequelize.query, .where()-Aufrufe des Query-Builders mit verkettetem String sowie rohe order- oder group-Klauseln.
  • Knex: knex.raw und whereRaw mit interpolierten Werten statt ?-Bindings.
  • SQLAlchemy: text() mit f-Strings sowie literal_column() oder Spaltennamen aus Benutzereingaben.
  • Django: .raw(), .extra() und cursor.execute mit formatierten Strings.
  • GORM: Where, Order und Raw, aufgerufen mit der Ausgabe von fmt.Sprintf statt mit Argumenten.

Dynamisches ORDER BY: Hier helfen Parameter nicht

Platzhalter binden Werte, keine Bezeichner. Sie können nicht ORDER BY $1 schreiben und einen Spaltennamen übergeben; die Datenbank würde nach einer Konstanten sortieren. Eine sortierbare Tabelle treibt Entwickler deshalb gern zurück zum String-Bau, und genau dort kehrt die Injection zurück. Dieselbe Einschränkung gilt für Tabellennamen, Schemanamen und SQL-Schlüsselwörter wie ASC und DESC.

Das sichere Muster ist eine Allowlist, die für Benutzer sichtbare Sortierschlüssel auf bekannte Spaltennamen abbildet. Die Eingabe wählt einen Eintrag aus; sie wird nie zu SQL. Die Richtung wird genauso behandelt: Sie wird auf ein festes ASC oder DESC abgebildet.

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"
}

// Sicher: col und dir können nur Werte aus dem obigen Code sein
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)

Wenn Sie tatsächlich einen dynamischen Bezeichner brauchen, der sich nicht per Allowlist abdecken lässt, verwenden Sie das Identifier-Quoting des Treibers statt eines eigenen. In psycopg ist das sql.SQL(...).format(sql.Identifier(name)); in pgx ist es pgx.Identifier{name}.Sanitize(). Eine Allowlist ist trotzdem besser, denn Quoting macht den Namen sicher, aber nicht zwingend gültig oder zulässig. Ein gequoteter Bezeichner erlaubt dem Aufrufer weiterhin, nach jeder Spalte der Tabelle zu sortieren oder zu filtern, auch nach solchen, die Sie nie offenlegen wollten, etwa einem Passwort-Hash oder einem internen Score. Das Sortieren nach einer verborgenen Spalte kann deren Werte über die Reihenfolge der Ergebnisse preisgeben, Vergleich für Vergleich.

IN-Listen ohne String-Bau

Das Filtern nach einer Liste von IDs ist die andere häufige Ausrede für String-Verkettung. Jeder große Stack bietet dafür einen sicheren Weg:

  • PostgreSQL mit pg oder pgx: Übergeben Sie die gesamte Liste als einen Array-Parameter und schreiben Sie WHERE id = ANY($1). Der Treiber sendet sie als Postgres-Array.
  • Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` wird zu einem Platzhalter pro Element expandiert.
  • SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), dann eine Liste übergeben.
  • Go mit MySQL: Erzeugen Sie den Platzhalter-String aus der Listenlänge (strings.Repeat("?,", n), gekürzt) und übergeben Sie die Werte als Argumente. Generiert werden nur die Platzhalter, niemals die Werte.
  • Lehnen Sie leere Listen ab, bevor die Abfrage läuft, und begrenzen Sie die Listengröße, damit der Endpunkt nicht zum Bau riesiger Anweisungen missbraucht werden kann.

Defense in Depth

Parametrisierung ist die Lösung. Die folgenden Maßnahmen begrenzen den Schaden, wenn doch einmal eine Abfrage durchrutscht:

  • Verbinden Sie sich mit einer Datenbankrolle, die nur die Rechte hat, die die Anwendung braucht. Die Web-App benötigt selten DROP, ALTER oder Zugriff auf andere Schemas.
  • Geben Sie rohe Datenbankfehler nicht an Clients zurück. Fehlerbasierte Injection ist darauf angewiesen, sie zu sehen.
  • Validieren Sie Typen an der Systemgrenze: Eine ID, die eine Ganzzahl oder UUID sein soll, sollte als solche geparst werden, bevor sie die Datenschicht erreicht.
  • Setzen Sie ein Statement-Timeout, das zeitbasierte blinde Injection und außer Kontrolle geratene Abfragen gleichermaßen begrenzt.

Vorhandene Lücken aufspüren

Suchen Sie zuerst nach den oben genannten Notausgängen und dann nach SQL-Schlüsselwörtern in der Nähe von String-Formatierung: SELECT oder WHERE in Template-Literalen, f-Strings, Sprintf oder +-Verkettung. Jeder Treffer ist entweder korrekt gebunden, aus einer Allowlist gebaut oder ein Fehler. Überspringen Sie keine Codepfade, die intern wirken: CSV-Importer, Cron-Jobs und Admin-Dashboards verarbeiten oft Eingaben, die ursprünglich von einem Benutzer stammen, nur einen Schritt entfernt. Werte, die aus Ihrer eigenen Datenbank gelesen werden, können eine Payload tragen, die zuvor sicher gespeichert und später in eine Abfrage eingefügt wurde; man spricht von Second-Order-Injection. CodeAuditAgent verfolgt diese Datenflüsse dateiübergreifend in öffentlichen GitHub-Repositories und eingefügtem Code und meldet CWE-89-Befunde mit der zitierten Abfrage, dem Weg der Eingabe dorthin und einer gepatchten Version.

Die Regel zum Mitnehmen ist kurz: Werte gehören in Parameter, Bezeichner kommen aus einer Allowlist, und nichts aus dem Request wird jemals in SQL-Text eingefügt.