HTTP-Codes — vollständiger Leitfaden für Entwickler: alle Status, Anwendungen und Sicherheitsfallen
Die meisten Dokumente über HTTP-Codes sind Listen mit 63 Einträgen und einzeiliger Definition. Nicht viel geht davon hervor. Dieses Dokument geht den umgekehrten Weg — sieben realistische Szenarien, die du im Backend schreibst, jedes mit vollständigem funktionierendem Code in Fastify + Prisma, gezeigt in zwei Versionen: mit Sicherheitsmängeln und ohne. HTTP-Codes erscheinen dort, wo sie immer erscheinen — im Kontext. Am Ende eine Nachschlagetabelle aller 63 Codes mit RFC-Zuordnung — wenn du nach Nummer suchen musst.
1. REST API — CRUD für Posts
Wir fangen mit dem häufigsten Fall an. Wir schreiben einen Endpoint für die Verwaltung von Blog-Posts. Darin werden natürlich acht HTTP-Codes erscheinen: 200, 201, 204, 304, 400, 404, 409, 422. Jeder an seiner Stelle, nicht weil „man ihn hätte verwenden müssen", sondern weil er ein konkretes Problem löst.
Vollständiger Endpoint — Fastify + Prisma
// apps/api/src/routes/posts.ts
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import crypto from 'node:crypto'
import { prisma } from '../lib/prisma'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
publishAt: z.string().datetime().optional(),
category: z.enum(['tech', 'business', 'security']),
})
const UpdatePostSchema = CreatePostSchema.partial()
function etag(updatedAt: Date) {
return `"${crypto.createHash('sha1').update(updatedAt.toISOString()).digest('hex').slice(0, 16)}"`
}
export async function postsRoutes(fastify: FastifyInstance) {
// LIST — GET /posts
fastify.get('/posts', async (req, reply) => {
const posts = await prisma.post.findMany({
where: { deletedAt: null },
orderBy: { createdAt: 'desc' },
take: 50,
})
return reply.send(posts) // 200 OK
})
// READ — GET /posts/:id (mit ETag und 304-Validierung)
fastify.get<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
const post = await prisma.post.findFirst({
where: { id: req.params.id, deletedAt: null },
})
if (!post) {
return reply.code(404).send({ error: 'post_not_found' })
}
const currentEtag = etag(post.updatedAt)
if (req.headers['if-none-match'] === currentEtag) {
return reply.code(304).send() // ohne Body
}
return reply.header('ETag', currentEtag).send(post) // 200 OK mit ETag
})
// CREATE — POST /posts
fastify.post('/posts', async (req, reply) => {
const parsed = CreatePostSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ // 400 — falsche Struktur
error: 'invalid_body',
issues: parsed.error.flatten(),
})
}
const data = parsed.data
// Business-Validierung — 422, weil das Format OK war
if (data.publishAt && new Date(data.publishAt) < new Date()) {
return reply.code(422).send({
error: 'publish_at_in_past',
message: 'publishAt muss in der Zukunft liegen',
})
}
try {
const post = await prisma.post.create({
data: {
title: data.title,
content: data.content,
category: data.category,
publishAt: data.publishAt ? new Date(data.publishAt) : null,
authorId: req.user.id,
},
})
return reply
.code(201) // 201 Created
.header('Location', `/posts/${post.id}`)
.header('ETag', etag(post.updatedAt))
.send(post)
} catch (err: any) {
if (err.code === 'P2002') { // Prisma unique constraint
return reply.code(409).send({ // 409 Conflict
error: 'slug_already_exists',
})
}
throw err
}
})
// UPDATE — PATCH /posts/:id (optimistisches Concurrency Control via If-Match)
fastify.patch<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
const parsed = UpdatePostSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.flatten() })
}
const existing = await prisma.post.findFirst({
where: { id: req.params.id, deletedAt: null },
})
if (!existing) {
return reply.code(404).send({ error: 'post_not_found' })
}
if (existing.authorId !== req.user.id && !req.user.isAdmin) {
return reply.code(403).send({ error: 'forbidden' })
}
// Optimistisches Locking — Client sendet If-Match mit ETag
const ifMatch = req.headers['if-match']
if (ifMatch && ifMatch !== etag(existing.updatedAt)) {
return reply.code(412).send({ // 412 Precondition Failed
error: 'stale_version',
message: 'Jemand hat den Post unterdessen geändert — hole die aktuelle Version',
})
}
const updated = await prisma.post.update({
where: { id: existing.id },
data: parsed.data,
})
return reply.header('ETag', etag(updated.updatedAt)).send(updated) // 200 OK
})
// DELETE — DELETE /posts/:id
fastify.delete<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
const post = await prisma.post.findFirst({
where: { id: req.params.id, deletedAt: null },
})
if (!post) {
return reply.code(404).send({ error: 'post_not_found' })
}
if (post.authorId !== req.user.id && !req.user.isAdmin) {
return reply.code(403).send({ error: 'forbidden' })
}
await prisma.post.update({
where: { id: post.id },
data: { deletedAt: new Date() },
})
return reply.code(204).send() // 204 No Content
})
}
Warum diese Codes und nicht andere — Entscheidung für Entscheidung
Warum 201 + Location statt 200
200 OK sagt „Operation war erfolgreich". 201 Created sagt „erfolgreich und eine neue Entität wurde erstellt". Der Unterschied ist praktisch: Ein Client, der 201 mit Location: /posts/abc erhält, weiß genau, dass er GET /posts/abc aufrufen kann und die neu erstellte Ressource erhält. Ein Client, der 200 erhält, muss die ID aus dem Body extrahieren — was einen Vertrag erfordert. Der Vertrag kann sich ändern und Clients brechen; Location ist ein Standard.
Warum 204 für DELETE statt 200
Soft Delete war erfolgreich, wir haben nichts Sinnvolles zurückzugeben — 204 No Content. RFC 9110 verbietet einen Body bei 204, also behandelt fetch() im Browser diese Antwort automatisch als „kein Body" und versucht nicht, JSON zu parsen. Zurück zu gehen 200 mit {"deleted":true} zwingt den Client, dieses Format zu verarbeiten; 204 ist der Standard.
Warum unterscheide ich 400 und 422
Zod wirft zwei Fehlertypen. „Fehlendes Feld title" — Syntax, das Request-Format ist falsch, 400 Bad Request. „publishAt in der Vergangenheit" — Format ist OK (es ist ein gültiges ISO-Datum), aber die Domain-Logik lehnt ab. Das ist 422 Unprocessable Content. GitHub, Stripe, Rails machen das konsistent so — und es macht Sinn, weil der Client 400 als Bug behandelt (falsch strukturiert), 422 aber als Ablehnung (User versucht etwas geschäftlich Illegales).
Warum 409 für Unique Constraint
Prisma wirft P2002, wenn zwei User gleichzeitig einen Post mit demselben Slug einfügen. Code 400 wäre irreführend — der Request war korrekt. 422 wäre seltsam — es ist keine Business-Regel, es ist ein State-Konflikt. 409 Conflict ist genau das: „Ich kann das nicht tun, weil es mit dem aktuellen State kollidiert".
Warum 412 beim optimistischen Locking
If-Match ist eine Vorbedingung. Wenn nicht erfüllt, sagt die Spezifikation (RFC 9110 §13.1.1) deutlich: 412 Precondition Failed. Nicht 409 — der Client ist nicht in einem Konflikt-Status, er hat sich selbst darin befunden, weil er eine alte Version hat.
Warum 304 statt die ganze Antwort erneut zu senden
Client mit ETag "abc123" sendet If-None-Match: "abc123". Wenn sich der Post nicht geändert hat — geben wir 304 zurück, leerer Body. Das spart Bandbreite (Post kann 50 KB Inhalt haben), spart JSON-Serialisierung, spart gzip. Cloudflare/Varnish verstehen 304 und versuchen nicht, ihn als Antwort zu cachen. In einem typischen CMS — 80%+ der Anfragen zu veröffentlichten Posts können mit 304 enden.
Test, der die Entscheidungen überprüft
// apps/api/tests/posts.test.ts
import { test, expect } from 'vitest'
import { buildApp } from '../src/app'
test('POST /posts gibt 201 + Location zurück', async () => {
const app = buildApp()
const res = await app.inject({
method: 'POST', url: '/posts',
headers: { authorization: 'Bearer test' },
payload: { title: 'X', content: 'Y', category: 'tech' },
})
expect(res.statusCode).toBe(201)
expect(res.headers.location).toMatch(/^\/posts\//)
expect(res.headers.etag).toBeTruthy()
})
test('POST /posts mit publishAt in der Vergangenheit → 422 (nicht 400)', async () => {
const res = await app.inject({
method: 'POST', url: '/posts',
headers: { authorization: 'Bearer test' },
payload: {
title: 'X', content: 'Y', category: 'tech',
publishAt: '2020-01-01T00:00:00Z', // Vergangenheit
},
})
expect(res.statusCode).toBe(422) // Semantik, nicht Syntax
expect(res.json().error).toBe('publish_at_in_past')
})
test('DELETE gibt 204 ohne Body zurück', async () => {
const res = await app.inject({ method: 'DELETE', url: '/posts/existing-id' })
expect(res.statusCode).toBe(204)
expect(res.body).toBe('') // leerer String, nicht "{}"
})
test('GET mit If-None-Match gibt 304 ohne Body zurück', async () => {
const first = await app.inject({ method: 'GET', url: '/posts/abc' })
const etag = first.headers.etag
const second = await app.inject({
method: 'GET', url: '/posts/abc',
headers: { 'if-none-match': etag },
})
expect(second.statusCode).toBe(304)
expect(second.body).toBe('')
})
test('PATCH mit veralteter If-Match → 412', async () => {
const res = await app.inject({
method: 'PATCH', url: '/posts/abc',
headers: { 'if-match': '"alte-version"' },
payload: { title: 'Neu' },
})
expect(res.statusCode).toBe(412)
})
2. Login und Sessions — 4 reale Bugs, 4 Fixes
Ein Login-Endpoint ist ein Ort, wo vier verschiedene Sicherheitsmängel durch unscheinbare Entscheidungen bei HTTP-Code / Response-Form / Logging sichtbar werden. Im Folgenden der vollständige Weg: zuerst die Version mit Bugs (genau wie die erste Iteration), dann jeder Bug auf den Tisch und behoben, dann die endgültige Version mit Test.
Version mit Bugs (NICHT VERWENDEN)
// apps/api/src/routes/auth/login.ts — Version mit 4 Bugs
export async function loginRoute(fastify: FastifyInstance) {
fastify.post('/login', async (req, reply) => {
const { email, password } = req.body as { email: string; password: string }
req.log.info({ email, password }, 'login attempt') // BUG #4
const user = await prisma.user.findUnique({ where: { email } })
if (!user) { // BUG #1
return reply.code(404).send({ error: 'user_not_found' })
}
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) { // BUG #2
return reply.code(401).send({ error: 'wrong_password' })
}
const attempts = await redis?.incr(`login:${email}`).catch(() => null)
if (attempts === null) { // BUG #3
// Redis ist down — lassen wir sie weitermachen, Sicherheit < Verfügbarkeit
} else if (attempts > 5) {
return reply.code(429).send({ error: 'too_many' })
}
const token = fastify.jwt.sign({ userId: user.id })
return reply.send({ token })
})
}
Bug #1 — Username-Enumeration durch verschiedene Codes
Der Endpoint gibt 404 user_not_found zurück, wenn die E-Mail nicht existiert, 401 wrong_password, wenn sie existiert aber das Passwort falsch ist. Ein Angreifer mit einer Liste von 500.000 E-Mails (Leak von einem anderen Service) kann in ein paar Stunden alle aktiven Accounts deiner Firma herausfiltern. HTTP-Status landen in Proxy/CDN/Access-Logs — man muss nicht mal den Body lesen.
Fix:
// DUMMY_HASH — vorberechneter bcrypt-Hash des Strings "dummy" (cost 10)
const DUMMY_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
const user = await prisma.user.findUnique({ where: { email } })
const hashToCompare = user?.passwordHash ?? DUMMY_HASH
const ok = await bcrypt.compare(password, hashToCompare)
if (!user || !ok) {
return reply.code(401).send({ error: 'invalid_credentials' })
}
Jetzt erhält der Angreifer identische 401 invalid_credentials, unabhängig davon, ob das Account existiert. Nicht möglich zu unterscheiden.
Bug #2 — Timing-Angriff
Der Fix aus #1 sieht nur oberflächlich aus das Problem zu lösen. Beim sorgfältigen Lesen — wenn user nicht existiert, hashToCompare = DUMMY_HASH, bcrypt.compare dauert ~80 ms. Wenn user existiert, bcrypt vergleicht mit seinem echten Hash — auch ~80 ms (solange cost gleich ist). OK, in diesem speziellen Fragment ist das Timing ausgeglichen.
Aber Achtung: in der ursprünglichen Bugversion mit Bugs führt der erste Branch if (!user) return ... in ~2 ms aus (nur Datenbankabfrage), der zweite ~82 ms (Abfrage + bcrypt). Ein Angreifer misst die durchschnittliche Zeit von 100 Versuchen:
// attacker.ts — zeigt die Differenz
async function measureLogin(email: string) {
const samples = 100
const start = Date.now()
for (let i = 0; i < samples; i++) {
await fetch('/login', { method: 'POST', body: JSON.stringify({ email, password: 'wrong' }) })
}
return (Date.now() - start) / samples
}
console.log('exists:', await measureLogin('real@company.pl')) // ~82ms
console.log('notexists:', await measureLogin('random@xxxxxx.pl')) // ~2ms
Unterschied von 80 ms ist eine Ewigkeit — der Angreifer enumeriert Accounts ohne auf den HTTP-Status zu achten. Fix erfordert Dummy-bcrypt.compare für nicht vorhandene User (siehe Code oben — ist bereits korrekt). Außerdem Regressions-Test:
test('login offenbart Benutzerexistenz nicht durch Timing', async () => {
const warmup = 10, samples = 200
// warmup
for (let i = 0; i < warmup; i++) await login('whatever@nowhere.xx', 'wrong')
const tExists: number[] = []
const tNotExists: number[] = []
for (let i = 0; i < samples; i++) {
const t1 = performance.now()
await login('real@company.pl', 'wrong')
tExists.push(performance.now() - t1)
const t2 = performance.now()
await login('random' + i + '@xxxxxx.xx', 'wrong')
tNotExists.push(performance.now() - t2)
}
const median = (arr: number[]) => arr.sort()[arr.length / 2 | 0]
const diff = Math.abs(median(tExists) - median(tNotExists))
expect(diff).toBeLessThan(5) // weniger als 5ms Unterschied
})
Bug #3 — Rate-Limit ausfallsoffen
redis?.incr(...).catch(() => null) — wenn Redis abstürzt, attempts === null, die Bedingung attempts > 5 ist false, Limit deaktiviert. Echter Incident (aus Audit bei PageForYou.pl im April 2026): OOM auf Redis + Angreifer zur selben Zeit + 200.000 Passwort-Versuche bevor jemand es merkte.
Fix — ausfallgesichert:
const key = `login:${email}:${req.ip}`
let attempts: number
try {
attempts = await redis.incr(key)
if (attempts === 1) await redis.expire(key, 60) // Sliding Window 1 min
} catch {
// Redis ist down — wir lassen NICHT zu
return reply
.code(429)
.header('Retry-After', '30')
.send({ error: 'rate_limiter_unavailable' })
}
if (attempts > 5) {
return reply
.code(429)
.header('Retry-After', '60')
.send({ error: 'too_many_attempts' })
}
Trade-off, bewusst: Bei Redis-Ausfall kann sich niemand ~30 Sekunden lang einloggen (Redis-Recovery + expire Zeit). Wir akzeptieren — Sicherheit > Verfügbarkeit. Die Alternative wäre lokales Fallback (In-Memory-Map), aber dann hat ein verteiltes System unkoordiniertes Limiting — jeder Pod zählt separat, tatsächlich N× 5 Versuche statt 5.
Bug #4 — Passwort in Logs
req.log.info({ email, password }, ...) → Loki/ELK/Splunk bekommen das Passwort im Klartext. Jeder mit Zugriff auf Monitoring (SRE, DevOps, Auftragnehmer) sieht es. Klassischer Leak.
Fix — Fastify Logger Redact:
import Fastify from 'fastify'
const fastify = Fastify({
logger: {
level: 'info',
redact: {
paths: [
'req.body.password',
'req.body.currentPassword',
'req.body.newPassword',
'req.headers.authorization',
'req.headers.cookie',
],
censor: '[REDACTED]',
},
},
})
Redact funktioniert auf Pino-Serializer-Ebene — Passwörter landen nie im Transport, auch nicht zufällig an anderer Stelle im Code.
Endgültige Version des Endpoints
// apps/api/src/routes/auth/login.ts — nach allen 4 Fixes
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { prisma } from '../../lib/prisma'
import { redis } from '../../lib/redis'
const DUMMY_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(1) })
export async function loginRoute(fastify: FastifyInstance) {
fastify.post('/login', async (req, reply) => {
const parsed = LoginSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body' })
}
const { email, password } = parsed.data
// 1. Rate Limit (ausfallgesichert)
const rateKey = `login:${email.toLowerCase()}:${req.ip}`
try {
const attempts = await redis.incr(rateKey)
if (attempts === 1) await redis.expire(rateKey, 60)
if (attempts > 5) {
return reply
.code(429)
.header('Retry-After', '60')
.header('WWW-Authenticate', 'Bearer realm="api"')
.send({ error: 'too_many_attempts' })
}
} catch {
return reply
.code(429)
.header('Retry-After', '30')
.send({ error: 'rate_limiter_unavailable' })
}
// 2. Konstante Zeit User-Lookup + Vergleich
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } })
const ok = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH)
if (!user || !ok) {
return reply
.code(401)
.header('WWW-Authenticate', 'Bearer realm="api"')
.send({ error: 'invalid_credentials' })
}
if (user.disabledAt) {
return reply.code(403).send({ error: 'account_disabled' })
}
// Erfolg — Rate-Limit-Zähler zurücksetzen + Token ausstellen
await redis.del(rateKey).catch(() => {})
const token = fastify.jwt.sign({ userId: user.id, role: user.role }, { expiresIn: '15m' })
return reply.send({ token, expiresIn: 900 })
})
}
Warum 401 und nicht 403 für fehlgeschlagene Anmeldung
Häufiger Fehler: Wenn der User das Passwort falsch eingegeben hat, vielleicht 403 Forbidden? Nein. 401 bedeutet „ich weiß nicht wer du bist, authentifiziere dich". 403 bedeutet „ich weiß wer du bist, aber du hast keine Berechtigung für diese Ressource". Authentifizierung ist fehlgeschlagen — 401. Ich verwende 403 nur für disabled account, wo die Identität OK war, aber die Richtlinie lehnt ab.
3. Redirects — 5 Szenarien ohne ins Knie zu schießen
Fünf verschiedene Umleitungen, jede mit anderem Grund. Die falsche Code-Wahl ändert POST in GET, bricht SEO oder öffnet Open Redirect.
Szenario 1: HTTP → HTTPS (global)
Code: 301 + HSTS. Permanent, für alle Methoden (GET dominiert; für POST ist das genauso ein Repair, Browser macht was es soll).
# /etc/nginx/sites-enabled/example.pl
server {
listen 80;
server_name example.pl www.example.pl;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.pl www.example.pl;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# ...
}
Falle: Die erste HTTP-Anfrage kann Session-Cookies im Klartext enthalten. HSTS löst das erst ab dem zweiten Besuch — nach der ersten erfolgreichen HTTPS-Antwort merkt sich der Browser das und versucht nicht mehr HTTP. Der erste Besuch muss entweder über explizit https:// sein oder durch Preload-Liste (hstspreload.org).
Szenario 2: Alte URL → neue nach Rebranding
Code: 301. Google deindexiert die alte URL und schreibt Page Rank um.
fastify.get('/blog-old/:slug', (req, reply) => {
return reply.redirect(301, `/blog/${req.params.slug}`)
})
Falle: 301 wird aggressiv vom Browser gecacht (manchmal für immer). Wenn du die Ziel-URL vermerkst, landen Nutzer dort auch nach der Korrektur, bis sie Cache leeren. Wenn du nicht 100% sicher bist — fang mit 302 an, wechsel zu 301 wenn du sicher bist.
Szenario 3: Post-Redirect-Get nach Formular
Code: 303 See Other. Erzwingt GET statt POST — F5 auf der Bestätigungsseite erzeugt keine Duplikat-Bestellung.
fastify.post('/checkout', async (req, reply) => {
const order = await createOrder(req.body)
return reply.code(303).header('Location', `/orders/${order.id}`).send()
})
Unterschied zu 302: 302 sollte historisch bedeuten „Methode beibehalten", aber Browser wechselten immer POST zu GET. RFC 9110 dokumentierte diese Nicht-Übereinstimmung. 303 ist explizit darüber — keine Unklarheit.
Szenario 4: Permanente Änderung von API-Endpoint mit POST
Code: 308 Permanent Redirect. Im Gegensatz zu 301, garantiert Methoden- und Body-Beibehaltung.
// Alter Endpoint /api/v1/orders/create (POST) → /api/v2/orders (POST)
fastify.post('/api/v1/orders/create', (req, reply) => {
return reply.redirect(308, '/api/v2/orders')
})
Client, der POST mit 2 KB Body sendet, erhält 308 Location: /api/v2/orders und führt POST mit demselben Body aus. 301 ist hier NICHT SICHER — einige HTTP-Bibliotheken (älteres curl, altes axios) wechseln POST zu GET.
Szenario 5: Open Redirect — klassischer Phishing-Angriff
Häufigste Verwundbarkeit, die ich in Audits sehe. Click-Tracking-Endpoint:
// ANFÄLLIG — NICHT VERWENDEN
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
return reply.redirect(302, req.query.url)
})
Angriff: SMS „Dein Konto bank.pl wird gesperrt, verifizieren: https://bank.pl/go?url=https://bank-login-verify.evil/auth". Opfer sieht vertrauenswürdige Domain, klickt, erhält 302 Location: https://bank-login-verify.evil/auth, landet bei Phishing. Passwort eingeben = Konto kompromittiert.
Fix — Allowlist:
const ALLOWED_HOSTS = new Set([
'bank.pl', 'mobile.bank.pl', 'blog.bank.pl',
])
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
let target: URL
try {
target = new URL(req.query.url)
} catch {
return reply.code(400).send({ error: 'invalid_url' })
}
// Nur HTTPS, nur bekannte Hosts
if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.host)) {
return reply.code(400).send({ error: 'domain_not_allowed' })
}
return reply.redirect(302, target.toString())
})
Die Wahl von 302 (nicht 301) ist absichtlich — wir wollen nicht, dass der Browser „klick hier → landest dort" cached, das soll bei jedem Klick eine frische Entscheidung sein.
4. Upload und Range Requests — inklusive Apache Killer
File-Upload ist, wo 413 (Datei zu groß), 415 (falscher Typ), 416 (ungültiger Range) und 206 (Partial Content — Video mit Seeking) auftauchen. Plus Apache Killer — 15 Jahre alter Angriff, der immer noch auf falsch konfigurierten Servern funktioniert.
Upload-Endpoint mit Validierung
import multipart from '@fastify/multipart'
import { pipeline } from 'node:stream/promises'
import fs from 'node:fs'
import crypto from 'node:crypto'
const ALLOWED_MIME = new Set([
'image/jpeg', 'image/png', 'image/webp',
'application/pdf',
])
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
export async function uploadRoute(fastify: FastifyInstance) {
await fastify.register(multipart, {
limits: {
fileSize: MAX_SIZE,
files: 1,
fields: 5,
headerPairs: 200,
},
})
fastify.post('/upload', async (req, reply) => {
const data = await req.file()
if (!data) return reply.code(400).send({ error: 'no_file' })
if (!ALLOWED_MIME.has(data.mimetype)) {
return reply.code(415).send({ // 415 Unsupported Media Type
error: 'mime_not_allowed',
allowed: [...ALLOWED_MIME],
})
}
const id = crypto.randomBytes(16).toString('hex')
const ext = data.mimetype.split('/')[1]
const path = `/srv/uploads/${id}.${ext}`
try {
await pipeline(data.file, fs.createWriteStream(path))
} catch (err: any) {
if (err.code === 'FST_REQ_FILE_TOO_LARGE') {
return reply.code(413).send({ // 413 Content Too Large
error: 'file_too_large',
maxBytes: MAX_SIZE,
})
}
throw err
}
// Sanity Check — multipart-Limit kann umgangen werden, check Real Size
const stat = await fs.promises.stat(path)
if (stat.size > MAX_SIZE) {
await fs.promises.unlink(path)
return reply.code(413).send({ error: 'file_too_large' })
}
// Magic Bytes Check — mimetype aus Header kann lügen
const realMime = await detectMimeFromBytes(path) // z.B. file-type lib
if (!ALLOWED_MIME.has(realMime)) {
await fs.promises.unlink(path)
return reply.code(415).send({ error: 'mime_mismatch' })
}
return reply.code(201)
.header('Location', `/files/${id}`)
.send({ id, size: stat.size, mime: realMime })
})
}
Warum Magic Bytes Check — vertrau dem Client nicht
data.mimetype ist Content-Type aus dem multipart-Header — Client kann dort jeden String einschreiben. Ein Angreifer sendet .exe mit Content-Type: image/png, später wird diese Datei in der App zu img src geleitet — nicht gefährlich — aber zu iframe oder Content-Type in der Antwort, und der Browser führt das Binary aus. Verifizierung via file-type/libmagic liest erste Bytes und erkennt den echten Typ.
Range Requests — Video mit Seeking servieren
Video-Player sendet Range: bytes=1048576-2097151 — will ein Fragment. Server antwortet 206 Partial Content.
fastify.get<{ Params: { id: string } }>('/videos/:id', async (req, reply) => {
const video = await prisma.video.findUnique({ where: { id: req.params.id } })
if (!video) return reply.code(404).send({ error: 'not_found' })
const stat = await fs.promises.stat(video.path)
const range = req.headers.range
if (!range) {
return reply
.header('Content-Length', stat.size.toString())
.header('Accept-Ranges', 'bytes')
.type('video/mp4')
.send(fs.createReadStream(video.path)) // 200 OK, ganz
}
const match = /^bytes=(\d+)-(\d*)$/.exec(range)
if (!match) {
return reply.code(416).send({ error: 'invalid_range' })
}
const start = parseInt(match[1], 10)
const end = match[2] ? parseInt(match[2], 10) : stat.size - 1
if (start >= stat.size || end >= stat.size || start > end) {
return reply
.code(416)
.header('Content-Range', `bytes */${stat.size}`)
.send()
}
const chunkSize = end - start + 1
return reply
.code(206) // 206 Partial Content
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
.header('Accept-Ranges', 'bytes')
.header('Content-Length', chunkSize.toString())
.type('video/mp4')
.send(fs.createReadStream(video.path, { start, end }))
})
Apache Killer — CVE-2011-3192
Angreifer sendet einen Header mit Tausenden überlappender Ranges:
Range: bytes=0-,0-1,0-2,0-3,0-4,0-5,0-6,0-7,...×1300
Server versucht, alle diese Fragments zuzubereiten und zu verbinden — OOM, Crash. Der Fix bei Apache/Nginx wurde vor langer Zeit gemacht, aber wenn du eigene Range-Behandlung schreibst (oben) — musst du die Anzahl und Größe der Ranges begrenzen. Mein Handler akzeptiert nur einen einzigen Range, das ist sicher. Das volle RFC 7233 unterstützt multipart/byteranges mit mehreren Ranges — implementieren nur, wenn du das wirklich brauchst, mit maximal 10-Range-Limit.
5. Async Jobs — 202 Accepted + Polling
RODO-Export ZIP dauert ~62 Sekunden bei großem Konto (PageForYou RODO-Export). Synchron — Browser returned Timeout, Cloudflare 524. Richtig — 202 Accepted + Status-Endpoint + Client-seitiges Polling.
Endpoint, der den Job erstellt
import { Queue } from 'bullmq'
const exportQueue = new Queue('exports', { connection: redisConfig })
fastify.post('/exports', async (req, reply) => {
const job = await exportQueue.add('generate-rodo-zip', {
userId: req.user.id,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
})
return reply
.code(202) // 202 Accepted
.header('Location', `/exports/${job.id}`)
.send({
jobId: job.id,
status: 'queued',
statusUrl: `/exports/${job.id}`,
})
})
Status-Endpoint — was zurückgeben, je nach Phase
fastify.get<{ Params: { id: string } }>('/exports/:id', async (req, reply) => {
const job = await exportQueue.getJob(req.params.id)
if (!job) return reply.code(404).send({ error: 'job_not_found' })
// Autorisierung — nur Owner des Jobs
if (job.data.userId !== req.user.id) {
return reply.code(404).send({ error: 'job_not_found' }) // 404 absichtlich, nicht 403
}
const state = await job.getState()
if (state === 'completed') {
// Fertig — Redirect zu Download-URL
return reply.code(303).header('Location', job.returnvalue.downloadUrl).send()
}
if (state === 'failed') {
return reply.code(500).send({
error: 'job_failed',
attempts: job.attemptsMade,
failedReason: job.failedReason,
})
}
// Läuft noch
return reply
.code(200)
.header('Retry-After', '5') // Hinweis für Client — poll in 5s
.send({
jobId: job.id,
status: state, // waiting, active, delayed
progress: job.progress,
})
})
Warum 404 statt 403 für fremden Job
Angreifer, der das Job-ID-Format kennt, könnte /exports/0, /exports/1, ... enumerieren und durch Status (403 vs 404) die Aktivität anderer User mappen. Immer 404 zurückgeben, wenn Job nicht dir gehört — Angreifer kann nicht unterscheiden „existiert nicht" von „nicht deiner".
Warum 303 nach Fertigstellung, nicht 200
Client, der nach Status fragt, will eine Download-URL wenn fertig. 303 See Other mit Location: signed-url sagt „geh dorthin GET-en". Client kann automatisch folgen, Nutzer kriegt Datei. 200 mit { downloadUrl: "..." } erfordert vom Client separates GET und separate Logik — 303 ist sauberer.
6. Cache-Validierung — ETag und 304
Vollständiges Beispiel Cache-Validierung in Blog-API, wie dieser den du liest. Ziel: Bandbreite 10× reduzieren, SSR-Render-Zeit senken.
Endpoint mit ETag und Validierung
import crypto from 'node:crypto'
function computeEtag(post: { id: string; updatedAt: Date; version: number }) {
const raw = `${post.id}:${post.updatedAt.toISOString()}:${post.version}`
return `"${crypto.createHash('sha1').update(raw).digest('hex').slice(0, 16)}"`
}
fastify.get<{ Params: { slug: string } }>('/posts/slug/:slug', async (req, reply) => {
const post = await prisma.post.findFirst({
where: { slug: req.params.slug, status: 'published' },
select: { id: true, updatedAt: true, version: true, /* und rest der Felder */
title: true, content: true, excerpt: true, coverImage: true },
})
if (!post) return reply.code(404).send({ error: 'not_found' })
const currentEtag = computeEtag(post)
const ifNoneMatch = req.headers['if-none-match']
// Cache-Validierung — hat Client aktuelle Version?
if (ifNoneMatch === currentEtag) {
return reply
.code(304) // 304 Not Modified
.header('ETag', currentEtag)
.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
.send() // leerer Body
}
return reply
.code(200)
.header('ETag', currentEtag)
.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
.header('Vary', 'Accept-Language')
.send(post)
})
Wie das Next.js / Cloudflare nutzt
Next.js ISR mit revalidate: 60 ruft deine API alle 60 s auf. Mit ETag:
- Erster Fetch →
200 OK+ ETag + Body (50 KB) - Nächste Revalidation Fetch (alle 60s) →
If-None-Match: "abc"→304+ 0 Bytes
Ersparnis: ~50 KB × Anzahl Revalidation × Anzahl Server. Bei 10 Cloudflare-Regionen × 60 Revalidation/h = 600 Fetch/h/Post. 50 KB × 600 = 30 MB/h → ohne ETag. Mit ETag → 600 × ~200 B (nur Header) = 120 KB/h. 250× weniger.
Was MUSS ins ETag
Jede Änderung, die der Client beobachten kann. Minimum:
id— verschiedene Posts haben verschiedene Etags auch wennupdatedAtzufällig identischupdatedAt— wird beim Editieren erhöhtversion— für kaskadige Änderungen (Kommentare, View-Counter) oft besser als nur aufupdatedAtzu vertrauen
Was NICHT ins ETag: Passwort-Hash, Secrets, Daten anderer User. ETag ist öffentlich — landet in CDN-Logs, Browser-Headern.
7. Error Handling in Production (500, 502, 503, 504)
Diese vier Codes sehen ähnlich aus, bedeuten aber etwas anderes und jeder hat eine andere Client/Monitoring-Reaktion.
500 — dein Code ist gecrasht
Exception im Handler. Setup, das Stack Trace auf Prod nicht leakt und requestId für Korrelation gibt:
fastify.setErrorHandler((err, req, reply) => {
req.log.error({ err, reqId: req.id, url: req.url, method: req.method }, 'unhandled')
// Business-Fehler — durchlassen
if (err.statusCode && err.statusCode < 500) {
return reply.code(err.statusCode).send({
error: err.code || 'error',
message: err.message,
})
}
// Echter 500 — verstecke Details
return reply.code(500).send({
error: 'internal_error',
requestId: req.id, // zu Logs
// NICHT: err.message, err.stack, err.sql, Dateipfade
})
})
Wenn User sagt „ich krieg einen Fehler", fragst du „welche Request ID?" und findest im Log den vollen Stack Trace. User sieht nichts Vertrauliches.
502 und 504 — Nginx → Backend
Beide sind automatisch aus Nginx. 502 = Backend hat Müll zurückgegeben (Upstream crashed mid-request, reset connection, invalid HTTP response). 504 = Backend antwortete nicht zeitig (proxy_read_timeout).
# /etc/nginx/sites-enabled/api
upstream api {
server 127.0.0.1:4000 max_fails=3 fail_timeout=30s;
server 127.0.0.1:4001 backup;
}
server {
listen 443 ssl;
server_name api.example.pl;
location / {
proxy_pass http://api;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# Retry auf nächsten Upstream
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
# Leak-Header entfernen
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
}
}
503 — zeitweise, Retry-After
503 ist DER EINZIGE der 5xx, der deutlich sagt „versuchen Sie es später nochmal". Verwend ihn wenn:
- Geplante Wartung (Blue-Green Deploy)
- Circuit Breaker geöffnet (abhängiger Service down)
- Load Shedding (zu viel RPS, schützen andere)
Normalerweise mit Retry-After + Custom Body:
// Circuit Breaker — wenn DB überlastet
fastify.addHook('preHandler', async (req, reply) => {
if (await circuitBreaker.isOpen()) {
return reply
.code(503)
.header('Retry-After', '30')
.send({
error: 'service_degraded',
retryAfter: 30,
})
}
})
Schlüsseldifferenz 500 vs 503: 500 = Bug, SRE bekommt Page. 503 = erwartet, Monitoring unterscheidet (nicht alerting). 503 zurückgeben um „nicht zu alarmieren" statt 500 — Anti-Pattern. Alarme sind dafür da zum alarmieren.
8. Nachschlagetabelle — alle 63 Codes mit RFC-Zuordnung
Wenn du schnell einen Code nachschlagen musst — Tabelle unten. Alle 63 offiziellen Einträge aus IANA-Registrierung mit RFC-Referenz.
1xx — Informational (4 Codes)
| Code | Name | RFC | Wann |
|---|---|---|---|
| 100 | Continue | RFC 9110 §15.2.1 | Client sendet Expect: 100-continue, Server stimmt Body zu |
| 101 | Switching Protocols | RFC 9110 §15.2.2 | WebSocket Handshake, HTTP/2 Upgrade |
| 102 | Processing ⚠ | RFC 2518 (deprecated) | WebDAV, aus RFC 4918 entfernt |
| 103 | Early Hints | RFC 8297 | Preload Hinweis vor finaler Antwort (Chrome 103+) |
2xx — Success (10 Codes)
| Code | Name | RFC | Wann |
|---|---|---|---|
| 200 | OK | RFC 9110 §15.3.1 | Erfolg mit Body |
| 201 | Created | RFC 9110 §15.3.2 | Neue Ressource, + Location |
| 202 | Accepted | RFC 9110 §15.3.3 | Async Job in Queue |
| 203 | Non-Authoritative | RFC 9110 §15.3.4 | Antwort von transformierendem Proxy |
| 204 | No Content | RFC 9110 §15.3.5 | DELETE OK, kein Body |
| 205 | Reset Content | RFC 9110 §15.3.6 | Formular zurücksetzen (selten) |
| 206 | Partial Content | RFC 9110 §15.3.7 | Range Request |
| 207 | Multi-Status | RFC 4918 §11.1 | WebDAV Batch |
| 208 | Already Reported | RFC 5842 §7.1 | WebDAV Binding, verhindere Duplikate |
| 226 | IM Used | RFC 3229 | Delta Encoding (selten) |
3xx — Redirections (9 Codes)
| Code | Name | RFC | Wann |
|---|---|---|---|
| 300 | Multiple Choices | RFC 9110 §15.4.1 | Mehrere Darstellungen (selten) |
| 301 | Moved Permanently | RFC 9110 §15.4.2 | Permanent, GET (kann POST→GET ändern) |
| 302 | Found | RFC 9110 §15.4.3 | Zeitweise, GET (mehrdeutig) |
| 303 | See Other | RFC 9110 §15.4.4 | Post-Redirect-Get |
| 304 | Not Modified | RFC 9110 §15.4.5 | Cache-Validierung ETag/If-Modified-Since |
| 305 | Use Proxy ⚠ | RFC 9110 §15.4.6 (deprecated) | Sicherheitslücke, nicht verwenden |
| 306 | (Unused) | RFC 9110 §15.4.7 | Reserviert |
| 307 | Temporary Redirect | RFC 9110 §15.4.8 | Zeitweise, Methode beibehalten |
| 308 | Permanent Redirect | RFC 9110 §15.4.9 | Permanent, Methode beibehalten |
4xx — Client Error (28 Codes)
| Code | Name | RFC | Wann |
|---|---|---|---|
| 400 | Bad Request | RFC 9110 §15.5.1 | Falsch strukturierter Request |
| 401 | Unauthorized | RFC 9110 §15.5.2 | Kein/falsches Token, + WWW-Authenticate |
| 402 | Payment Required | RFC 9110 §15.5.3 | Reserviert, Stripe „card declined" |
| 403 | Forbidden | RFC 9110 §15.5.4 | Token OK, keine Berechtigung |
| 404 | Not Found | RFC 9110 §15.5.5 | Ressource existiert nicht |
| 405 | Method Not Allowed | RFC 9110 §15.5.6 | + Allow |
| 406 | Not Acceptable | RFC 9110 §15.5.7 | Content Negotiation passt nicht |
| 407 | Proxy Auth Required | RFC 9110 §15.5.8 | Wie 401 für Proxy |
| 408 | Request Timeout | RFC 9110 §15.5.9 | Body wird zu langsam gesendet |
| 409 | Conflict | RFC 9110 §15.5.10 | Unique Violation, State Machine |
| 410 | Gone | RFC 9110 §15.5.11 | Gelöscht für immer |
| 411 | Length Required | RFC 9110 §15.5.12 | Kein Content-Length |
| 412 | Precondition Failed | RFC 9110 §15.5.13 | If-Match / If-Unmodified-Since |
| 413 | Content Too Large | RFC 9110 §15.5.14 | Body übersteigt Limit |
| 414 | URI Too Long | RFC 9110 §15.5.15 | URL zu lang |
| 415 | Unsupported Media Type | RFC 9110 §15.5.16 | Falscher Content-Type |
| 416 | Range Not Satisfiable | RFC 9110 §15.5.17 | Falscher Range |
| 417 | Expectation Failed | RFC 9110 §15.5.18 | Erfüllt nicht Expect |
| 418 | I'm a teapot | RFC 2324 (Witz) | Honeypot für Scanner |
| 421 | Misdirected Request | RFC 9110 §15.5.20 | HTTP/2 Connection Coalescing |
| 422 | Unprocessable Content | RFC 9110 §15.5.21 | Business-Validierung |
| 423 | Locked | RFC 4918 §11.3 | WebDAV |
| 424 | Failed Dependency | RFC 4918 §11.4 | WebDAV |
| 425 | Too Early | RFC 8470 | TLS 1.3 0-RTT Replay Protection |
| 426 | Upgrade Required | RFC 9110 §15.5.22 | Neueres Protokoll erzwingen |
| 428 | Precondition Required | RFC 6585 §3 | If-Match erzwingen (Lost Update) |
| 429 | Too Many Requests | RFC 6585 §4 | Rate Limit, + Retry-After |
| 431 | Request Headers Too Large | RFC 6585 §5 | Header überschreiten Limit |
| 451 | Unavailable For Legal Reasons | RFC 7725 | RODO, DMCA, Geoblock |
5xx — Server Error (11 Codes)
| Code | Name | RFC | Wann |
|---|---|---|---|
| 500 | Internal Server Error | RFC 9110 §15.6.1 | Exception im Code |
| 501 | Not Implemented | RFC 9110 §15.6.2 | Unbekannte HTTP-Methode |
| 502 | Bad Gateway | RFC 9110 §15.6.3 | Proxy bekam Müll vom Backend |
| 503 | Service Unavailable | RFC 9110 §15.6.4 | Zeitweise, + Retry-After |
| 504 | Gateway Timeout | RFC 9110 §15.6.5 | Proxy → Backend Timeout |
| 505 | HTTP Version Not Supported | RFC 9110 §15.6.6 | Falsches HTTP/X |
| 506 | Variant Also Negotiates | RFC 2295 §8.1 | Content Negotiation Fehler (selten) |
| 507 | Insufficient Storage | RFC 4918 §11.5 | Kein Platz auf Disk (Upload) |
| 508 | Loop Detected | RFC 5842 §7.2 | WebDAV Recursion |
| 510 | Not Extended ⚠ | RFC 2774 (deprecated) | HTTP Extension Framework |
| 511 | Network Authentication Required | RFC 6585 §6 | Captive Portal (WiFi Hotel/Flughafen) |
Quellen
- IANA, Hypertext Transfer Protocol (HTTP) Status Code Registry, iana.org/assignments/http-status-codes
- R. Fielding, M. Nottingham, J. Reschke, RFC 9110: HTTP Semantics, Juni 2022, rfc-editor.org/rfc/rfc9110
- L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, Juni 2007, rfc-editor.org/rfc/rfc4918
- M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, April 2012, rfc-editor.org/rfc/rfc6585
- T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, Februar 2016
- K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, Dezember 2017
- CVE-2011-3192, Apache HTTP Server Range Header DoS (Apache Killer), nvd.nist.gov
- OWASP, Authentication Cheat Sheet, cheatsheetseries.owasp.org
- OWASP, Unvalidated Redirects and Forwards, cheatsheetseries.owasp.org
- J. Kettle, Practical Web Cache Poisoning, PortSwigger Research 2018
- Fastify, Reply API documentation, fastify.dev/docs
- Node.js, BullMQ job queue documentation, docs.bullmq.io