Programowanie 24. April 2026 27 min

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 wenn updatedAt zufällig identisch
  • updatedAt — wird beim Editieren erhöht
  • version — für kaskadige Änderungen (Kommentare, View-Counter) oft besser als nur auf updatedAt zu 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)

CodeNameRFCWann
100ContinueRFC 9110 §15.2.1Client sendet Expect: 100-continue, Server stimmt Body zu
101Switching ProtocolsRFC 9110 §15.2.2WebSocket Handshake, HTTP/2 Upgrade
102Processing ⚠RFC 2518 (deprecated)WebDAV, aus RFC 4918 entfernt
103Early HintsRFC 8297Preload Hinweis vor finaler Antwort (Chrome 103+)

2xx — Success (10 Codes)

CodeNameRFCWann
200OKRFC 9110 §15.3.1Erfolg mit Body
201CreatedRFC 9110 §15.3.2Neue Ressource, + Location
202AcceptedRFC 9110 §15.3.3Async Job in Queue
203Non-AuthoritativeRFC 9110 §15.3.4Antwort von transformierendem Proxy
204No ContentRFC 9110 §15.3.5DELETE OK, kein Body
205Reset ContentRFC 9110 §15.3.6Formular zurücksetzen (selten)
206Partial ContentRFC 9110 §15.3.7Range Request
207Multi-StatusRFC 4918 §11.1WebDAV Batch
208Already ReportedRFC 5842 §7.1WebDAV Binding, verhindere Duplikate
226IM UsedRFC 3229Delta Encoding (selten)

3xx — Redirections (9 Codes)

CodeNameRFCWann
300Multiple ChoicesRFC 9110 §15.4.1Mehrere Darstellungen (selten)
301Moved PermanentlyRFC 9110 §15.4.2Permanent, GET (kann POST→GET ändern)
302FoundRFC 9110 §15.4.3Zeitweise, GET (mehrdeutig)
303See OtherRFC 9110 §15.4.4Post-Redirect-Get
304Not ModifiedRFC 9110 §15.4.5Cache-Validierung ETag/If-Modified-Since
305Use Proxy ⚠RFC 9110 §15.4.6 (deprecated)Sicherheitslücke, nicht verwenden
306(Unused)RFC 9110 §15.4.7Reserviert
307Temporary RedirectRFC 9110 §15.4.8Zeitweise, Methode beibehalten
308Permanent RedirectRFC 9110 §15.4.9Permanent, Methode beibehalten

4xx — Client Error (28 Codes)

CodeNameRFCWann
400Bad RequestRFC 9110 §15.5.1Falsch strukturierter Request
401UnauthorizedRFC 9110 §15.5.2Kein/falsches Token, + WWW-Authenticate
402Payment RequiredRFC 9110 §15.5.3Reserviert, Stripe „card declined"
403ForbiddenRFC 9110 §15.5.4Token OK, keine Berechtigung
404Not FoundRFC 9110 §15.5.5Ressource existiert nicht
405Method Not AllowedRFC 9110 §15.5.6+ Allow
406Not AcceptableRFC 9110 §15.5.7Content Negotiation passt nicht
407Proxy Auth RequiredRFC 9110 §15.5.8Wie 401 für Proxy
408Request TimeoutRFC 9110 §15.5.9Body wird zu langsam gesendet
409ConflictRFC 9110 §15.5.10Unique Violation, State Machine
410GoneRFC 9110 §15.5.11Gelöscht für immer
411Length RequiredRFC 9110 §15.5.12Kein Content-Length
412Precondition FailedRFC 9110 §15.5.13If-Match / If-Unmodified-Since
413Content Too LargeRFC 9110 §15.5.14Body übersteigt Limit
414URI Too LongRFC 9110 §15.5.15URL zu lang
415Unsupported Media TypeRFC 9110 §15.5.16Falscher Content-Type
416Range Not SatisfiableRFC 9110 §15.5.17Falscher Range
417Expectation FailedRFC 9110 §15.5.18Erfüllt nicht Expect
418I'm a teapotRFC 2324 (Witz)Honeypot für Scanner
421Misdirected RequestRFC 9110 §15.5.20HTTP/2 Connection Coalescing
422Unprocessable ContentRFC 9110 §15.5.21Business-Validierung
423LockedRFC 4918 §11.3WebDAV
424Failed DependencyRFC 4918 §11.4WebDAV
425Too EarlyRFC 8470TLS 1.3 0-RTT Replay Protection
426Upgrade RequiredRFC 9110 §15.5.22Neueres Protokoll erzwingen
428Precondition RequiredRFC 6585 §3If-Match erzwingen (Lost Update)
429Too Many RequestsRFC 6585 §4Rate Limit, + Retry-After
431Request Headers Too LargeRFC 6585 §5Header überschreiten Limit
451Unavailable For Legal ReasonsRFC 7725RODO, DMCA, Geoblock

5xx — Server Error (11 Codes)

CodeNameRFCWann
500Internal Server ErrorRFC 9110 §15.6.1Exception im Code
501Not ImplementedRFC 9110 §15.6.2Unbekannte HTTP-Methode
502Bad GatewayRFC 9110 §15.6.3Proxy bekam Müll vom Backend
503Service UnavailableRFC 9110 §15.6.4Zeitweise, + Retry-After
504Gateway TimeoutRFC 9110 §15.6.5Proxy → Backend Timeout
505HTTP Version Not SupportedRFC 9110 §15.6.6Falsches HTTP/X
506Variant Also NegotiatesRFC 2295 §8.1Content Negotiation Fehler (selten)
507Insufficient StorageRFC 4918 §11.5Kein Platz auf Disk (Upload)
508Loop DetectedRFC 5842 §7.2WebDAV Recursion
510Not Extended ⚠RFC 2774 (deprecated)HTTP Extension Framework
511Network Authentication RequiredRFC 6585 §6Captive Portal (WiFi Hotel/Flughafen)

Quellen

  1. IANA, Hypertext Transfer Protocol (HTTP) Status Code Registry, iana.org/assignments/http-status-codes
  2. R. Fielding, M. Nottingham, J. Reschke, RFC 9110: HTTP Semantics, Juni 2022, rfc-editor.org/rfc/rfc9110
  3. L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, Juni 2007, rfc-editor.org/rfc/rfc4918
  4. M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, April 2012, rfc-editor.org/rfc/rfc6585
  5. T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, Februar 2016
  6. K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, Dezember 2017
  7. CVE-2011-3192, Apache HTTP Server Range Header DoS (Apache Killer), nvd.nist.gov
  8. OWASP, Authentication Cheat Sheet, cheatsheetseries.owasp.org
  9. OWASP, Unvalidated Redirects and Forwards, cheatsheetseries.owasp.org
  10. J. Kettle, Practical Web Cache Poisoning, PortSwigger Research 2018
  11. Fastify, Reply API documentation, fastify.dev/docs
  12. Node.js, BullMQ job queue documentation, docs.bullmq.io
HTTP-Codes — vollständiger Leitfaden für Entwickler: alle Status, Anwendungen und Sicherheitsfallen — PageForYou.pl