Programowanie 24 april 2026 27 min

HTTP-statuskoder — komplett guide för programmerare: alla statusar, tillämpningar och säkerhetsfällor

De flesta dokument om HTTP-statuskoder är listor med 63 poster och enradiga definitioner. Mycket kan inte utläsas från det. Det här dokumentet går motsatt väg — sju verkliga scenarier som du skriver i backend, varje med fullständig fungerande kod Fastify + Prisma, visad i två versioner: med säkerhetsbuggar och utan. HTTP-statuskoder dyker upp där de alltid dyker upp — i sammanhang. I slutet en referenstabell över alla 63 koder med RFC-tilldelning — när du behöver söka efter nummer.

1. REST API — CRUD för inlägg

Vi börjar med det vanligaste fallet. Vi skriver en endpoint som hanterar blogginlägg. I den kommer naturligtvis åtta HTTP-koder att dyka upp: 200, 201, 204, 304, 400, 404, 409, 422. Var och en på sin plats, inte för att "den var tvungen att användas", utan för att den löser ett konkret problem.

Fullständig 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 (med ETag och 304-validering)
  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()                     // utan innehål
    }
    return reply.header('ETag', currentEtag).send(post) // 200 OK med 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 — dålig struktur
        error: 'invalid_body',
        issues: parsed.error.flatten(),
      })
    }
    const data = parsed.data

    // Affärslogik-validering — 422, för format var OK
    if (data.publishAt && new Date(data.publishAt) < new Date()) {
      return reply.code(422).send({
        error: 'publish_at_in_past',
        message: 'publishAt måste vara i framtiden',
      })
    }

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

    // Optimistic lock — klienten skickade If-Match med 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: 'Någon ändrade inlägget — hämta den aktuella versionen',
      })
    }

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

Varför dessa koder och inte andra — beslut efter beslut

Varför 201 + Location istället för 200

200 OK säger "operationen lyckades". 201 Created säger "den lyckades och en ny entitet skapades". Skillnaden är praktisk: en klient som får 201 med Location: /posts/abc vet exakt att den kan göra GET /posts/abc och få den nyligen skapade resursen. En klient som får 200 måste extrahera ID:t från kroppen — vilket kräver ett kontrakt. Kontraktet kan ändras och förstöra klienter; Location är standarden.

Varför 204 för DELETE istället för 200

Soft delete lyckades, vi har inget meningsfullt att returnera — 204 No Content. RFC 9110 förbjuder kropp vid 204, så fetch() i webbläsaren hanterar automatiskt denna respons som "ingen kropp" och försöker inte tolka JSON. Att returnera 200 med {"deleted":true} tvingar klienten att hantera ett sådant format; 204 är standarden.

Varför jag särskiljer 400 och 422

Zod returnerar två typer av fel. "Saknat fält title" — syntax, begärans format är dåligt, 400 Bad Request. "publishAt i det förflutna" — format är OK (det är ett giltigt ISO-datum), men domänlogiken vägrar. Det är 422 Unprocessable Content. GitHub, Stripe, Rails gör det konsekvent så här — och det är vettigt, för klienten behandlar 400 som en bugg (vi komponerar begäran felaktigt), medan 422 är en vägran (användaren försöker göra något affärsmässigt olagligt).

Varför 409 för unique constraint

Prisma kastar P2002 när två användare samtidigt infogar ett inlägg med samma slug. Kod 400 skulle vara vilseledande — begäran var korrekt. 422 skulle vara konstigt — det är inte en affärsregel, det är en tillståndskonflikt. 409 Conflict är exakt det: "jag kan inte göra det här, för det strider mot det aktuella tillståndet".

Varför 412 vid optimistic lock

If-Match är ett förutsättande villkor. När det inte uppfylls, säger specifikationen (RFC 9110 §13.1.1) tydligt: 412 Precondition Failed. Inte 409 — klienten är inte i en tillståndskonflikt, den hamnade själv i den för att den har en gammal version.

Varför 304 istället för att serva helt svar

En klient med ETag "abc123" skickar If-None-Match: "abc123". Om inlägget inte ändrats — returnera 304, tom kropp. Det sparar bandbredd (inlägget kan vara 50 KB innehål), det sparar JSON-serialisering, det sparar gzip. Cloudflare/Varnish förstår 304 och försöker inte cachea det som en svar. I en typisk CMS — 80%+ av förfrågningarna till publicerade inlägg kan sluta med 304.

