Programowanie 24 kwietnia 2026 23 min

Kody HTTP — kompletny przewodnik dla programistów: wszystkie statusy, zastosowania i pułapki bezpieczeństwa

Większość dokumentów o kodach HTTP to listy 63 pozycji z jednolinijkową definicją. Niewiele z tego wynika. Ten dokument idzie odwrotną drogą — siedem realnych scenariuszy, które piszesz w backendzie, każdy z pełnym działającym kodem Fastify + Prisma, pokazanym w dwóch wersjach: z bugami bezpieczeństwa i bez. Kody HTTP pojawiają się tam gdzie się zawsze pojawiają — w kontekście. Na końcu tabela referencyjna wszystkich 63 kodów z przypisaniem do RFC — gdy potrzebujesz szukać po numerze.

1. REST API — CRUD dla postów

Zaczynamy od najczęstszego przypadku. Piszemy endpoint zarządzający postami bloga. W nim naturalnie pojawi się osiem kodów HTTP: 200, 201, 204, 304, 400, 404, 409, 422. Każdy w swoim miejscu, nie dlatego że „trzeba było go użyć", tylko bo rozwiązuje konkretny problem.

Pełny 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 (z ETag i walidacją 304)
  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()                     // bez ciała
    }
    return reply.header('ETag', currentEtag).send(post) // 200 OK z 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 — zła struktura
        error: 'invalid_body',
        issues: parsed.error.flatten(),
      })
    }
    const data = parsed.data

    // Walidacja biznesowa — 422, bo format był OK
    if (data.publishAt && new Date(data.publishAt) < new Date()) {
      return reply.code(422).send({
        error: 'publish_at_in_past',
        message: 'publishAt musi być w przyszłości',
      })
    }

    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 (optimistic concurrency przez 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' })
    }

    // Optimistic lock — klient wysłał If-Match z 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: 'Ktoś zmienił post w międzyczasie — pobierz aktualną wersję',
      })
    }

    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
  })
}

Dlaczego tych kodów a nie innych — decyzja po decyzji

Dlaczego 201 + Location zamiast 200

200 OK mówi „operacja się udała". 201 Created mówi „udała się i powstał nowy byt". Różnica jest praktyczna: klient dostający 201 z Location: /posts/abc wie dokładnie, że może zrobić GET /posts/abc i dostać nowo utworzony zasób. Klient dostający 200 musi wyciągnąć ID z ciała — co wymaga kontraktu. Kontrakt można zmienić i zepsuć klientów; Location jest standardem.

Dlaczego 204 dla DELETE zamiast 200

Soft delete się udał, nie mamy nic sensownego do zwrócenia — 204 No Content. RFC 9110 zabrania ciała przy 204, więc fetch() w przeglądarce automatycznie traktuje tę odpowiedź jako „brak body" i nie próbuje parsować JSON-a. Zwrócenie 200 z {"deleted":true} zmusza klienta do obsługiwania takiego formatu; 204 jest standardem.

Dlaczego rozróżniam 400 i 422

Zod zwraca dwa typy błędów. „Brakujące pole title" — syntaktyka, format requestu jest zły, 400 Bad Request. „publishAt w przeszłości" — format jest OK (to poprawna data ISO), ale logika domeny odmawia. To jest 422 Unprocessable Content. GitHub, Stripe, Rails konsekwentnie tak robią — i to ma sens, bo klient traktuje 400 jako bug (źle składamy request), a 422 jako odmowę (user próbuje zrobić coś nielegalnego biznesowo).

Dlaczego 409 dla unique constraint

Prisma rzuca P2002 gdy dwóch userów równocześnie wstawi post z tym samym slugiem. Kod 400 byłby mylący — request był poprawny. 422 byłby dziwny — to nie jest reguła biznesowa, to konflikt stanu. 409 Conflict to dokładnie to: „nie mogę tego zrobić, bo koliduje z obecnym stanem".

Dlaczego 412 przy optimistic lock

