Europe/Berlin
--:--:--

Secure Code Review: Die 5 Fehler, die ich in jedem Backend finde

March 19, 2026Ich reviewe Backend-Code. Beruflich. Und ich sage dir: Die gleichen Fehler tauchen in fast jedem Projekt auf. Egal ob Startup mit drei Devs oder Mittelständler mit eigenem IT-Team — bestimmte Schwachstellen sind so verbreitet, dass ich sie fast schon auf meiner Checkliste abhaken kann, bevor ich den ersten Commit öffne. In diesem Post zeige ich dir fünf Security-Findings, die mir in den letzten Monaten immer wieder begegnet sind. Nicht aus einem Lehrbuch, sondern aus echten Audits — natürlich anonymisiert. Für jedes Finding erkläre ich dir, was ich gefunden habe, wie ich es ausnutzen konnte und wie wir es gemeinsam gefixt haben. Das ist auch mein Ansatz als Full-Cycle Security Engineer: Ich breche rein und baue dann die Tür richtig ein. Beide Perspektiven gehören zusammen.
Ein SaaS-Produkt, FastAPI-Backend, SQLAlchemy als ORM. Der Endpoint für Profil-Updates sah so aus:
Python
@router.put("/api/users/me")
async def update_profile(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    user = get_current_user(request, db)

    for key, value in data.items():
        setattr(user, key, value)

    db.commit()
    return {"status": "updated"}
Sieht harmlos aus, oder? Der User schickt ein JSON mit seinem neuen Anzeigenamen, vielleicht eine neue E-Mail-Adresse. Der Code iteriert über die Felder und setzt sie direkt auf das ORM-Objekt. Das Problem: Das User-Model hatte ein Feld role. Und ein Feld is_active. Und ein Feld email_verified. Ein einziger cURL-Befehl:
Bash
curl -X PUT https://app.example.com/api/users/me \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"display_name": "David", "role": "admin", "email_verified": true}'
Fertig. Mein Account war Admin. Ich hatte Zugriff auf das Admin-Panel, konnte andere User verwalten, Rechnungsdaten einsehen, das volle Programm. Das Fiese daran: Der Endpoint war hinter einer Authentication-Middleware. Die Devs dachten, "nur authentifizierte User kommen hier rein, also ist es sicher." Aber Authentifizierung ist nicht Autorisierung — und schon gar nicht Input-Validierung. Die Lösung ist ein explizites Schema, das genau definiert, welche Felder der User ändern darf:
Python
from pydantic import BaseModel
from typing import Optional

class UserProfileUpdate(BaseModel):
    display_name: Optional[str] = None
    bio: Optional[str] = None
    avatar_url: Optional[str] = None
    # Keine weiteren Felder. Punkt.

@router.put("/api/users/me")
async def update_profile(
    update: UserProfileUpdate,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_user)
):
    update_data = update.model_dump(exclude_unset=True)

    for key, value in update_data.items():
        setattr(user, key, value)

    db.commit()
    return {"status": "updated"}
Der Unterschied: Pydantic validiert den Input und wirft alles weg, was nicht im Schema definiert ist. Selbst wenn jemand "role": "admin" mitschickt — das Feld existiert im DTO nicht und wird ignoriert. Faustregel: Niemals request.json() direkt auf ein ORM-Model mappen. Immer ein explizites Input-Schema dazwischen. Das gilt für Flask mit Marshmallow genauso wie für Express mit Zod oder Joi.
Eine Buchhaltungs-App. Der Endpoint zum Abrufen von Rechnungen:
Python
@router.get("/api/invoices/{invoice_id}")
async def get_invoice(
    invoice_id: int,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_user)
):
    invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
    if not invoice:
        raise HTTPException(status_code=404, detail="Not found")
    return invoice
Authentifizierung? Check. Der User muss eingeloggt sein. Aber schau genau hin: Wo wird geprüft, ob die Rechnung diesem User gehört? Nirgends. Ich war eingeloggt als Testuser und hatte eine eigene Rechnung unter /api/invoices/847. Dann habe ich einfach die ID hochgezählt:
Bash
for id in $(seq 1 1000); do
    response=$(curl -s -o /dev/null -w "%{http_code}" \
        "https://app.example.com/api/invoices/$id" \
        -H "Authorization: Bearer $TOKEN")
    if [ "$response" = "200" ]; then
        echo "Accessible: $id"
    fi