Test som verifierar besluten

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

test('POST /posts returnerar 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 med publishAt i det förflutna → 422 (inte 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',  // förflutet
    },
  })
  expect(res.statusCode).toBe(422)   // semantik, inte syntax
  expect(res.json().error).toBe('publish_at_in_past')
})

test('DELETE returnerar 204 utan kropp', async () => {
  const res = await app.inject({ method: 'DELETE', url: '/posts/existing-id' })
  expect(res.statusCode).toBe(204)
  expect(res.body).toBe('')         // tom sträng, inte "{}"
})

test('GET med If-None-Match returnerar 304 utan kropp', 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 med förfallen If-Match → 412', async () => {
  const res = await app.inject({
    method: 'PATCH', url: '/posts/abc',
    headers: { 'if-match': '"gammal-version"' },
    payload: { title: 'Ny' },
  })
  expect(res.statusCode).toBe(412)
})

2. Inloggning och sessioner — 4 verkliga buggar, 4 fixes

Inloggningsendpointen är en plats där fyra olika säkerhetsbuggar visar sig genom opretentiösa HTTP-kod-/svarformat-/loggningsbeslut. Nedan är den fullständiga vägen: först versionen med buggar (exakt sådan vi skriver på första iterationen), sedan varje bugg extraherad på bordet och fixad, sedan den slutliga versionen med test.

Version med buggar (ANVÄND INTE)

// apps/api/src/routes/auth/login.ts — version med 4 buggar
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')   // BUGG #4

    const user = await prisma.user.findUnique({ where: { email } })
    if (!user) {                                          // BUGG #1
      return reply.code(404).send({ error: 'user_not_found' })
    }

    const ok = await bcrypt.compare(password, user.passwordHash)
    if (!ok) {                                            // BUGG #2
      return reply.code(401).send({ error: 'wrong_password' })
    }

    const attempts = await redis?.incr(`login:${email}`).catch(() => null)
    if (attempts === null) {                              // BUGG #3
      // Redis kraschade — vi tillåter vidare, säkerhet < tillgänglighet
    } else if (attempts > 5) {
      return reply.code(429).send({ error: 'too_many' })
    }

    const token = fastify.jwt.sign({ userId: user.id })
    return reply.send({ token })
  })
}

Bugg #1 — användaruppräkning genom olika koder

Endpointen returnerar 404 user_not_found när e-postadressen inte finns, 401 wrong_password när den finns men lösenordet är fel. En angripare med en lista på 500 000 e-postadresser (läcka från en annan tjänst) filtrerar bort på några timmar alla aktiva konton för ditt företag. HTTP-statuskoder hamnar i proxy/CDN/access-loggar — du behöver inte ens läsa kroppen.

Fix:

// DUMMY_HASH — förkalkylerad bcrypt-hash av strängen "dummy" (kostnad 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' })
}

Nu får angriparen identisk 401 invalid_credentials oavsett om kontot existerar. Det finns inget sätt att skilja.

Bugg #2 — timing attack

Fixet från #1 verkar bara lösa problemet. Om du läser det noggrant — när user inte finns, hashToCompare = DUMMY_HASH, bcrypt.compare tar ~80 ms. När user finns, bcrypt jämför med dess riktiga hash — också ~80 ms (förutsatt samma kostnad). OK, i detta specifika fragment är tidningen utjämnad.

Men observera: i den ursprungliga versionen med buggar körs den första grenen if (!user) return ... på ~2 ms (bara databasfråga), den andra på ~82 ms (fråga + bcrypt). En angripare mäter genomsnittstiden från 100 försök:

// attacker.ts — visar skillnaden
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

Skillnaden på 80 ms är en evighet — angriparen enumererar konton utan att titta på HTTP-statuskoden. Fixet kräver dummy bcrypt.compare för icke-existerande användare (se kod ovan — den är redan korrekt). Dessutom en regressions-test:

test('login avslöjar inte användarens existens genom 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)   // mindre än 5ms skillnad
})

Bugg #3 — fail-open rate limit

