Ein verbreitetes Vertrauen: „Wir nutzen ein ORM, also kein SQL-Injection." Stimmt für den Großteil des Codes — und ist gleichzeitig die häufigste Quelle moderner SQL-Injection-Bugs, weil ORMs bewusste Auswege für Edge-Cases bieten, an denen Sicherheit verloren geht. Dieser Artikel zeigt die typischen Fallen in Sequelize, Prisma, SQLAlchemy, Hibernate und Active Record — und warum dynamische Spalten-Namen und ORDER BY die häufigsten Quellen sind.
Was ORMs leisten — und nicht leisten
Moderne ORMs (Object-Relational Mappers) wie Sequelize, Prisma, SQLAlchemy, Hibernate, Active Record nehmen einer Anwendung den größten Teil der Query-Konstruktion ab. Für Standard-CRUD-Operationen sind sie strukturell SQL-Injection-frei:
// Sequelize: sicher — Werte werden automatisch parametriert
const user = await User.findOne({
where: { username: req.body.username }
});Das ORM baut intern eine parametrierte Query, der Wert von username landet als Parameter — kein Injection-Vektor.
Wo ORMs aber nicht schützen:
- Raw-Query-APIs —
db.raw(...),connection.execute(...),query()mit String-Argument. - Dynamische Spalten- und Tabellen-Namen —
ORDER BY {column}— Spalten lassen sich nicht parametrieren. LIKE-Patterns mit User-Wildcards.IN (...)-Listen mit dynamischer Länge — manche Libraries handhaben das schlecht.- Bequemlichkeits-APIs, die Strings akzeptieren, wo Objects sicherer wären.
- Native SQL-Funktionen aus User-Input.
In all diesen Fällen muss der/die Entwickler:in explizit sicher arbeiten — Allowlist, manuelle Parametrierung, Schema-Validation.
Sequelize (Node.js)
Sichere Patterns:
// Standard-Finder — sicher
const user = await User.findOne({ where: { email } });
// Operatoren mit Sequelize.Op — sicher
const { Op } = Sequelize;
const items = await Item.findAll({
where: { price: { [Op.gte]: minPrice } }
});Fallen:
// Falle 1: Raw-Query
const result = await sequelize.query(
`SELECT * FROM users WHERE id = ${userId}`,
{ type: QueryTypes.SELECT }
);
// Korrekt:
const result = await sequelize.query(
'SELECT * FROM users WHERE id = :id',
{ replacements: { id: userId }, type: QueryTypes.SELECT }
);
// Falle 2: literal() mit User-Input
const order = await Sequelize.literal(req.query.order); // SQLi
// Korrekt: Allowlist
const ALLOWED = new Set(['name', 'createdAt']);
const order = ALLOWED.has(req.query.order) ? req.query.order : 'createdAt';
// Falle 3: where-Bedingung als String
const items = await Item.findAll({
where: Sequelize.literal(`status = '${req.query.status}'`)
});
// Korrekt:
const items = await Item.findAll({ where: { status: req.query.status } });
// Falle 4: ORDER BY mit User-Spalte
const items = await Item.findAll({
order: [[req.query.sortBy, 'ASC']]
});
// sortBy = "(SELECT password FROM users WHERE id=1)" — SQLi
// Korrekt: Allowlist (siehe oben)Allgemeine Sequelize-Regel: Op.*-Operatoren statt String-Templates, replacements statt Template-Literal in query(), immer Allowlist bei literal() und dynamic columns.
Prisma (Node.js / TypeScript)
Prisma ist eines der modernen ORMs mit TypeScript-First-Ansatz. Standardmäßig parametriert, aber auch hier gibt es Auswege.
Sichere Patterns:
// Sicher: Prisma Client API
const user = await prisma.user.findUnique({
where: { email: userEmail },
});
// Sicher: $queryRaw mit Tagged Template Literal
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE email = ${userEmail}
`;
// Tagged Template → Prisma parametriert automatischFallen:
// Falle 1: $queryRawUnsafe — nicht parametriert!
const users = await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE email = '${userEmail}'`
);
// Korrekt: $queryRaw mit Template Literal (siehe oben)
// Oder: $queryRawUnsafe MIT separaten Parametern:
const users = await prisma.$queryRawUnsafe(
'SELECT * FROM users WHERE email = $1',
userEmail
);
// Falle 2: Sortier-Spalte aus User-Input
const items = await prisma.item.findMany({
orderBy: { [req.query.sortBy as string]: 'asc' },
});
// sortBy könnte beliebig sein. Prisma erlaubt nur valide Spalten,
// aber wenn der Wert nicht in der Allowlist ist, wirft es Fehler —
// besser explizit prüfen.
// Falle 3: String-Konkatenation in $executeRawUnsafe
await prisma.$executeRawUnsafe(`DROP TABLE ${tableName}`); // RCE
// Niemals mit User-Input.Prisma-Regel: $queryRaw mit Tagged Template als Default, $queryRawUnsafe nur mit expliziten Parametern. Spalten- und Tabellen-Namen niemals aus User-Input ohne Allowlist.
SQLAlchemy (Python)
SQLAlchemy bietet zwei APIs: Core (low-level) und ORM (high-level). Beide sind standardmäßig parametriert.
Sichere Patterns:
# ORM-API: sicher
user = session.query(User).filter(User.email == user_email).first()
# Core-API: sicher
from sqlalchemy import text, select
stmt = select(User).where(User.email == user_email)
user = session.execute(stmt).scalar_one_or_none()
# text() mit Bind-Parametern: sicher
result = session.execute(
text("SELECT * FROM users WHERE email = :email"),
{"email": user_email}
)Fallen:
# Falle 1: text() mit f-String
result = session.execute(
text(f"SELECT * FROM users WHERE email = '{user_email}'")
)
# Korrekt: Bind-Parameter (siehe oben)
# Falle 2: ORDER BY mit User-Input
results = session.query(Product).order_by(
text(f"{request.args.get('sort')} ASC")
).all()
# Korrekt: Allowlist
ALLOWED = {'name', 'price', 'created_at'}
sort = request.args.get('sort')
if sort not in ALLOWED:
sort = 'created_at'
results = session.query(Product).order_by(text(f"{sort} ASC")).all()
# Falle 3: Column-Name aus String
column_name = request.args.get('filter_col')
results = session.query(Product).filter(
getattr(Product, column_name) == value
).all()
# getattr mit beliebigem Namen ist Schwachstelle — Allowlist nötig
# Falle 4: Raw Connection-Execute
conn.execute(f"SELECT * FROM users WHERE id = {user_id}") # SQLi
# Korrekt: parametriert
conn.execute(text("SELECT * FROM users WHERE id = :id"), {"id": user_id})SQLAlchemy-Regel: Niemals f-Strings oder Format-Strings für SQL bauen. text() immer mit Bind-Parametern. Spalten-Zugriff per getattr(Model, name) nur mit Allowlist.
Hibernate / JPA (Java)
Hibernate und JPA sind Java-Standard. Default-API ist parametriert; gefährlich werden HQL- und Native-Query-Konstruktionen.
Sichere Patterns:
// JPA Criteria API: sicher
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> root = cq.from(User.class);
cq.select(root).where(cb.equal(root.get("email"), userEmail));
// Named Parameter: sicher
TypedQuery<User> query = entityManager.createQuery(
"SELECT u FROM User u WHERE u.email = :email", User.class
);
query.setParameter("email", userEmail);
// Native Query mit Parameter: sicher
Query nativeQuery = entityManager.createNativeQuery(
"SELECT * FROM users WHERE email = ?"
);
nativeQuery.setParameter(1, userEmail);Fallen:
// Falle 1: String-Konkatenation in HQL
String hql = "SELECT u FROM User u WHERE u.email = '" + userEmail + "'";
Query query = entityManager.createQuery(hql);
// Falle 2: ORDER BY in HQL
String hql = "SELECT u FROM User u ORDER BY " + sortColumn;
// sortColumn aus User-Input — Injection möglich
// Korrekt: Allowlist
// Falle 3: Native Query mit String-Konkat
Query q = entityManager.createNativeQuery(
"SELECT * FROM users WHERE name LIKE '%" + searchTerm + "%'"
);
// Korrekt: parametrieren mit LIKE-Konkat innerhalb des Parameters
Query q = entityManager.createNativeQuery(
"SELECT * FROM users WHERE name LIKE :search"
);
q.setParameter("search", "%" + searchTerm + "%");Hibernate-Regel: Named Parameters, niemals String-Konkat. Criteria-API für komplexe Queries.
Active Record (Rails)
Ruby on Rails Active Record ist standardmäßig parametriert.
Sichere Patterns:
# Sicher: Hash-Bedingung
User.where(email: params[:email])
# Sicher: Array-Bedingung mit Platzhalter
User.where("email = ?", params[:email])
# Sicher: Hash mit Symbol-Keys
User.where("email = :email", email: params[:email])Fallen:
# Falle 1: String-Interpolation
User.where("email = '#{params[:email]}'") # SQLi
# Falle 2: ORDER BY mit User-Input
Product.order(params[:sort])
# params[:sort] kann SQL enthalten
# Korrekt: Allowlist
ALLOWED_SORT = %w[name price created_at]
sort = ALLOWED_SORT.include?(params[:sort]) ? params[:sort] : 'created_at'
Product.order(sort)
# Falle 3: find_by_sql mit String-Konkat
User.find_by_sql("SELECT * FROM users WHERE id = #{params[:id]}") # SQLi
# Korrekt:
User.find_by_sql(["SELECT * FROM users WHERE id = ?", params[:id]])Rails-Regel: Niemals String-Interpolation in Active-Record-Bedingungen. Statt dessen Hash-Form oder Array-Form mit ?.
Dynamische Spalten-Namen — der häufigste Vektor
Über alle ORMs hinweg ist dynamische Spalten-Auswahl der häufigste verbleibende SQL-Injection-Vektor:
- „User kann sortieren" —
ORDER BY {column}. - „User kann filtern" —
WHERE {column} = ?. - „User kann gruppieren" —
GROUP BY {column}. - „Datentabellen mit beliebigen Spalten anzeigen" —
SELECT {columns} FROM ....
Spalten-Namen lassen sich nicht parametrieren. Sie sind Teil der Query-Struktur, nicht der Daten. Die einzige Lösung: Allowlist im Code.
Generisches Pattern:
// Allowlist als Konstante pro Modell
const ALLOWED_SORT_COLUMNS = {
products: new Set(['name', 'price', 'created_at', 'category']),
users: new Set(['email', 'created_at']),
};
function safeSortColumn(table, requestedColumn, defaultColumn) {
const allowed = ALLOWED_SORT_COLUMNS[table];
if (allowed && allowed.has(requestedColumn)) {
return requestedColumn;
}
return defaultColumn;
}
const sortColumn = safeSortColumn(
'products',
req.query.sort,
'created_at'
);
const items = await Product.findAll({
order: [[sortColumn, 'ASC']],
});Beim Code-Review: Suche nach allen Stellen, an denen Spalten- oder Tabellen-Namen dynamisch aus User-Input kommen. Pro Stelle Allowlist verifizieren.
LIKE-Patterns und Wildcards
LIKE ist ein häufiger Stolperstein. Selbst bei korrekter Parametrierung können Wildcard-Zeichen (%, _) in User-Input zu unerwartetem Verhalten führen:
Schadhaft:
// Parametriert, aber: User-Input kann Wildcards enthalten
const search = req.query.q;
const items = await Item.findAll({
where: { name: { [Op.like]: `%${search}%` } }
});
// User-Input "%admin%" — alle Items mit "admin" im Namen
// User-Input "_secret_" — sehr breite Suche, ggf. Performance-ProblemDas ist kein klassisches SQL-Injection (kein Code-Eval), aber:
- Side-Channel für Daten-Discovery (Trial-and-Error mit Wildcards).
- DoS-Vektor durch sehr unspezifische Patterns.
Schutz: Wildcards escapen, bevor sie in LIKE landen:
function escapeLike(input) {
// % → \%, _ → \_, \ → \\
return input.replace(/[\\%_]/g, '\\$&');
}
const search = escapeLike(req.query.q);
const items = await Item.findAll({
where: { name: { [Op.like]: `%${search}%` } }
});Die Escape-Sequenz mit Backslash funktioniert in PostgreSQL, MySQL und SQLite. MSSQL nutzt [%]-Bracket-Escape.
Häufige Stolperfallen
Wir nutzen ein ORM, also kein SQL-Injection — der häufigste Irrtum
ORMs schützen den Standard-Weg, lassen aber Auswege. Jede $queryRawUnsafe, jedes Sequelize.literal(), jeder f-String in SQLAlchemy, jede String-Interpolation in Active-Record-where ist ein potenzieller Vektor. Code-Review muss alle diese Stellen finden.
Sequelize literal() und ORM-Sicherheit
Die Sequelize.literal()-Funktion ist explizit als Escape-Hatch designed — sie fügt String unverändert ein. Wer sie mit User-Input nutzt, hat SQL-Injection eingebaut. Die meisten Sequelize-CVE-Reports drehen sich um falsche Nutzung von literal().
Prisma's Sicherheits-Ansatz
Prisma hat einen klugen Trick: $queryRaw (sicher) und $queryRawUnsafe (gefährlich) sind unterschiedliche Funktionen mit unterschiedlichen Namen. Das macht es im Code-Review trivial, gefährliche Stellen zu finden — einfach nach Unsafe grep'en. Andere ORMs haben das nicht so klar getrennt.
ORDER BY-Injection ist subtil
Im Gegensatz zu klassischen SQL-Injections geht ORDER-BY-Injection oft ohne sichtbaren Effekt durch. Detection erfordert Time-Based oder Boolean-Blind. Die Lücke ist da, fällt aber nicht auf. Allowlist pro Endpoint.
Hibernate HQL-Injection ist nicht trivial
HQL (Hibernate Query Language) hat eigene Syntax — manche klassische SQL-Injection-Payloads funktionieren nicht direkt. Aber HQL kann via select new MyDTO(...)-Konstrukte und JOIN-Manipulation komplexe Daten-Leaks produzieren. Eigene Test-Methodik nötig.
Native Queries als Last-Resort
Wenn ORM die gewünschte Query nicht ausdrücken kann, ist Native Query mit expliziten Parametern der Ausweg. Aber: jede Native Query ist ein Code-Review-Punkt. Native Queries sollten markiert und besonders geprüft werden.
Schema-Migrationen unter separatem User
ORMs haben oft Migrations-Funktionen, die DDL-Statements ausführen. Migrations-User sollte vom App-User getrennt sein. Ein Production-Webserver braucht keine CREATE TABLE-Rechte. Wenn er sie hat und Injection eintritt, ist mehr verloren als nötig. Vertieft in Defense-in-Depth.
Weiterführende Ressourcen
Externe Quellen
- Sequelize — Raw Queries
- Prisma — Raw Database Access
- SQLAlchemy — Using Textual SQL
- Hibernate User Guide
- Rails — Active Record Querying
- rails-sqli.org — Rails-spezifische SQLi-Patterns
- OWASP SQL Injection Prevention Cheat Sheet