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.1. Mass Assignment: Wenn der User sich selbst zum Admin macht
Was ich gefunden habe
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"}
Wie ich es ausgenutzt habe
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}'
Wie ich es gefixt habe
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"}
2. IDOR: Fremde Rechnungen lesen in 30 Sekunden
Was ich gefunden habe
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
Wie ich es ausgenutzt habe
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
Wie ich es gefixt habe
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
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
3. SQL Injection im ORM — ja, das geht
Was ich gefunden habe
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
Wie ich es ausgenutzt habe
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.
Wie ich es gefixt habe
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
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
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
4. Kein Rate Limiting auf Auth-Endpoints
Was ich gefunden habe
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 });
});
Wie ich es ausgenutzt habe
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)
Wie ich es gefixt habe
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);
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 });
});
5. Verbose Error Messages: Dein Backend erzählt Angreifern alles
Was ich gefunden habe
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"
}
- 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)
Wie ich es ausgenutzt habe
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")
Wie ich es gefixt habe
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."
}
)
Das Muster dahinter
- 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
Was du jetzt tun kannst
- 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.
- 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.
- 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.