redis?.incr(...).catch(() => null) — när Redis kraschade, attempts === null, villkoret attempts > 5 är false, gränsen är inaktiverad. Verklig incident (från revision på PageForYou.pl i april 2026): OOM på Redis + angripare samtidigt + 200 000 lösenordsförsök innan någon märkte.

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)   // glidande fönster 1 min
} catch {
  // Redis kraschade — vi tillåter INTE vidare
  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, medveten: om Redis kraschade kan ingen logga in i ~30 s (tid för Redis-återhämtning + expire). Vi accepterar det — säkerhet > tillgänglighet. Alternativet är en lokal fallback (in-memory-karta), men då har ett distribuerat system en okoordinerad gräns — varje pod räknar separat, reellt N× 5 försök istället för 5.

Bugg #4 — lösenord i loggar

req.log.info({ email, password }, ...) → Loki/ELK/Splunk får lösenordet i klartext. Alla med tillgång till övervakning (SRE, DevOps, underleverantör) ser det. Klassisk läcka.

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 fungerar på Pino-serializernivå — lösenorden hamnar aldrig i transporten, inte ens av misstag någon annanstans i koden.

Slutlig version av endpointen

// apps/api/src/routes/auth/login.ts — efter alla 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 (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' })
    }

    // Framgång — återställ rate limit + utfärda 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 })
  })
}

Varför 401 och inte 403 för misslyckad inloggning

Klassiskt misstag: eftersom användaren gett "fel lösenord", kanske 403 Forbidden? Nej. 401 betyder "jag vet inte vem du är, autentisera dig". 403 betyder "jag vet vem du är, men du har ingen behörighet till denna resurs". Vid inloggning misslyckades autentisering — 401. Jag använder 403 bara för inaktiverat konto, där identitet var OK, men policy vägrar vidare.

3. Omdirigeringar — 5 scenarier utan att skjuta sig själv i foten

Fem olika omdirigeringar, var och en med en annan anledning. Valet av fel kod ändrar POST till GET, bryter SEO, eller öppnar open redirect.

Scenario 1: HTTP → HTTPS (globalt)

Kod: 301 + HSTS. Permanent, för alla metoder (GET dominerar; för POST är det lika många reparationer, webbläsaren gör vad den ska göra).

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

Fälla: den första HTTP-begäran kan innehålla sessionscookies i klartext. HSTS löser detta först från det andra besöket — efter ett första lyckat HTTPS-svar kommer webbläsaren ihåg och försöker inte längre HTTP. Det första besöket måste antingen via explicit https://, eller preload-lista (hstspreload.org).

Scenario 2: Gammal URL → ny efter omvarumärkesnamn

Kod: 301. Google kommer att avindexera den gamla URL:en och skriva över sidrangordningen.

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

Fälla: 301 cachas aggressivt av webbläsaren (ibland för alltid). Om du missar mål-URL:en kommer användare att landa där även efter att du fixat det, tills de rensar cache. Om du inte är 100% säker — använd 302 från början, ändra till 301 när du är säker.

Scenario 3: Post-Redirect-Get efter formulär

Kod: 303 See Other. Tvingar GET istället för POST — F5 på bekräftelsesidan orsakar inte duplicerad order.

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

Skillnad från 302: 302 avsåg historiskt att "bevara metod", men webbläsare ändrade alltid POST till GET. RFC 9110 dokumenterade denna avvikelse. 303 är explicit om det — ingen tvetydlighet.

Scenario 4: Permanent förändring av API-endpoint från POST

Kod: 308 Permanent Redirect. Till skillnad från 301, garanterar bevarande av metod och kropp.

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

En klient som skickade POST med 2 KB-kropp får 308 Location: /api/v2/orders och utför POST med samma kropp. 301 är INTE SÄKERT här — vissa HTTP-bibliotek (äldre curl, gamla axios) konverterar POST till GET.

Scenario 5: Open redirect — klassisk phishing-attack

Vanligaste sårbarheten jag ser på revision. Endpoint för spårning av klick:

// SÅRBAR — ANVÄND INTE
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
  return reply.redirect(302, req.query.url)
})

Attack: SMS "Ditt bank.pl-konto kommer att blockeras, verifiera: https://bank.pl/go?url=https://bank-login-verify.evil/auth". Offret ser en betrodd domän, klickar, får 302 Location: https://bank-login-verify.evil/auth, landar på phishing. Inmatning av lösenord = komprometterat 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' })
  }

  // Endast HTTPS, endast kända värdar
  if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.host)) {
    return reply.code(400).send({ error: 'domain_not_allowed' })
  }

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

Valet av 302 (inte 301) är avsiktligt — vi vill inte att webbläsaren cachear "klicka här → landa där", det ska vara ett friskt beslut vid varje klick.

4. Upload och range requests — inklusive Apache Killer