If-Match to warunek wstępny. Gdy się nie spełnia, specyfikacja (RFC 9110 §13.1.1) mówi jasno: 412 Precondition Failed. Nie 409 — klient nie jest w stanie konfliktu, on sam się w nim znalazł bo ma starą wersję.

Dlaczego 304 zamiast re-serwować całą odpowiedź

Klient mający ETag "abc123" wysyła If-None-Match: "abc123". Jeśli post się nie zmienił — zwracamy 304, pusty body. Oszczędza to pasmo (post może mieć 50 KB treści), oszczędza serializacji JSON-a, oszczędza gzip. Cloudflare/Varnish rozumieją 304 i nie próbują cachować go jako odpowiedź. W typowym CMS-ie — 80%+ żądań do opublikowanych postów może kończyć się 304.

Test sprawdzający decyzje

// apps/api/tests/posts.test.ts
import { test, expect } from 'vitest'
import { buildApp } from '../src/app'

test('POST /posts zwraca 201 + Location', 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 z publishAt w przeszłości → 422 (nie 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',  // przeszłość
    },
  })
  expect(res.statusCode).toBe(422)   // semantyka, nie składnia
  expect(res.json().error).toBe('publish_at_in_past')
})

test('DELETE zwraca 204 bez ciała', async () => {
  const res = await app.inject({ method: 'DELETE', url: '/posts/existing-id' })
  expect(res.statusCode).toBe(204)
  expect(res.body).toBe('')         // pusty string, nie "{}"
})

test('GET z If-None-Match zwraca 304 bez ciała', 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 z przestałym If-Match → 412', async () => {
  const res = await app.inject({
    method: 'PATCH', url: '/posts/abc',
    headers: { 'if-match': '"stara-wersja"' },
    payload: { title: 'Nowy' },
  })
  expect(res.statusCode).toBe(412)
})

2. Logowanie i sesje — 4 realne bugi, 4 fixy

Endpoint logowania to jedno miejsce, gdzie cztery różne bugi bezpieczeństwa ujawniają się przez niepozorne decyzje kod HTTP / kształt odpowiedzi / logowanie. Poniżej pełna ścieżka: najpierw wersja z bugami (dokładnie taka, jaką piszemy przy pierwszej iteracji), potem każdy bug wyciągnięty na stół i naprawiony, potem wersja finalna z testem.

Wersja z bugami (DON'T USE)

// apps/api/src/routes/auth/login.ts — wersja z 4 bugami
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 padł — pozwalamy dalej, bezpieczeństwo < dostępność
    } 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 przez różne kody

Endpoint zwraca 404 user_not_found gdy emaila nie ma, 401 wrong_password gdy jest ale hasło jest złe. Atakujący z listą 500 tys. emaili (leak z innego serwisu) odfiltruje w kilka godzin wszystkie aktywne konta Twojej firmy. Statusy HTTP trafiają do proxy/CDN/access logów — nie trzeba nawet czytać ciała.

Fix:

// DUMMY_HASH — precomputed bcrypt hash stringa "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' })
}

Teraz atakujący dostaje identyczne 401 invalid_credentials niezależnie od tego, czy konto istnieje. Nie ma jak odróżnić.

Bug #2 — timing attack

Fix z #1 tylko pozornie rozwiązał problem. Czytając go uważnie — gdy user nie istnieje, hashToCompare = DUMMY_HASH, bcrypt.compare działa ~80 ms. Gdy user istnieje, bcrypt porównuje z jego prawdziwym hashem — też ~80 ms (o ile cost jest ten sam). OK, w tym konkretnym fragmencie timing jest wyrównany.

Ale uwaga: w oryginalnej wersji z bugami pierwsza gałąź if (!user) return ... wykonuje się ~2 ms (tylko query do bazy), druga ~82 ms (query + bcrypt). Atakujący mierzy średni czas z 100 prób:

// attacker.ts — pokazuje różnicę
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

Różnica 80 ms to wieczność — atakujący enumeruje konta bez patrzenia na status HTTP. Fix wymaga dummy bcrypt.compare dla nieistniejących userów (patrz kod wyżej — jest już prawidłowy). Dodatkowo test regresyjny:

test('login nie ujawnia istnienia usera przez 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)   // mniej niż 5ms różnicy
})

Bug #3 — fail-open rate limit

redis?.incr(...).catch(() => null) — gdy Redis padnie, attempts === null, warunek attempts > 5 jest false, limit wyłączony. Realny incydent (z audytu na PageForYou.pl w kwietniu 2026): OOM na Redisie + atakujący w tym samym momencie + 200 tys. prób haseł zanim ktoś zauważył.

Fix — fail closed:

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 padł — NIE pozwalamy dalej
  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, świadomy: przy padzie Redis nikt się nie loguje przez ~30 s (czas powrotu Redis + expire). Akceptujemy — bezpieczeństwo > dostępność. Alternatywa to lokalny fallback (in-memory map), ale wtedy rozproszony system ma nieskoordynowany limit — każdy pod zlicza osobno, realnie N× 5 prób zamiast 5.

Bug #4 — hasło w logach

req.log.info({ email, password }, ...) → Loki/ELK/Splunk dostają hasło w plaintext. Każdy z dostępem do monitoringu (SRE, DevOps, kontraktor) je zobaczy. Klasyczny 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 działa na poziomie serializera Pino — hasła nigdy nie trafiają do transportu, nawet przez przypadek w innym miejscu kodu.

Finalna wersja endpointu

// apps/api/src/routes/auth/login.ts — po wszystkich 4 fixach
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 (fail-closed)
    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. Constant-time user lookup + compare
    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' })
    }

    // Sukces — reset rate limit + issue token
    await redis.del(rateKey).catch(() => {})
    const token = fastify.jwt.sign({ userId: user.id, role: user.role }, { expiresIn: '15m' })
    return reply.send({ token, expiresIn: 900 })
  })
}

Dlaczego 401 a nie 403 dla nieudanego logowania

Klasyczna pomyłka: skoro user podał „złe hasło", może 403 Forbidden? Nie. 401 znaczy „nie wiem kim jesteś, uwierzytelnij się". 403 znaczy „wiem kim jesteś, ale nie masz uprawnień do tego zasobu". W logowaniu nie udało się uwierzytelnienie — 401. Używam 403 tylko dla disabled account, gdzie tożsamość była OK, ale polityka odmawia dalej.

3. Redirecty — 5 scenariuszy bez strzelania sobie w stopę

Pięć różnych przekierowań, każde z innym powodem. Wybór złego kodu zmienia POST na GET, łamie SEO, albo otwiera open redirect.

Scenariusz 1: HTTP → HTTPS (globalny)

Kod: 301 + HSTS. Trwałe, dla wszystkich metod (GET dominuje; dla POST to tak samo naprawy, przeglądarka zrobi co ma zrobić).

# /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;
  # ...
}

Pułapka: pierwszy request HTTP może zawierać cookie sesyjne w plaintext. HSTS rozwiązuje to dopiero od drugiej wizyty — po pierwszej pomyślnej odpowiedzi HTTPS przeglądarka zapamiętuje i nie próbuje już HTTP. Pierwsza wizyta musi być albo przez explicit https://, albo preload listę (hstspreload.org).

Scenariusz 2: Stary URL → nowy po rebrandingu

Kod: 301. Google deindeksuje stary URL i przepisze page rank.

fastify.get('/blog-old/:slug', (req, reply) => {
  return reply.redirect(301, `/blog/${req.params.slug}`)
})

Pułapka: 301 jest agresywnie cachowany przez przeglądarkę (czasem na zawsze). Jeśli pomylisz URL docelowy, użytkownicy będą tam lądować nawet po poprawce, dopóki nie wyczyszczą cache. Jeśli nie masz 100% pewności — użyj 302 na początku, zmień na 301 gdy będziesz pewny.

Scenariusz 3: Post-Redirect-Get po formularzu

Kod: 303 See Other. Wymusza GET zamiast POST — F5 na stronie potwierdzenia nie powoduje duplikatu zamówienia.

fastify.post('/checkout', async (req, reply) => {
  const order = await createOrder(req.body)
  return reply.code(303).header('Location', `/orders/${order.id}`).send()
})