done
Ergebnis: 312 von 1000 Rechnungen waren abrufbar. Mit vollständigen Kundendaten, Beträgen, Bankverbindungen. Ein DSGVO-Albtraum. Das ist IDOR — Insecure Direct Object Reference. Einer der häufigsten Befunde in der OWASP Top 10, und trotzdem sehe ich ihn in fast jedem Projekt. Zwei Dinge: Erstens eine Ownership-Prüfung direkt in der Query. Zweitens UUIDs statt sequenzieller IDs, um Enumeration zu erschweren.
Python
@router.get("/api/invoices/{invoice_id}")
async def get_invoice(
    invoice_id: uuid.UUID,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_user)
):
    invoice = db.query(Invoice).filter(
        Invoice.id == invoice_id,
        Invoice.owner_id == user.id  # Ownership-Check
    ).first()

    if not invoice:
        raise HTTPException(status_code=404, detail="Not found")

    return invoice
Wichtig: Der 404 statt 403 ist Absicht. Wenn du bei fehlender Berechtigung einen 403 zurückgibst, verrätst du dem Angreifer, dass die Ressource existiert. Ein 404 lässt ihn im Dunkeln. Für komplexere Szenarien — etwa wenn mehrere User auf eine Rechnung zugreifen dürfen — lohnt sich eine zentrale Autorisierungs-Middleware:
Python
async def check_invoice_access(
    invoice_id: uuid.UUID,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_user)
) -> Invoice:
    invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
    if not invoice:
        raise HTTPException(status_code=404)

    if invoice.owner_id != user.id and user.id not in [
        m.user_id for m in invoice.shared_with
    ]:
        raise HTTPException(status_code=404)  # Auch hier: 404, nicht 403

    return invoice

"Wir benutzen SQLAlchemy, SQL Injection ist kein Thema." Das höre ich oft. Und meistens stimmt es — bis jemand an einer Stelle einen Raw Query einbaut, weil die ORM-Syntax zu umständlich war. In einem Projekt fand ich eine Suchfunktion:
Python
@router.get("/api/products/search")
async def search_products(q: str, db: Session = Depends(get_db)):
    results = db.execute(
        text(f"SELECT * FROM products WHERE name LIKE '%{q}%'")
    ).fetchall()
    return results
Ein f-String in einem SQL-Statement. Mitten im ORM-Code. Die restlichen 200 Endpoints nutzten saubere SQLAlchemy-Queries — aber dieser eine war handgeschrieben. Klassische SQL Injection. Erst die Bestätigung, dass der Input ungefiltert durchgeht:
GET /api/products/search?q=' OR '1'='1
Antwort: Alle Produkte in der Datenbank. Dann die Eskalation:
GET /api/products/search?q=' UNION SELECT id,email,password_hash,role,null FROM users--
Damit hatte ich die komplette User-Tabelle inklusive Password-Hashes. Bei einem bcrypt-Hash ist das erstmal nicht direkt kritisch — aber in Kombination mit einem Credential-Stuffing-Angriff oder schwachen Passwörtern wird es schnell gefährlich. Und das Schlimmste: Im Code-Review war diese Stelle nicht aufgefallen, weil sie in einer Datei steckte, die seit Monaten nicht angefasst wurde. "Legacy-Code, funktioniert, fassen wir nicht an." Kennt jeder. Parametrisierte Queries. Immer. Keine Ausnahme.
Python
@router.get("/api/products/search")
async def search_products(q: str, db: Session = Depends(get_db)):
    results = db.execute(
        text("SELECT * FROM products WHERE name LIKE :search"),
        {"search": f"%{q}%"}
    ).fetchall()
    return results
Oder noch besser — zurück zum ORM:
Python
@router.get("/api/products/search")
async def search_products(q: str, db: Session = Depends(get_db)):
    results = db.query(Product).filter(
        Product.name.ilike(f"%{q}%")
    ).all()
    return results
Bonus: Wir haben zusätzlich einen Pre-Commit-Hook eingerichtet, der nach f-Strings in der Nähe von db.execute sucht:
Yaml
# .pre-commit-config.yaml
- repo: local
  hooks:
    - id: no-sql-fstrings
      name: Check for SQL f-strings
      entry: 'grep -rn "db\.execute.*f[\"'"'"']" --include="*.py"'
      language: system
      pass_filenames: false