Upload av fil är en plats där 413 (fil för stor), 415 (fel typ), 416 (ogiltigt intervall) och 206 (partial content — video med sökning) dyker upp. Plus Apache Killer — 15-årig attack som fortfarande fungerar på felkonfigurerade servrar.

Upload-endpoint med validering

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-gränsen kan kringgås, kontrollera 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 från header kan ljuga
    const realMime = await detectMimeFromBytes(path)   // t.ex. 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 })
  })
}

Varför magic bytes check — förlita dig inte på klienten

data.mimetype är Content-Type från multipart-headern — klienten kan skriva vilken sträng som helst där. En angripare skickar .exe med Content-Type: image/png, och senare på andra ställen i applikationen serveras denna fil till img src — vilket inte är farligt — men till iframe eller Content-Type i svaret, och webbläsaren exekverar binären. Verifiering via file-type/libmagic läser de första bytena och känner igen typen verkligen.

Range requests — serwering av video med sökning

Videospelaren skickar Range: bytes=1048576-2097151 — den vill ha mittfragmentet. Servern svarar 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, helhet
  }

  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

Angriparen skickar ett header med tusentals överlappande intervall:

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

Servern försöker förbereda och slå samman alla dessa fragment — OOM, crash. Fixet i Apache/Nginx fastnade redan för länge sedan, men om du skriver din egen Range-hantering (ovan) — måste du begränsa antalet och storleken på intervall. Min handler accepterar bara ett enskilt intervall, vilket är säkert. Fullständig RFC 7233 stöder multipart/byteranges med flera intervall — implementera bara om du verkligen behöver det, med gränsen max 10 intervall.

5. Async jobs — 202 Accepted + polling

GDPR-export ZIP har ~62 sekunder på stor konto (PageForYou GDPR-export). Synkront — webbläsaren returnerar timeout, Cloudflare 524. Korrekt — 202 Accepted + statusendpoint + polling från klientsida.

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

Statusendpoint — vad ska returneras, beroende på fas

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

  // Auktorisering — endast jobbets ägare
  if (job.data.userId !== req.user.id) {
    return reply.code(404).send({ error: 'job_not_found' })   // 404 avsiktligt, inte 403
  }

  const state = await job.getState()

  if (state === 'completed') {
    // Klar — omdirigering till 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,
    })
  }

  // Fortfarande körs
  return reply
    .code(200)
    .header('Retry-After', '5')    // tips till klient — poll efter 5s
    .send({
      jobId: job.id,
      status: state,                 // waiting, active, delayed
      progress: job.progress,
    })
})

Varför 404 istället för 403 på andras job

En angripare som känner till jobbets ID-format kunde enumerera /exports/0, /exports/1, ... och efter status (403 vs 404) kartlägga annan användaraktivitet. Genom att alltid returnera 404 när jobbet inte är ditt — kan angriparen inte skilja mellan "existerar inte" och "inte ditt".

Varför 303 när det är klart, inte 200

En klient som frågar efter status vill ha URL till nedladdning när den är klar. 303 See Other med Location: signed-url säger "gå dit GET-em". Klienten kan automatiskt följa omdirigering, användare får filen. 200 med { downloadUrl: "..." } kräver att klienten gör en separat GET och separat logik — 303 är renare.

6. Cache-validering — ETag och 304

Fullständigt exempel på cache-validering i blog-API, som den du läser. Mål: minska bandbredd 10×, minska SSR-renderningstid.

Endpoint med ETag och validering

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, /* och resten av fälten */
              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-validering — har klienten aktuell 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()                                      // tom kropp
  }

  return reply
    .code(200)
    .header('ETag', currentEtag)
    .header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
    .header('Vary', 'Accept-Language')
    .send(post)
})

Hur detta används av Next.js / Cloudflare

Next.js ISR med revalidate: 60 anropar fetch till ditt API var 60:e sekund. Med ETag:

  • Första fetch → 200 OK + ETag + kropp (50 KB)
  • Efterföljande revalidation-fetch (var 60:e sekund) → If-None-Match: "abc"304 + 0 byte

Besparelse: ~50 KB × antal revalidation × antal servrar. Med 10 Cloudflare-regioner × 60 revalidation/h = 600 fetch/h/inlägg. 50 KB × 600 = 30 MB/h → utan ETag. Med ETag → 600 × ~200 B (bara header) = 120 KB/h. 250× mindre.

Vad MÅSTE ingå i ETag