Różnica z 302: 302 historycznie miało znaczyć „zachowaj metodę", ale przeglądarki zawsze zmieniały POST na GET. RFC 9110 udokumentowało tę niezgodność. 303 jest eksplicite o tym — żadnej niejednoznaczności.

Scenariusz 4: Trwała zmiana endpointu API z POST

Kod: 308 Permanent Redirect. W odróżnieniu od 301, gwarantuje zachowanie metody i ciała.

// Stary 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')
})

Klient, który wysłał POST z body 2 KB, dostanie 308 Location: /api/v2/orders i wykona POST z tym samym body. 301 tu NIE JEST BEZPIECZNE — niektóre biblioteki HTTP (starsze curl, stare axios) zamienią POST na GET.

Scenariusz 5: Open redirect — klasyczny atak phishingowy

Najczęstsza podatność aplikacji, którą widzę na audycie. Endpoint trackingu kliknięć:

// PODATNE — NIE USE
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
  return reply.redirect(302, req.query.url)
})

Atak: SMS „Twoje konto bank.pl zostanie zablokowane, zweryfikuj: https://bank.pl/go?url=https://bank-login-verify.evil/auth". Ofiara widzi zaufaną domenę, klika, dostaje 302 Location: https://bank-login-verify.evil/auth, ląduje na phishingu. Podanie hasła = przejęte konto.

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' })
  }

  // Tylko HTTPS, tylko znane hosty
  if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.host)) {
    return reply.code(400).send({ error: 'domain_not_allowed' })
  }

  return reply.redirect(302, target.toString())
})

Wybór 302 (nie 301) jest celowy — nie chcemy, żeby przeglądarka cachowała „kliknij tu → ląduj tam", to ma być świeża decyzja przy każdym kliknięciu.

4. Upload i range requests — w tym Apache Killer

Upload pliku to miejsce, gdzie pojawia się 413 (plik za duży), 415 (zły typ), 416 (nieprawidłowy range) i 206 (partial content — wideo z seekingiem). Plus Apache Killer — 15-letni atak, który wciąż działa na źle skonfigurowanych serwerach.

Endpoint uploadu z walidacją

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 może być ominięty, sprawdź 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 z nagłówka może kłamać
    const realMime = await detectMimeFromBytes(path)   // np. 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 })
  })
}

Dlaczego magic bytes check — nie ufaj klientowi

data.mimetype to Content-Type z nagłówka multipartu — klient może tam wpisać dowolny string. Atakujący prześle .exe z Content-Type: image/png, a potem w innym miejscu aplikacji ten plik zostanie podany do img src — co nie jest groźne — ale do iframe albo Content-Type w odpowiedzi, i przeglądarka wykona binarkę. Weryfikacja przez file-type/libmagic czyta pierwsze bajty i rozpoznaje typ naprawdę.

Range requests — serwowanie wideo z seekingiem

Video player wysyła Range: bytes=1048576-2097151 — chce środkowy fragment. Serwer odpowiada 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, całość
  }

  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

Atakujący wysyła header z tysiącami nakładających się zakresów:

Range: bytes=0-,0-1,0-2,0-3,0-4,0-5,0-6,0-7,...×1300

Serwer próbuje przygotować i połączyć wszystkie te fragmenty — OOM, crash. Fix w Apache/Nginx zapadł już dawno, ale jeśli piszesz własną obsługę Range (powyżej) — musisz ograniczyć liczbę i rozmiar zakresów. Mój handler akceptuje tylko pojedynczy range, co jest bezpieczne. Pełne RFC 7233 wspiera multipart/byteranges z wieloma zakresami — implementuj tylko jeśli naprawdę potrzebujesz, z limitem max 10 zakresów.

5. Async jobs — 202 Accepted + polling

Export ZIP ma ~62 sekundy przy dużym koncie (PageForYou RODO export). Synchronicznie — przeglądarka zwraca timeout, Cloudflare 524. Poprawnie — 202 Accepted + endpoint statusu + polling po stronie klienta.