Ist das perfekt? Nein. Aber es fängt die offensichtlichsten Fälle ab und macht Devs auf das Problem aufmerksam.
Ein Login-Endpoint. Kein Rate Limiting. Kein Account-Lockout. Kein CAPTCHA. Nichts.
Javascript
// Express.js
app.post('/api/auth/login', async (req, res) => {
    const { email, password } = req.body;
    const user = await User.findOne({ email });

    if (!user || !await bcrypt.compare(password, user.passwordHash)) {
        return res.status(401).json({ error: 'Invalid credentials' });
    }

    const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
    res.json({ token });
});
Funktional korrekt. Sicherheitstechnisch eine Einladung. Credential Stuffing. Ich habe eine öffentlich verfügbare Leak-Datenbank genommen (ja, die gibt es, und ja, Angreifer nutzen sie) und die E-Mail-Adressen gegen den Login-Endpoint geprüft.
Python
import asyncio
import aiohttp

async def try_login(session, email, password):
    async with session.post(
        'https://app.example.com/api/auth/login',
        json={'email': email, 'password': password}
    ) as resp:
        if resp.status == 200:
            print(f"[+] Valid: {email}")
            return await resp.json()

async def main():
    credentials = load_leaked_credentials()  # Tausende Einträge
    async with aiohttp.ClientSession() as session:
        tasks = [try_login(session, c['email'], c['password'])
                 for c in credentials]
        await asyncio.gather(*tasks)
Mit asyncio und 50 parallelen Requests konnte ich etwa 2.000 Logins pro Minute testen. Innerhalb von 10 Minuten hatte ich 23 gültige Accounts. Gleiches Spiel beim /api/auth/reset-password-Endpoint: Ohne Rate Limiting konnte ich testen, welche E-Mail-Adressen registriert sind (unterschiedliche Fehlermeldungen für existierende vs. nicht-existierende Accounts — ein weiterer klassischer Fehler). Rate Limiting auf mehreren Ebenen:
Javascript
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const { createClient } = require('redis');

const redisClient = createClient({ url: process.env.REDIS_URL });

// Globales Rate Limiting
const globalLimiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 Minuten
    max: 100,
    standardHeaders: true,
    legacyHeaders: false,
});

// Strenges Rate Limiting für Auth-Endpoints
const authLimiter = rateLimit({
    store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
    windowMs: 15 * 60 * 1000,
    max: 10,  // 10 Versuche pro 15 Minuten
    skipSuccessfulRequests: true,
    message: { error: 'Too many attempts. Please try again later.' },
});

app.use('/api/', globalLimiter);
app.use('/api/auth/', authLimiter);
Dazu ein Account-Lockout-Mechanismus:
Javascript
app.post('/api/auth/login', authLimiter, async (req, res) => {
    const { email, password } = req.body;
    const user = await User.findOne({ email });

    // Generische Fehlermeldung -- IMMER die gleiche
    const GENERIC_ERROR = { error: 'Invalid credentials' };

    if (!user) {
        return res.status(401).json(GENERIC_ERROR);
    }

    // Account-Lockout prüfen
    if (user.lockoutUntil && user.lockoutUntil > new Date()) {
        return res.status(401).json(GENERIC_ERROR); // Gleiche Meldung!
    }

    if (!await bcrypt.compare(password, user.passwordHash)) {
        user.failedAttempts = (user.failedAttempts || 0) + 1;

        if (user.failedAttempts >= 5) {
            user.lockoutUntil = new Date(Date.now() + 30 * 60 * 1000);
            user.failedAttempts = 0;
            // Alert ans Security-Team senden
            await notifySecurityTeam(email, req.ip);
        }

        await user.save();
        return res.status(401).json(GENERIC_ERROR);
    }

    // Erfolgreicher Login: Counter zurücksetzen
    user.failedAttempts = 0;
    user.lockoutUntil = null;
    await user.save();

    const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
    res.json({ token });
});
Beachte: Die Fehlermeldung ist bei gesperrtem Account und falschem Passwort identisch. Sonst kann ein Angreifer erkennen, wann ein Account existiert und wann er gesperrt ist.
Ein FastAPI-Projekt in Production. Debug-Mode war aus — aber der globale Exception-Handler fehlte. Das Ergebnis bei einem ungültigen Request:
Json
{
    "detail": "Traceback (most recent call last):\n  File \"/app/api/routes/orders.py\", line 47, in create_order\n    result = db.execute(text(\"INSERT INTO orders ...\"))\nsqlalchemy.exc.IntegrityError: ...\nConnection: postgresql://app_user:s3cret_passw0rd@10.0.1.5:5432/production_db"
}
In einer einzigen Fehlermeldung bekam ich:
  • Den internen Dateipfad (/app/api/routes/orders.py)
  • Die Datenbankstruktur (Tabellen orders und products, Foreign-Key-Constraints)
  • Den Datenbank-Benutzernamen und das Passwort (app_user:s3cret_passw0rd)
  • Die interne IP-Adresse des Datenbankservers (10.0.1.5)
  • Den Datenbanknamen (production_db)