Varje ändring som kan observeras av klienten. Minimum:

  • id — olika inlägg har olika etags även om updatedAt råkar vara identisk
  • updatedAt — bump vid redigering
  • version — vid kaskadändringar (kommentarer, vyräknare) är det vanligtvis bättre att ha ett eget fält än förlita sig på updatedAt

Vad INTE ska sättas i ETag: hash av lösenord, hemligheter, andra användares data. ETag är offentlig — hamnar i CDN-loggar, webbläsarens headers.

7. Felhantering i produktion (500, 502, 503, 504)

Dessa fyra koder ser likadana ut, men var och en betyder något olika och var och en har olika reaktion från klientsida / övervakning.

500 — din kod kraschade

Undantag i handler. Inställning som inte läcker stack trace på prod och ger requestId för korrelation:

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

  // Affärsfel — skicka vidare
  if (err.statusCode && err.statusCode < 500) {
    return reply.code(err.statusCode).send({
      error: err.code || 'error',
      message: err.message,
    })
  }

  // Verklig 500 — göm detaljer
  return reply.code(500).send({
    error: 'internal_error',
    requestId: req.id,   // till loggar
    // INTE: err.message, err.stack, err.sql, filvägar
  })
})

När användare rapporterar "jag fick ett fel", frågar "vilket request ID?" och hittar i loggar en fullständig stack trace. Användare ser ingenting känsligt.

502 och 504 — Nginx → backend

Båda automatiska från Nginx. 502 = backend returnerade skräp (upstream kraschade under tiden, reset connection, ogiltigt HTTP-svar). 504 = backend svarade inte i tid (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;

    # Försök på nästa upstream
    proxy_next_upstream error timeout http_502 http_503;
    proxy_next_upstream_tries 2;

    # Ta bort läckade headers
    proxy_hide_header X-Powered-By;
    proxy_hide_header Server;
  }
}

503 — tillfälligt, Retry-After

503 är DEN ENDA från 5xx-gruppen som tydligt säger till klienten "försök igen". Använd när:

  • Planerat underhåll (deploy blue-green)
  • Circuit breaker öppen (beroende tjänst kraschade)
  • Load shedding (för mycket RPS, skydda resten)

Vanligtvis med Retry-After + custom body:

// Circuit breaker — när DB är överbelastad
fastify.addHook('preHandler', async (req, reply) => {
  if (await circuitBreaker.isOpen()) {
    return reply
      .code(503)
      .header('Retry-After', '30')
      .send({
        error: 'service_degraded',
        retryAfter: 30,
      })
  }
})

Nyckelskillnad 500 vs 503: 500 = bugg, SRE får page. 503 = förväntat, övervakningen skiljer (ingen larmering). Att returnera 503 istället för 500 för att "inte larma" — anti-mönster. Larm är till för att larma.

8. Referenstabell — alla 63 koder med RFC-tilldelning

När du snabbt behöver kontrollera en viss kod — tabellen nedan. Alla 63 officiella poster från IANA-registret med hänvisning till definierade RFC.

1xx — Informativ (4 koder)

KodNamnRFCNär
100ContinueRFC 9110 §15.2.1Klient skickade Expect: 100-continue, server godkänner kropp
101Switching ProtocolsRFC 9110 §15.2.2WebSocket handshake, HTTP/2 upgrade
102Processing ⚠RFC 2518 (inaktuell)WebDAV, borttaget från RFC 4918
103Early HintsRFC 8297Preload hint före slutgiltig svar (Chrome 103+)

2xx — Framgång (10 koder)

KodNamnRFCNär
200OKRFC 9110 §15.3.1Framgång med kropp
201CreatedRFC 9110 §15.3.2Ny resurs, + Location
202AcceptedRFC 9110 §15.3.3Async job köad
203Non-AuthoritativeRFC 9110 §15.3.4Svar från omvandlande proxy
204No ContentRFC 9110 §15.3.5DELETE OK, ingen kropp
205Reset ContentRFC 9110 §15.3.6Återställ formulär (sällsynt)
206Partial ContentRFC 9110 §15.3.7Range request
207Multi-StatusRFC 4918 §11.1WebDAV batch
208Already ReportedRFC 5842 §7.1WebDAV binding, undvik duplikat
226IM UsedRFC 3229Delta encoding (sällsynt)

3xx — Omdirigeringar (9 koder)