Endpoint tworzący job

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}`,
    })
})

Endpoint statusu — co zwrócić, zależnie od etapu

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' })

  // Autoryzacja — tylko właściciel joba
  if (job.data.userId !== req.user.id) {
    return reply.code(404).send({ error: 'job_not_found' })   // 404 celowo, nie 403
  }

  const state = await job.getState()

  if (state === 'completed') {
    // Gotowe — redirect na 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,
    })
  }

  // Wciąż się wykonuje
  return reply
    .code(200)
    .header('Retry-After', '5')    // hint dla klienta — poll za 5s
    .send({
      jobId: job.id,
      status: state,                 // waiting, active, delayed
      progress: job.progress,
    })
})

Dlaczego 404 zamiast 403 przy cudzym jobie

Atakujący znający format job ID mógłby enumerować /exports/0, /exports/1, ... i po statusach (403 vs 404) mapować aktywność innych userów. Zwracając zawsze 404 gdy job nie jest Twój — atakujący nie odróżnia „nie istnieje" od „nie Twoje".

Dlaczego 303 po zakończeniu, nie 200

Klient pytający o status chce URL do pobrania gdy gotowe. 303 See Other z Location: signed-url mówi „idź tam GET-em". Klient może follow-redirect automatycznie, użytkownik dostaje plik. 200 z { downloadUrl: "..." } wymaga od klienta osobnego GET-a i osobnej logiki — 303 jest czyściej.

6. Cache walidacja — ETag i 304

Pełny przykład cache walidacji w blog API, takim jak ten, który czytasz. Cel: zmniejszyć bandwidth 10×, obniżyć czas renderu SSR.

Endpoint z ETag-iem i walidacją

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, /* i reszta pól */
              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']

  // Walidacja cache — klient ma aktualną wersję?
  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()                                      // pusty 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)
})

Jak to wykorzystuje Next.js / Cloudflare

Next.js ISR z revalidate: 60 wywoła fetch do Twojego API co 60 s. Z ETag-iem:

  • Pierwszy fetch → 200 OK + ETag + ciało (50 KB)
  • Kolejne revalidation fetch (co 60 s) → If-None-Match: "abc"304 + 0 bajtów

Oszczędność: ~50 KB × ilość revalidation × ilość serwerów edge. Przy 10 regionach Cloudflare × 60 revalidation/h = 600 fetch/h/post. 50 KB × 600 = 30 MB/h → bez ETag. Z ETag → 600 × ~200 B (sam nagłówek) = 120 KB/h. 250× mniej.

Co MUSI trafić do ETag-a

Każda zmiana obserwowalna przez klienta. Minimum:

  • id — różne posty mają różne etagi nawet gdy updatedAt przypadkowo identyczne
  • updatedAt — bump przy edycji
  • version — w przypadku kaskadowych zmian (komentarze, licznik wyświetleń) zwykle lepiej mieć osobne pole niż polegać na updatedAt

Czego NIE dawać do ETag-a: hasha hasła, sekretów, danych innych userów. ETag jest publiczny — trafia do logów CDN, nagłówków przeglądarki.

7. Error handling w produkcji (500, 502, 503, 504)

Te cztery kody wyglądają podobnie, ale każdy znaczy coś innego i każdy ma inną reakcję po stronie klienta / monitoringu.

500 — twój kod padł

Exception w handlerze. Setup, który nie przecieka stack trace na prod i daje requestId do korelacji:

fastify.setErrorHandler((err, req, reply) => {
  req.log.error({ err, reqId: req.id, url: req.url, method: req.method }, 'unhandled')

  // Błędy biznesowe — przepuść
  if (err.statusCode && err.statusCode < 500) {
    return reply.code(err.statusCode).send({
      error: err.code || 'error',
      message: err.message,
    })
  }

  // Prawdziwe 500 — ukryj szczegóły
  return reply.code(500).send({
    error: 'internal_error',
    requestId: req.id,   // do logów
    // NIE: err.message, err.stack, err.sql, ścieżki plików
  })
})

Gdy user zgłasza „dostałem błąd", pyta „jaki request ID?" i znajdujesz w logach pełny stack trace. User nie widzi nic wrażliwego.

502 i 504 — Nginx → backend

Obydwa automatyczne z Nginx. 502 = backend zwrócił śmieci (upstream padł w trakcie, reset connection, invalid HTTP response). 504 = backend nie odpowiedział w czasie (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 na następny upstream
    proxy_next_upstream error timeout http_502 http_503;
    proxy_next_upstream_tries 2;

    # Usuń nagłówki wycieku
    proxy_hide_header X-Powered-By;
    proxy_hide_header Server;
  }
}

503 — czasowo, Retry-After

503 to JEDYNY z 5xx, który jasno mówi klientowi „spróbuj ponownie". Użyj gdy:

  • Planowana konserwacja (deploy blue-green)
  • Circuit breaker otwarty (zależny serwis padł)
  • Load shedding (za dużo RPS, chronimy pozostałych)

Zwykle z Retry-After + custom body:

// Circuit breaker — gdy DB jest przeciążone
fastify.addHook('preHandler', async (req, reply) => {
  if (await circuitBreaker.isOpen()) {
    return reply
      .code(503)
      .header('Retry-After', '30')
      .send({
        error: 'service_degraded',
        retryAfter: 30,
      })
  }
})

Kluczowa różnica 500 vs 503: 500 = bug, SRE dostaje page. 503 = oczekiwane, monitoring odróżnia (nie alerting). Zwracanie 503 zamiast 500 żeby „nie alarmować" — anty-wzorzec. Alarmy są od tego, żeby alarmować.

8. Tabela referencyjna — wszystkie 63 kody z przypisaniem do RFC

Gdy musisz szybko sprawdzić konkretny kod — tabela poniżej. Wszystkie 63 oficjalne wpisy z rejestru IANA z odnośnikiem do definiującego RFC.

1xx — Informacyjne (4 kody)

KodNazwaRFCKiedy
100ContinueRFC 9110 §15.2.1Klient wysłał Expect: 100-continue, serwer zgadza się na body
101Switching ProtocolsRFC 9110 §15.2.2WebSocket handshake, HTTP/2 upgrade
102Processing ⚠RFC 2518 (deprecated)WebDAV, usunięte z RFC 4918
103Early HintsRFC 8297Preload hint przed finalną odpowiedzią (Chrome 103+)

2xx — Sukces (10 kodów)

KodNazwaRFCKiedy
200OKRFC 9110 §15.3.1Sukces z ciałem
201CreatedRFC 9110 §15.3.2Nowy zasób, + Location
202AcceptedRFC 9110 §15.3.3Async job zakolejkowany
203Non-AuthoritativeRFC 9110 §15.3.4Odpowiedź z transformującego proxy
204No ContentRFC 9110 §15.3.5DELETE OK, brak ciała
205Reset ContentRFC 9110 §15.3.6Resetuj formularz (rzadko)
206Partial ContentRFC 9110 §15.3.7Range request
207Multi-StatusRFC 4918 §11.1WebDAV batch
208Already ReportedRFC 5842 §7.1WebDAV binding, unikaj duplikatów
226IM UsedRFC 3229Delta encoding (rzadko)

3xx — Przekierowania (9 kodów)

KodNazwaRFCKiedy
300Multiple ChoicesRFC 9110 §15.4.1Kilka reprezentacji (rzadko)
301Moved PermanentlyRFC 9110 §15.4.2Trwale, GET (może zmienić POST→GET)
302FoundRFC 9110 §15.4.3Tymczasowo, GET (dwuznaczne)
303See OtherRFC 9110 §15.4.4Post-Redirect-Get
304Not ModifiedRFC 9110 §15.4.5Cache walidacja ETag/If-Modified-Since
305Use Proxy ⚠RFC 9110 §15.4.6 (deprecated)Security vulnerability, nie używaj
306(Unused)RFC 9110 §15.4.7Zarezerwowane
307Temporary RedirectRFC 9110 §15.4.8Tymczasowo, zachowuje metodę
308Permanent RedirectRFC 9110 §15.4.9Trwale, zachowuje metodę

4xx — Błędy klienta (28 kodów)

KodNazwaRFCKiedy
400Bad RequestRFC 9110 §15.5.1Zły format requestu
401UnauthorizedRFC 9110 §15.5.2Brak / zły token, + WWW-Authenticate
402Payment RequiredRFC 9110 §15.5.3Zarezerwowane, Stripe „card declined"
403ForbiddenRFC 9110 §15.5.4Token OK, brak uprawnień
404Not FoundRFC 9110 §15.5.5Zasób nie istnieje
405Method Not AllowedRFC 9110 §15.5.6+ Allow
406Not AcceptableRFC 9110 §15.5.7Content negotiation nie pasuje
407Proxy Auth RequiredRFC 9110 §15.5.8Jak 401 dla proxy
408Request TimeoutRFC 9110 §15.5.9Za wolne wysyłanie body
409ConflictRFC 9110 §15.5.10Unique violation, state machine
410GoneRFC 9110 §15.5.11Usunięty na zawsze
411Length RequiredRFC 9110 §15.5.12Brak Content-Length
412Precondition FailedRFC 9110 §15.5.13If-Match / If-Unmodified-Since
413Content Too LargeRFC 9110 §15.5.14Body przekracza limit
414URI Too LongRFC 9110 §15.5.15URL za długi
415Unsupported Media TypeRFC 9110 §15.5.16Zły Content-Type
416Range Not SatisfiableRFC 9110 §15.5.17Zły Range
417Expectation FailedRFC 9110 §15.5.18Nie spełnia Expect
418I'm a teapotRFC 2324 (żart)Honeypot dla skanerów
421Misdirected RequestRFC 9110 §15.5.20HTTP/2 connection coalescing
422Unprocessable ContentRFC 9110 §15.5.21Walidacja biznesowa
423LockedRFC 4918 §11.3WebDAV
424Failed DependencyRFC 4918 §11.4WebDAV
425Too EarlyRFC 8470TLS 1.3 0-RTT replay protection
426Upgrade RequiredRFC 9110 §15.5.22Wymuś nowszy protokół
428Precondition RequiredRFC 6585 §3Wymuś If-Match (lost update)
429Too Many RequestsRFC 6585 §4Rate limit, + Retry-After
431Request Headers Too LargeRFC 6585 §5Nagłówki przekroczyły limit
451Unavailable For Legal ReasonsRFC 7725RODO, DMCA, geoblock

5xx — Błędy serwera (11 kodów)

KodNazwaRFCKiedy
500Internal Server ErrorRFC 9110 §15.6.1Exception w kodzie
501Not ImplementedRFC 9110 §15.6.2Nieznana metoda HTTP
502Bad GatewayRFC 9110 §15.6.3Proxy dostał śmieci z backendu
503Service UnavailableRFC 9110 §15.6.4Czasowo, + Retry-After
504Gateway TimeoutRFC 9110 §15.6.5Proxy → backend timeout
505HTTP Version Not SupportedRFC 9110 §15.6.6Zły HTTP/X
506Variant Also NegotiatesRFC 2295 §8.1Błąd content negotiation (rzadko)
507Insufficient StorageRFC 4918 §11.5Brak miejsca na dysku (upload)
508Loop DetectedRFC 5842 §7.2WebDAV recursion
510Not Extended ⚠RFC 2774 (deprecated)HTTP Extension Framework
511Network Authentication RequiredRFC 6585 §6Captive portal (WiFi hotel/lotnisko)

Źródła

  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, Czerwiec 2022, rfc-editor.org/rfc/rfc9110
  3. L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, Czerwiec 2007, rfc-editor.org/rfc/rfc4918
  4. M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, Kwiecień 2012, rfc-editor.org/rfc/rfc6585
  5. T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, Luty 2016
  6. K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, Grudzień 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
Kody HTTP — kompletny przewodnik dla programistów: wszystkie statusy, zastosowania i pułapki bezpieczeństwa — PageForYou.pl