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 gdyupdatedAtprzypadkowo identyczneupdatedAt— bump przy edycjiversion— w przypadku kaskadowych zmian (komentarze, licznik wyświetleń) zwykle lepiej mieć osobne pole niż polegać naupdatedAt
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)
| Kod | Nazwa | RFC | Kiedy |
|---|---|---|---|
| 100 | Continue | RFC 9110 §15.2.1 | Klient wysłał Expect: 100-continue, serwer zgadza się na body |
| 101 | Switching Protocols | RFC 9110 §15.2.2 | WebSocket handshake, HTTP/2 upgrade |
| 102 | Processing ⚠ | RFC 2518 (deprecated) | WebDAV, usunięte z RFC 4918 |
| 103 | Early Hints | RFC 8297 | Preload hint przed finalną odpowiedzią (Chrome 103+) |
2xx — Sukces (10 kodów)
| Kod | Nazwa | RFC | Kiedy |
|---|---|---|---|
| 200 | OK | RFC 9110 §15.3.1 | Sukces z ciałem |
| 201 | Created | RFC 9110 §15.3.2 | Nowy zasób, + Location |
| 202 | Accepted | RFC 9110 §15.3.3 | Async job zakolejkowany |
| 203 | Non-Authoritative | RFC 9110 §15.3.4 | Odpowiedź z transformującego proxy |
| 204 | No Content | RFC 9110 §15.3.5 | DELETE OK, brak ciała |
| 205 | Reset Content | RFC 9110 §15.3.6 | Resetuj formularz (rzadko) |
| 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, unikaj duplikatów |
| 226 | IM Used | RFC 3229 | Delta encoding (rzadko) |
3xx — Przekierowania (9 kodów)
| Kod | Nazwa | RFC | Kiedy |
|---|---|---|---|
| 300 | Multiple Choices | RFC 9110 §15.4.1 | Kilka reprezentacji (rzadko) |
| 301 | Moved Permanently | RFC 9110 §15.4.2 | Trwale, GET (może zmienić POST→GET) |
| 302 | Found | RFC 9110 §15.4.3 | Tymczasowo, GET (dwuznaczne) |
| 303 | See Other | RFC 9110 §15.4.4 | Post-Redirect-Get |
| 304 | Not Modified | RFC 9110 §15.4.5 | Cache walidacja ETag/If-Modified-Since |
| 305 | Use Proxy ⚠ | RFC 9110 §15.4.6 (deprecated) | Security vulnerability, nie używaj |
| 306 | (Unused) | RFC 9110 §15.4.7 | Zarezerwowane |
| 307 | Temporary Redirect | RFC 9110 §15.4.8 | Tymczasowo, zachowuje metodę |
| 308 | Permanent Redirect | RFC 9110 §15.4.9 | Trwale, zachowuje metodę |
4xx — Błędy klienta (28 kodów)
| Kod | Nazwa | RFC | Kiedy |
|---|---|---|---|
| 400 | Bad Request | RFC 9110 §15.5.1 | Zły format requestu |
| 401 | Unauthorized | RFC 9110 §15.5.2 | Brak / zły token, + WWW-Authenticate |
| 402 | Payment Required | RFC 9110 §15.5.3 | Zarezerwowane, Stripe „card declined" |
| 403 | Forbidden | RFC 9110 §15.5.4 | Token OK, brak uprawnień |
| 404 | Not Found | RFC 9110 §15.5.5 | Zasób nie istnieje |
| 405 | Method Not Allowed | RFC 9110 §15.5.6 | + Allow |
| 406 | Not Acceptable | RFC 9110 §15.5.7 | Content negotiation nie pasuje |
| 407 | Proxy Auth Required | RFC 9110 §15.5.8 | Jak 401 dla proxy |
| 408 | Request Timeout | RFC 9110 §15.5.9 | Za wolne wysyłanie body |
| 409 | Conflict | RFC 9110 §15.5.10 | Unique violation, state machine |
| 410 | Gone | RFC 9110 §15.5.11 | Usunięty na zawsze |
| 411 | Length Required | RFC 9110 §15.5.12 | Brak 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 przekracza limit |
| 414 | URI Too Long | RFC 9110 §15.5.15 | URL za długi |
| 415 | Unsupported Media Type | RFC 9110 §15.5.16 | Zły Content-Type |
| 416 | Range Not Satisfiable | RFC 9110 §15.5.17 | Zły Range |
| 417 | Expectation Failed | RFC 9110 §15.5.18 | Nie spełnia Expect |
| 418 | I'm a teapot | RFC 2324 (żart) | Honeypot dla skanerów |
| 421 | Misdirected Request | RFC 9110 §15.5.20 | HTTP/2 connection coalescing |
| 422 | Unprocessable Content | RFC 9110 §15.5.21 | Walidacja biznesowa |
| 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 | Wymuś nowszy protokół |
| 428 | Precondition Required | RFC 6585 §3 | Wymuś If-Match (lost update) |
| 429 | Too Many Requests | RFC 6585 §4 | Rate limit, + Retry-After |
| 431 | Request Headers Too Large | RFC 6585 §5 | Nagłówki przekroczyły limit |
| 451 | Unavailable For Legal Reasons | RFC 7725 | RODO, DMCA, geoblock |
5xx — Błędy serwera (11 kodów)
| Kod | Nazwa | RFC | Kiedy |
|---|---|---|---|
| 500 | Internal Server Error | RFC 9110 §15.6.1 | Exception w kodzie |
| 501 | Not Implemented | RFC 9110 §15.6.2 | Nieznana metoda HTTP |
| 502 | Bad Gateway | RFC 9110 §15.6.3 | Proxy dostał śmieci z backendu |
| 503 | Service Unavailable | RFC 9110 §15.6.4 | Czasowo, + Retry-After |
| 504 | Gateway Timeout | RFC 9110 §15.6.5 | Proxy → backend timeout |
| 505 | HTTP Version Not Supported | RFC 9110 §15.6.6 | Zły HTTP/X |
| 506 | Variant Also Negotiates | RFC 2295 §8.1 | Błąd content negotiation (rzadko) |
| 507 | Insufficient Storage | RFC 4918 §11.5 | Brak miejsca na dysku (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/lotnisko) |
Źródła
- IANA, Hypertext Transfer Protocol (HTTP) Status Code Registry, iana.org/assignments/http-status-codes
- R. Fielding, M. Nottingham, J. Reschke, RFC 9110: HTTP Semantics, Czerwiec 2022, rfc-editor.org/rfc/rfc9110
- L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, Czerwiec 2007, rfc-editor.org/rfc/rfc4918
- M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, Kwiecień 2012, rfc-editor.org/rfc/rfc6585
- T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, Luty 2016
- K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, Grudzień 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