Abschnitt 12 · Lektion 5
Prepared Statements
In der letzten Lektion standen Fragezeichen in den Anweisungen und der Artikel hat gesagt, warum das so ist, komme später. Jetzt kommt es.
Der bequeme Weg und was er anrichtet
export const AUFBAU = `CREATE TABLE buecher (
id INTEGER PRIMARY KEY,
titel TEXT NOT NULL,
autor TEXT
);
INSERT INTO buecher (titel, autor) VALUES
('Node in der Praxis', 'Adler'),
('JavaScript von vorn', 'Berger'),
('HTTP verstehen', 'Adler'),
('Datenbanken im Alltag', 'Berger'),
('Die lange Nacht des Debuggens', 'Adler'),
('Streams und Puffer', 'Berger'),
('Nachtschicht mit Node', 'Adler'),
('Sicher ausliefern', NULL),
('Testen ohne Angst', 'Berger'),
('Fehler lesen lernen', 'O''Brien');`; import { DatabaseSync } from "node:sqlite";
import { AUFBAU } from "./fixture.js";
const db = new DatabaseSync(":memory:");
db.exec(AUFBAU);
// Der Wert wird in den Text der Anweisung hineingeschrieben. Bequem, und
// genau das ist das Problem.
function suche(autor) {
const sql = `SELECT count(*) AS n FROM buecher WHERE autor = '${autor}'`;
console.log(` SQL: ${sql}`);
try {
console.log(` Treffer: ${db.prepare(sql).get().n}`);
} catch (fehler) {
console.log(` Fehler: ${fehler.message}`);
}
}
console.log("Suche nach Adler:");
suche("Adler");
console.log("Suche nach O'Brien:");
suche("O'Brien");
console.log("Suche nach ' OR '1'='1 :");
suche("' OR '1'='1"); Drei Suchen, dreimal derselbe Code, drei völlig verschiedene Ergebnisse. Das Beispiel schreibt jeweils die Anweisung mit heraus, die dabei entstanden ist, und daran sieht man alles.
Bei „Adler” geht es gut. Der Wert landet zwischen den Anführungszeichen, die Anweisung sagt, was sie sagen soll, vier Treffer.
Bei „O’Brien” bricht es ab. Der Apostroph im Namen beendet die Zeichenkette mitten im Wort, und was danach kommt, hält SQLite für den Anfang eines Befehls. Das ist noch kein Angriff, das ist ein Kunde mit einem irischen Nachnamen. Der Fehler ist da, lange bevor sich jemand für dich interessiert.
Beim dritten Wert wird aus der Suche etwas anderes. Aus WHERE autor = '' plus OR '1'='1'
wird eine Bedingung, die für jede Zeile stimmt, und die Suche liefert den ganzen Bestand. Was wie
eine Zählung aussieht, ist ein Datenabzug.
Genau das ist der Kern: Der Wert bleibt nicht Wert, sondern wird Teil der Anweisung. Wer den Text einer Anweisung aus fremden Daten zusammensetzt, überlässt dem Absender die Anweisung.
Der Platzhalter und was der Treiber damit tut
export const AUFBAU = `CREATE TABLE buecher (
id INTEGER PRIMARY KEY,
titel TEXT NOT NULL,
autor TEXT
);
INSERT INTO buecher (titel, autor) VALUES
('Node in der Praxis', 'Adler'),
('JavaScript von vorn', 'Berger'),
('HTTP verstehen', 'Adler'),
('Datenbanken im Alltag', 'Berger'),
('Die lange Nacht des Debuggens', 'Adler'),
('Streams und Puffer', 'Berger'),
('Nachtschicht mit Node', 'Adler'),
('Sicher ausliefern', NULL),
('Testen ohne Angst', 'Berger'),
('Fehler lesen lernen', 'O''Brien');`; import { DatabaseSync } from "node:sqlite";
import { AUFBAU } from "./fixture.js";
const db = new DatabaseSync(":memory:");
db.exec(AUFBAU);
// Die Anweisung steht fest, der Wert kommt getrennt dazu.
const nachAutor = db.prepare("SELECT count(*) AS n FROM buecher WHERE autor = ?");
for (const gesucht of ["Adler", "O'Brien", "' OR '1'='1"]) {
console.log(`${nachAutor.get(gesucht).n} Treffer fuer ${gesucht}`);
}
// Bei mehreren Werten sind Namen lesbarer als eine Reihe Fragezeichen.
const benannt = db.prepare("SELECT count(*) AS n FROM buecher WHERE autor = $autor AND titel LIKE $muster");
console.log(`${benannt.get({ autor: "Adler", muster: "%Nacht%" }).n} Treffer fuer Adler mit Nacht im Titel`); Derselbe Angriff, dieselben Daten, und alles verhält sich, wie man es erwarten würde. O’Brien wird gefunden, der Einschleusversuch findet niemanden, weil es keinen Autor mit diesem seltsamen Namen gibt.
Der Grund ist keine Filterung und kein Ausputzen der Eingabe. Anweisung und Werte gehen getrennt
zur Datenbank. Der Treiber schickt erst SELECT count(*) AS n FROM buecher WHERE autor = ? und
lässt SQLite daraus einen fertigen Plan bauen. Der Plan steht dann schon, bevor der Wert überhaupt
ins Spiel kommt. Ein Wert, der zu einem Zeitpunkt ankommt, an dem gar nichts mehr geparst wird, kann
keine Anweisung mehr werden.
Deshalb hilft auch kein „ich prüfe die Eingabe halt gut genug”. Das ist ein Wettlauf gegen die Kreativität von Fremden. Der Platzhalter ist kein besserer Filter, er macht die Frage überflüssig.
Benannte Parameter
Bei zwei Werten sind Fragezeichen in Ordnung. Bei sechs zählt man, und beim Umsortieren zählt man
falsch. Dann schreibt man Namen: $autor und $muster in der Anweisung, ein Objekt mit denselben
Namen bei get(). Die Reihenfolge spielt dann keine Rolle mehr.
Ein Detail, das öfter Ärger macht, als es sollte: Bei LIKE gehören die Prozentzeichen in den
Wert, nicht in die Anweisung. Also WHERE titel LIKE ? und dann `%${teil}%` als Wert.
Schreibst du LIKE '%?%', steht dort ein Fragezeichen zwischen zwei Prozentzeichen, und das ist ein
ganz normaler Text und kein Platzhalter mehr.
Der zweite Vorteil, den kaum jemand nennt
Eine vorbereitete Anweisung wird einmal zerlegt und geprüft, und danach kann man sie beliebig oft
mit anderen Werten ausführen. Das ist genau der Handgriff aus Lektion 12.3: prepare() vor die
Schleife, run() hinein.
Sicherheit und Geschwindigkeit zeigen hier also in dieselbe Richtung, was selten genug vorkommt.
Wie schlimm es wirklich wird, und wie schlimm nicht
Die berühmteste Fassung des Angriffs hängt ein zweites Kommando an: '; DROP TABLE buecher; --.
Es lohnt sich, genau hinzusehen, wann das wirklich passiert.
export const AUFBAU = `CREATE TABLE buecher (
id INTEGER PRIMARY KEY,
titel TEXT NOT NULL,
autor TEXT
);
INSERT INTO buecher (titel, autor) VALUES
('Node in der Praxis', 'Adler'),
('JavaScript von vorn', 'Berger'),
('HTTP verstehen', 'Adler'),
('Datenbanken im Alltag', 'Berger'),
('Die lange Nacht des Debuggens', 'Adler'),
('Streams und Puffer', 'Berger'),
('Nachtschicht mit Node', 'Adler'),
('Sicher ausliefern', NULL),
('Testen ohne Angst', 'Berger'),
('Fehler lesen lernen', 'O''Brien');`; import { DatabaseSync } from "node:sqlite";
import { AUFBAU } from "./fixture.js";
const db = new DatabaseSync(":memory:");
db.exec(AUFBAU);
const zaehle = () => {
try {
return `${db.prepare("SELECT count(*) AS n FROM buecher").get().n} Zeilen`;
} catch (fehler) {
return `keine Tabelle mehr (${fehler.message})`;
}
};
console.log("vorher:", zaehle());
const gemein = "'; DROP TABLE buecher; --";
// prepare fuehrt nur die erste Anweisung aus. Das Angehaengte laeuft ins Leere.
db.prepare(`SELECT count(*) AS n FROM buecher WHERE autor = '${gemein}'`).all();
console.log("nach prepare:", zaehle());
// exec fuehrt alles aus, was im Text steht.
db.exec(`DELETE FROM buecher WHERE autor = '${gemein}'`);
console.log("nach exec:", zaehle()); Über prepare() läuft es ins Leere. db.prepare() nimmt genau eine Anweisung und führt nur
diese aus, alles Angehängte wird nicht ausgeführt. Die Tabelle steht danach noch.
Über db.exec() fliegt die Tabelle raus. exec() führt aus, was im Text steht, und wenn dort
drei Anweisungen stehen, führt es drei aus.
Daraus folgt keine Entwarnung, sondern eine zweite Regel neben der ersten. Die Verkettung im
prepare()-Fall hat immer noch den ganzen Bestand ausgeliefert, und Daten abzuziehen ist in der
Praxis der häufigere und oft teurere Schaden. Und exec() gehört niemals in die Nähe fremder
Werte. exec() ist für feste Anweisungen da, die du selbst geschrieben hast: Tabellen anlegen,
BEGIN, COMMIT. Alles, wo ein Wert von außen vorkommt, läuft über prepare() mit Platzhaltern.
Die eine Stelle, an der Platzhalter nicht helfen
Platzhalter stehen für Werte. Ein Tabellenname ist kein Wert, ein Spaltenname auch nicht. Wer
also die Sortierspalte von außen bestimmen lassen will, kann nicht ORDER BY ? schreiben, das ist
schlicht kein gültiges SQL.
Die Lösung ist keine Ausnahme von der Regel, sondern ein anderes Werkzeug: eine feste Liste. Man prüft den Wunsch gegen die erlaubten Spaltennamen und benutzt den Namen aus der eigenen Liste, niemals den vom Aufrufer geschickten Text. Damit steht in der Anweisung immer nur etwas, das man selbst hingeschrieben hat, und genau darum geht es die ganze Lektion.
Zum Mitnehmen
Die wichtigste einzelne Sicherheitsregel im ganzen Kurs, und sie steht hier und nicht in Abschnitt 15: Ein Wert gehört nie in den Text einer Anweisung.
Jetzt du
Basis Konto, kostenlosZu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.
Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.
In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.
Was in dieser Lektion steckt
-
Artikel mit 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe, dein Code läuft auf einem Server
Öffnet sich mit dem Basis Konto.