KodNamnRFCNär
300Multiple ChoicesRFC 9110 §15.4.1Flera representationer (sällsynt)
301Moved PermanentlyRFC 9110 §15.4.2Permanent, GET (kan ändra POST→GET)
302FoundRFC 9110 §15.4.3Tillfällig, GET (tvetydig)
303See OtherRFC 9110 §15.4.4Post-Redirect-Get
304Not ModifiedRFC 9110 §15.4.5Cache-validering ETag/If-Modified-Since
305Use Proxy ⚠RFC 9110 §15.4.6 (inaktuell)Säkerhetssårbarhet, använd inte
306(Oanvänd)RFC 9110 §15.4.7Reserverad
307Temporary RedirectRFC 9110 §15.4.8Tillfällig, bevarar metod
308Permanent RedirectRFC 9110 §15.4.9Permanent, bevarar metod

4xx — Klientfel (28 koder)

KodNamnRFCNär
400Bad RequestRFC 9110 §15.5.1Dåligt requestformat
401UnauthorizedRFC 9110 §15.5.2Saknad / dålig token, + WWW-Authenticate
402Payment RequiredRFC 9110 §15.5.3Reserverad, Stripe "kort avslaget"
403ForbiddenRFC 9110 §15.5.4Token OK, ingen behörighet
404Not FoundRFC 9110 §15.5.5Resursen existerar inte
405Method Not AllowedRFC 9110 §15.5.6+ Allow
406Not AcceptableRFC 9110 §15.5.7Content negotiation passar inte
407Proxy Auth RequiredRFC 9110 §15.5.8Som 401 för proxy
408Request TimeoutRFC 9110 §15.5.9För långsamt att skicka kropp
409ConflictRFC 9110 §15.5.10Unique violation, state machine
410GoneRFC 9110 §15.5.11Borttagen för alltid
411Length RequiredRFC 9110 §15.5.12Saknar Content-Length
412Precondition FailedRFC 9110 §15.5.13If-Match / If-Unmodified-Since
413Content Too LargeRFC 9110 §15.5.14Kropp överskrider gränsen
414URI Too LongRFC 9110 §15.5.15URL för lång
415Unsupported Media TypeRFC 9110 §15.5.16Dålig Content-Type
416Range Not SatisfiableRFC 9110 §15.5.17Dålig Range
417Expectation FailedRFC 9110 §15.5.18Uppfyller inte Expect
418I'm a teapotRFC 2324 (skämt)Honeypot för skannrar
421Misdirected RequestRFC 9110 §15.5.20HTTP/2 connection coalescing
422Unprocessable ContentRFC 9110 §15.5.21Affärsvalidering
423LockedRFC 4918 §11.3WebDAV
424Failed DependencyRFC 4918 §11.4WebDAV
425Too EarlyRFC 8470TLS 1.3 0-RTT replay protection
426Upgrade RequiredRFC 9110 §15.5.22Tvinga nyare protokoll
428Precondition RequiredRFC 6585 §3Tvinga If-Match (lost update)
429Too Many RequestsRFC 6585 §4Rate limit, + Retry-After
431Request Headers Too LargeRFC 6585 §5Headers överskrider gränsen
451Unavailable For Legal ReasonsRFC 7725GDPR, DMCA, geoblock

5xx — Serverfel (11 koder)

KodNamnRFCNär
500Internal Server ErrorRFC 9110 §15.6.1Undantag i kod
501Not ImplementedRFC 9110 §15.6.2Okänd HTTP-metod
502Bad GatewayRFC 9110 §15.6.3Proxy fick skräp från backend
503Service UnavailableRFC 9110 §15.6.4Tillfällig, + Retry-After
504Gateway TimeoutRFC 9110 §15.6.5Proxy → backend timeout
505HTTP Version Not SupportedRFC 9110 §15.6.6Dålig HTTP/X
506Variant Also NegotiatesRFC 2295 §8.1Content negotiation-fel (sällsynt)
507Insufficient StorageRFC 4918 §11.5Inget diskutrymme (upload)
508Loop DetectedRFC 5842 §7.2WebDAV recursion
510Not Extended ⚠RFC 2774 (inaktuell)HTTP Extension Framework
511Network Authentication RequiredRFC 6585 §6Captive portal (WiFi hotell/flygplats)

Källor

  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, Februari 2016
  6. K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, December 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-statuskoder — komplett guide för programmerare: alla statusar, tillämpningar och säkerhetsfällor — PageForYou.pl