FlawOpen

flawopen.com/sql-injection/Javascript

● CWE-89 · Kritisch
Sicherheitsforschung · FlawOpen

SQL-Injection in JavaScript verhindern (mysql2, pg, Prisma, Knex)

Ein Template-Literal oder + in pool.query() lässt einen Request-Parameter Ihr SQL umschreiben und jede Zeile zurückgeben (CWE-89). Der sichere Aufruf in mysql2, node-postgres, Prisma und Knex, warum ${} in einer Prisma-Methode sicher und in der nächsten gefährlich ist und wie man ohne orderByRaw sortiert.[1], [2], [3], [4], [5], [6]

💡 Einfache Erklärung (ELI5)

Stell dir ein Paketzentrum vor: Der Fahrplan wird gedruckt und abgeschlossen, bevor Pakete eintreffen. Der Name auf einem Etikett gehört in ein festes Feld; er kann keinen zusätzlichen Halt in den Fahrplan schreiben. SQL-Parameter funktionieren genauso: mysql2 execute(sql, [userId]) und pg query(sql, [status]) binden Werte, und Prismas getaggtes $queryRaw bindet jede ${}-Lücke. Baust du SQL vorher mit einem Template Literal oder + zusammen oder verwendest $queryRawUnsafe, steht der Kundentext im Fahrplan selbst. Knex orderByRaw birgt dasselbe Risiko für Sortieranweisungen; ordne den angefragten Schlüssel einer festen Spalte zu und rufe orderBy auf.[1], [2], [3], [4], [5], [6]

Kernkonzepte & Begriffe

Template literals in query()
Ein Backtick-String mit ${userId} wird von JavaScript ausgewertet, bevor der Treiber aufgerufen wird. Der Treiber erhält also fertiges SQL mit dem Wert darin. Die Syntax sieht aus wie ein Platzhalter, bindet aber nichts.[7], [1], [6]
mysql2 execute() vs query()
execute(sql, values) nutzt ein serverseitiges Prepared Statement. query(sql, values) maskiert die Werte im Client und setzt sie in die ?-Marker ein. Beides ist sicher, solange die Werte im Array stehen; keins hilft, wenn der SQL-String mit + oder einem Template-Literal gebaut wurde. mysql2 schaltet multipleStatements standardmäßig ab.[3], [6], [9]
pg query(text, values)
node-postgres schickt values getrennt von text, $1 füllt also PostgreSQL. Ohne Werte nutzt pg das einfache Query-Protokoll, das auch mehrere durch Semikolon getrennte Anweisungen in einem Aufruf annimmt.[2], [6]
Prisma $queryRaw vs $queryRawUnsafe
$queryRaw ist ein Tagged Template: Prisma macht aus jedem ${} einen gebundenen Parameter. $queryRawUnsafe(string) nimmt einen fertigen String, dasselbe ${} in einem normalen Template-Literal landet also im SQL. Die Namen unterscheiden sich um ein Wort, das Risiko ist das Gegenteil.[4], [6]
Knex whereRaw / orderBy
whereRaw('email = ?', [email]) bindet Werte, aber whereRaw und orderByRaw mit interpolierten Strings sind rohes SQL. orderBy(column) quotet die Spalte als Bezeichner; geben Sie nur Namen aus einer Allowlist hinein.[5], [6]

Framework-Zuordnungen

Schritt-für-Schritt Angriffsablauf

Schritt 1

Der Request-Wert erreicht die Datenschicht

Eine Express-Route liest req.query.id und reicht es an getUser() weiter. Query-Parameter sind Strings oder Arrays, nie Zahlen, und nichts prüft sie.[8], [6]

Schritt 2

Ein Template-Literal baut das SQL

` ... WHERE id = ${userId} wird zuerst ausgewertet. Mit dem harmlosen Testwert 0 OR 1=1 wird daraus WHERE id = 0 OR 1=1`, bevor mysql2 den String sieht.[7], [1], [6]

Schritt 3

Der Treiber erhält keine Werte

pool.query(sql) hat ein leeres Werte-Array, es gibt also nichts zu maskieren oder zu binden. MySQL parst OR 1=1 als Teil der WHERE-Klausel.[3], [2], [9], [6]

Schritt 4

Die Route liefert die Tabelle

Die Bedingung ist für jede Zeile wahr. Die Route, die einen Nutzer liefern sollte, liefert alle – ebenso über $queryRawUnsafe oder ein orderByRaw, das aus dem Query-String gespeist wird.[1], [6]

Quellcode: Verwundbar vs. Sicher

VERWUNDBARE IMPLEMENTIERUNG
// users.js: jede Abfrage unten baut SQL-Text aus Request-Daten

// mysql2: das Template-Literal wird ausgewertet, bevor der Treiber läuft
export async function getUser(pool, userId) {
  const [rows] = await pool.query(`SELECT id, email FROM users WHERE id = ${userId}`);
  return rows[0];
}

// pg: Verkettung, und kein Werte-Array zum Binden
export async function findOrders(pg, status) {
  const { rows } = await pg.query("SELECT id FROM orders WHERE status = '" + status + "'");
  return rows;
}

// Prisma: die Unsafe-Variante nimmt einen fertigen String
export function findByEmail(prisma, email) {
  return prisma.$queryRawUnsafe(`SELECT id FROM "User" WHERE email = '${email}'`);
}

// Knex: orderByRaw fügt den Request-Wert als rohes SQL ein
export function listUsers(knex, sortColumn) {
  return knex('users').select('id', 'email').orderByRaw(sortColumn);
}
GEHÄRTETER SICHERHEITS-PATCH
// users.js: Werte stehen im Werte-Array, Spalten kommen aus einer Allowlist
const SORTABLE = new Map([['id', 'id'], ['email', 'email'], ['created', 'created_at']]);

// mysql2: serverseitiges Prepared Statement, Wert an ? gebunden
export async function getUser(pool, userId) {
  const [rows] = await pool.execute('SELECT id, email FROM users WHERE id = ?', [userId]);
  return rows[0];
}

// pg: PostgreSQL füllt $1 aus dem Werte-Array
export async function findOrders(pg, status) {
  const { rows } = await pg.query('SELECT id FROM orders WHERE status = $1', [status]);
  return rows;
}

// Prisma: Tagged Template, jedes ${} wird ein gebundener Parameter
export function findByEmail(prisma, email) {
  return prisma.$queryRaw`SELECT id FROM "User" WHERE email = ${email}`;
}

// Knex: orderBy quotet die freigegebene Spalte als Bezeichner
export function listUsers(knex, sortKey) {
  const column = SORTABLE.get(sortKey) ?? 'id';
  return knex('users').select('id', 'email').orderBy(column);
}

Checkliste für Engineering & Systemsicherheit

Quellen