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:

JavaScript sequelize-safe.js
// 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-APIsdb.raw(...), connection.execute(...), query() mit String-Argument.
  • Dynamische Spalten- und Tabellen-NamenORDER 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:

JavaScript sequelize-secure.js
// 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:

JavaScript sequelize-pitfalls.js
// 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:

TypeScript prisma-secure.ts
// 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 automatisch

Fallen:

TypeScript prisma-pitfalls.ts
// 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:

Python sqlalchemy-secure.py
# 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:

Python sqlalchemy-pitfalls.py
# 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:

Java hibernate-secure.java
// 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:

Java hibernate-pitfalls.java
// 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:

Ruby activerecord-secure.rb
# 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:

Ruby activerecord-pitfalls.rb
# 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 &#123;column&#125;.
  • „User kann filtern" — WHERE &#123;column&#125; = ?.
  • „User kann gruppieren" — GROUP BY &#123;column&#125;.
  • „Datentabellen mit beliebigen Spalten anzeigen" — SELECT &#123;columns&#125; 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:

JavaScript column-allowlist-pattern.js
// 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:

JavaScript like-wildcard-issue.js
// 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-Problem

Das 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:

JavaScript like-escape.js
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

/ Weiter

Zurück zu Injection (SQL/NoSQL/Cmd)

Zur Übersicht