Ich musste gar nicht richtig angreifen. Ich habe einfach systematisch fehlerhafte Requests geschickt und die Fehlermeldungen gesammelt:
Python
import requests

payloads = [
    {"product_id": 99999},           # Foreign Key Error -> DB-Schema
    {"product_id": "abc"},            # Type Error -> Validierungslogik
    {"quantity": -1},                 # Constraint Error -> Business Logic
    {},                               # Missing Field -> Required Fields
    {"product_id": 1, "x": "A"*10000} # Overflow -> Buffer/Length Limits
]

for payload in payloads:
    resp = requests.post(
        'https://app.example.com/api/orders',
        json=payload,
        headers={'Authorization': f'Bearer {token}'}
    )
    if resp.status_code >= 400:
        print(f"Payload: {payload}")
        print(f"Error: {resp.text}\n")
Innerhalb von einer Stunde hatte ich ein komplettes Bild der internen Architektur: Datenbankschema, interne IP-Ranges, Dateisystemstruktur, verwendete Libraries mit Versionsnummern. Alles nur aus Fehlermeldungen. Ein globaler Exception-Handler, der nach außen generische Fehler zeigt und intern alles sauber loggt:
Python
import logging
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

logger = logging.getLogger("app.errors")

app = FastAPI()

@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    # Eindeutige Error-ID für Korrelation
    error_id = str(uuid.uuid4())

    # Intern: Alles loggen, was wir brauchen
    logger.error(
        "Unhandled exception",
        extra={
            "error_id": error_id,
            "path": request.url.path,
            "method": request.method,
            "client_ip": request.client.host,
            "exception_type": type(exc).__name__,
            "exception_message": str(exc),
        },
        exc_info=True,  # Kompletter Traceback im Log
    )

    # Extern: Generische Meldung mit Error-ID
    return JSONResponse(
        status_code=500,
        content={
            "error": "An internal error occurred.",
            "error_id": error_id,
            "support": "If this persists, contact support with the error_id."
        }
    )
Der Trick mit der error_id: Der User sieht eine UUID, die er dem Support geben kann. Intern kannst du damit im Log-System (ELK, Grafana Loki, was auch immer) den vollständigen Traceback finden. Maximale Transparenz intern, minimale Informationspreisgabe nach außen.
Wenn du dir diese fünf Findings anschaust, fällt ein Muster auf: Keiner dieser Fehler ist besonders exotisch. Es sind keine Zero-Days, keine Side-Channel-Attacks, keine kryptographischen Schwächen. Es sind Basics.
  • Mass Assignment: Input nicht validiert
  • IDOR: Autorisierung nicht geprüft
  • SQL Injection: Userinput nicht escaped
  • Rate Limiting: Brute-Force nicht verhindert
  • Verbose Errors: Interne Infos nicht geschützt
Das Problem ist nicht mangelndes Wissen. Die meisten Devs wissen, dass SQL Injection existiert. Das Problem ist der Alltag: Deadlines, Feature-Druck, "das machen wir später". Security-Bugs entstehen nicht, weil jemand es nicht besser weiß, sondern weil niemand hinschaut.
Bevor du zum nächsten Tab wechselst, hier drei Dinge, die du heute noch machen kannst:
  1. Grep deinen Code. Such nach setattr in Python, nach Object.assign in JavaScript, nach Raw-SQL-Strings in der Nähe deiner ORM-Imports. Du wirst wahrscheinlich etwas finden.
  2. Check deine Auth-Endpoints. Öffne dein Terminal und feuere 100 Login-Requests in 10 Sekunden ab. Wenn dein Server brav alle beantwortet: Du hast ein Problem.
  3. Provoziere einen Fehler in Production. Schick einen kaputten Request an deine API und schau dir die Antwort an. Siehst du einen Traceback? Einen Dateipfad? Einen Connection-String? Dann weißt du, was zu tun ist.
Und wenn du willst, dass jemand das systematisch für dich macht — mit Audit-Report, priorisierten Findings und konkreten Fixes — dann schreib mir. Genau das ist mein Job. Kontakt aufnehmen