Programowanie 2026 m. balandžio 24 d. 26 min

HTTP kodai — visapusiskas programuotojų vadovas: visi statusai, pritaikymas ir saugumo spąstai

Dauguma HTTP kodų dokumentacijos yra 63 pozicijų sąrašai su vienos eilutės apibrėžimais. Iš to mažai kas pasidaro. Šis dokumentas eina atvirkštiai — septyni realūs scenarijai, kuriuos rašai backende, kiekvienas su pilnu veikiančiu Fastify + Prisma kodu, parodytam dviem versijomis: su saugos klaidomis ir be jų. HTTP kodai atsiranda ten, kur jie visada atsiranda — kontekste. Pabaigoje – visų 63 kodų pamatytėms lentele su priskyrimu RFC – kai reikalingas ieškoti pagal numerį.

1. REST API — CRUD įrašams

Pradedame nuo dažniausio atvejo. Rašome galutinę tašką, kuri tvarko blog įrašus. Joje natūraliai pasirodys aštuoni HTTP kodai: 200, 201, 204, 304, 400, 404, 409, 422. Kiekvienas savo vietoje, ne todėl, kad „reikėjo jį naudoti", o todėl, kad jis išsprendžia konkretų klausimą.

Visas galutinis taškas — 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 (su ETag ir 304 tikrinimais)
  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()                     // be kūno
    }
    return reply.header('ETag', currentEtag).send(post) // 200 OK su 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 — blogas struktūra
        error: 'invalid_body',
        issues: parsed.error.flatten(),
      })
    }
    const data = parsed.data

    // Verslo validacija — 422, nes formatas buvo OK
    if (data.publishAt && new Date(data.publishAt) < new Date()) {
      return reply.code(422).send({
        error: 'publish_at_in_past',
        message: 'publishAt turi būti ateityje',
      })
    }

    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 unikalaus apribojimo
        return reply.code(409).send({                   // 409 Conflict
          error: 'slug_already_exists',
        })
      }
      throw err
    }
  })

  // UPDATE — PATCH /posts/:id (optimistinis sutapimas per 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' })
    }

    // Optimistinis užrakinimas — kliento pasiųlytas If-Match su 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: 'Kažkas pakeitė įrašą tuo metu — perimkite naujausią versiją',
      })
    }

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

Kodėl šie kodai, o ne kiti — sprendimas po sprendimo

Kodėl 201 + Location vietoj 200

200 OK sako „operacija pavyko". 201 Created sako „pavyko ir buvo sukurtas naujas objektas". Skirtumas yra praktinis: klienatas, gavęs 201Location: /posts/abc, tiksliai žino, kad gali atlikti GET /posts/abc ir gauti naujai sukurtą išteklį. Klienatas, gavęs 200, turi išgauti ID iš kūno — tai reikalauja kontrakto. Kontraktą galima pakeisti ir sulaužyti klientus; Location yra standartas.

Kodėl 204 DELETE vietoj 200

Minkštas naikinimas pavyko, mums nėra nieko prasmingo grąžinti — 204 No Content. RFC 9110 draudžia kūną 204 metu, taigi fetch() naršyklėje automatiškai traktuoja šią atsakymą kaip „nėra kūno" ir nepabandą suanalizo JSON. Grąžinimas 200 su {"deleted":true} verčia klientą tvarkytis tokiu formatu; 204 yra standartas.

Kodėl skiriu 400 ir 422

Zod grąžina du klaidų tipus. „Trūksta title lauko" — sintaksė, užklauso formatas yra blogas, 400 Bad Request. „publishAt praeityje" — formatas OK (tai teisinga ISO data), bet domeno logika atsisakys. Tai yra 422 Unprocessable Content. GitHub, Stripe, Rails nuosekliai tai daro — ir tai turi prasmę, nes klienatas traktuoja 400 kaip klaidą (blogai formuojame užklausą), o 422 kaip atsisakymą (vartotojas bando daryti ką nors neleistino verslo atžvilgiu).

Kodėl 409 unikaliam apribojimui

Prisma meta P2002, kai du vartotojai vienu metu įterps įrašą su tuo pačiu slug. Kodas 400 būtų klaidinantis — prašymas buvo teisingas. 422 būtų keistas — tai nėra verslo taisyklė, tai valstybės konfliktas. 409 Conflict yra tiksliai tai: „negaliu to padaryti, nes konfliktuoja su esamą būkle".

Kodėl 412 optimistiniame užrakinime

If-Match yra išankstinė sąlyga. Kai ji neišpildyta, specifikacija (RFC 9110 §13.1.1) aiškiai sako: 412 Precondition Failed. Ne 409 — klienatas nėra konflikt būsenoje, jis savęs ten patalpino, nes turi senąją versiją.

Kodėl 304 vietoje persiunčiant visą atsakymą

Klienatas, turintis ETag "abc123", siunčia If-None-Match: "abc123". Jei įrašas nepasikeitė — grąžiname 304, tuščias kūnas. Tai sutaupo pralaidumą (įrašas gali turėti 50 KB turinio), sutaupo JSON serializacijos, sutaupo gzip. Cloudflare/Varnish supranta 304 ir nepabandys jį talpinti kaip atsakymą. Tipiniame CMS — 80%+ pagrindinių įrašų prašymų gali baigties 304.

Testas patikrinantis sprendimus

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

test('POST /posts grąžina 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 su publishAt praeityje → 422 (ne 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',  // praeitis
    },
  })
  expect(res.statusCode).toBe(422)   // semantika, ne sintaksė
  expect(res.json().error).toBe('publish_at_in_past')
})

test('DELETE grąžina 204 be kūno', async () => {
  const res = await app.inject({ method: 'DELETE', url: '/posts/existing-id' })
  expect(res.statusCode).toBe(204)
  expect(res.body).toBe('')         // tuščias stygus, ne "{}"
})

test('GET su If-None-Match grąžina 304 be kūno', 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 su senamis If-Match → 412', async () => {
  const res = await app.inject({
    method: 'PATCH', url: '/posts/abc',
    headers: { 'if-match': '"senamis-versija"' },
    payload: { title: 'Naujas' },
  })
  expect(res.statusCode).toBe(412)
})

2. Prisijungimas ir sesijos — 4 realios klaidos, 4 taisymai

Prisijungimo galutinis taškas yra viena vieta, kur keturios skirtingos saugos klaidos atsitikia per neakivaizdžius HTTP kodo / atsakymo formos / registravimo sprendimus. Toliau pilna kelionė: pirmiausia versija su klaidomis (tiksliai tokia, kurią rašome pirmoje iteracijoje), tada kiekviena klaida ištraukta ant lentos ir pataisyta, tada galutinė versija su testu.

Versija su klaidomis (NENAUDOTI)

// apps/api/src/routes/auth/login.ts — versija su 4 klaidomis
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')   // KLAIDA #4

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

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

    const attempts = await redis?.incr(`login:${email}`).catch(() => null)
    if (attempts === null) {                              // KLAIDA #3
      // Redis sugedo — leistis toliau, saugumas < prieinamumas
    } else if (attempts > 5) {
      return reply.code(429).send({ error: 'too_many' })
    }

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

Klaida #1 — vartotojo numeracija pagal skirtingus kodus

Galutinis taškas grąžina 404 user_not_found, kai el. pašto nėra, 401 wrong_password, kai yra, bet slaptažodis blogas. Žmogus atakuojantis su 500 tūkst. el. pašto sąrašu (nuotėka iš kito serverio) filtruos per kelias valandas visas aktyvias jūsų bendrovės paskyras. HTTP būsenos pasiekia proxy/CDN/prieigos žurnalus — jums nereikia net skaityti kūno.

Pataisymas:

// DUMMY_HASH — iš anksto apskaičiuotas bcrypt „dummy" hash (kaina 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' })
}

Dabar žmogus atakuojantis gauna identišką 401 invalid_credentials, nepriklausomai nuo to, ar paskyra egzistuoja. Nėra kaip atskirti.

Klaida #2 — laimingas atakos

Taisymas iš #1 tik matyt išsprendžia problemą. Skaitant jį atidžiai — kai user neegzistuoja, hashToCompare = DUMMY_HASH, bcrypt.compare veikia ~80 ms. Kai user egzistuoja, bcrypt lygina su jo tikriuoju hash — taip pat ~80 ms (jei kaina ta pati). OK, šiame konkrečiame fragmente laiminimas yra subalansuotas.

Bet dėmesys: orginalioje versijoje su klaidomis pirmoji šaka if (!user) return ... vykdoma ~2 ms (tik duomenų bazės užklausa), antroji ~82 ms (užklausa + bcrypt). Žmogus atakuojantis matuoja vidutinį laiką iš 100 bandymų:

// attacker.ts — rodo skirtumą
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

80 ms skirtumas yra amžinybė — žmogus atakuojantis suskaičiuoja paskyras, nežiūrint į HTTP būseną. Taisymas reikalauja fiktyvaus bcrypt.compare neegzistuojantiems vartotojams (žiūrėti aukščiau esantis kodas — jau yra teisingas). Be to, regresijos testas:

test('login neparodys vartotojo egzistencijos per laimą', async () => {
  const warmup = 10, samples = 200

  // šildyti
  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)   // mažiau nei 5ms skirtumo
})

Klaida #3 — atviroji norma atidaro

redis?.incr(...).catch(() => null) — kai Redis sugenda, attempts === null, sąlyga attempts > 5 yra false, riba išjungta. Realus incidentas (iš audito PageForYou.pl 2026 balandžio): OOM Redise + žmogus atakuojantis tuo pačiu metu + 200 tūkst. slaptažodžio bandymų kol kas nors pastebėjo.

Pataisymas — nesėkmė uždaryta:

const key = `login:${email}:${req.ip}`
let attempts: number
try {
  attempts = await redis.incr(key)
  if (attempts === 1) await redis.expire(key, 60)   // slankus langas 1 min
} catch {
  // Redis sugedo — NESILEISTIS toliau
  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' })
}

Kompromisas, sąmoningas: kai Redis sugeda, niekas per ~30 s negali prisijungti (Redis grąžinimo laikas + expire). Sutinkame — saugumas > prieinamumas. Alternatyva yra lokalus fallback (atminties žemėlapis), bet tada paskirstyta sistema turi nesusijungtą ribą — kiekvienas pod skaičiuoja atskirai, realiai N× 5 bandymų vietoj 5.

Klaida #4 — slaptažodis žurnaluose

req.log.info({ email, password }, ...) → Loki/ELK/Splunk gauna slaptažodį plaintext. Kiekvienas su prieiga prie monitoringo (SRE, DevOps, rangovas) jį pamatys. Klasinis nuotėkis.

Pataisymas — 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 veikia Pino serializavimo lygyje — slaptažodžiai niekada nepasiekia transporto, net atsitiktinai kitur kode.

Galutinė loginės versija

// apps/api/src/routes/auth/login.ts — po visų 4 taisymų
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. Norma apribojimas (nesėkmė uždaryta)
    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. Pastovaus laiko vartotojo paieška + palyginimas
    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' })
    }

    // Sėkmė — normo apribojimo nustatymas + žetono emisija
    await redis.del(rateKey).catch(() => {})
    const token = fastify.jwt.sign({ userId: user.id, role: user.role }, { expiresIn: '15m' })
    return reply.send({ token, expiresIn: 900 })
  })
}

Kodėl 401 o ne 403 nesėkmingam prisijungimui

Klasiška klaida: kadangi vartotojas davė „blogą slaptažodį", gal 403 Forbidden? Ne. 401 reiškia „nežinau, kas tu esi, užsiregistruok". 403 reiškia „žinau, kas tu esi, bet neturi šio ištekliaus leidimo". Prisijungime nepavyko autentifikacija — 401. Aš naudoju 403 tik disabled account atveju, kur tapatybė buvo OK, bet politika atsisakai.

3. Peradresavimai — 5 scenarijai be nuo savęs šaudymo

Penki skirtingi peradresavimai, kiekvienas su skirtingu tikslu. Klaidingo kodo pasirinkimas keičia POST į GET, laužo SEO arba atidaro atvirą peradresavimą.

Scenarijus 1: HTTP → HTTPS (globalinis)

Kodas: 301 + HSTS. Nuolatinis, visoms metodoms (GET dominuoja; POST tai lygiai taip pat pataisymas, naršyklė deis, ką turi daryti).

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

Paslaptis: pirmasis HTTP prašymas gali turėti sesijos cookies plaintext. HSTS tai išsprendžia tik nuo antros apsilankymo — po pirmojo sėkmingo HTTPS atsakymo naršyklė atsimena ir nebandys HTTP. Pirmasis apsilankymas turi būti arba per aiškios https://, arba iš anksto įkeltos sąrašo (hstspreload.org).

Scenarijus 2: Senas URL → naujas po rebrandingo

Kodas: 301. Google išbrigs senąjį URL ir perrašys puslapio rangą.

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

Paslaptis: 301 agresyviai talpinama naršykles (kartais amžinai). Jei suklaidini tikslinį URL, vartotojai bus ten nukreipti net po pataisymo, kol nepročys cache. Jei neesi 100% tikras — naudok 302 pradžioje, pakeisk į 301, kai būsi tikras.

Scenarijus 3: Post-Redirect-Get po formos

Kodas: 303 See Other. Verčia GET vietoj POST — F5 patvirtinimo puslapyje nesudarys užsakymo dublikato.

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

Skirtumas su 302: 302 istoriškai reikėjo reiškinti „išsaugok metodą", bet naršyklės visada keistis POST į GET. RFC 9110 udokumentavo šią neatitiktį. 303 yra aiškiai apie tai — jokios nevienareikšmiškumo.

Scenarijus 4: Nuolatinis API galutinio taško pokytis iš POST

Kodas: 308 Permanent Redirect. Priešingai nei 301, garantuoja metodo ir kūno išsaugojimą.

// Senas galutinis taškas /api/v1/orders/create (POST) → /api/v2/orders (POST)
fastify.post('/api/v1/orders/create', (req, reply) => {
  return reply.redirect(308, '/api/v2/orders')
})

Klienatas, kuris pasiuntė POST su 2 KB kūnu, gaus 308 Location: /api/v2/orders ir atliks POST su tuo pačiu kūnu. 301 čia NĖ SAUGUS — kai kurie HTTP bibliotekos (senesnės curl, senos axios) keičia POST į GET.

Scenarijus 5: Atvirtas peradresavimas — klasikinis phishingo atakos

Dažniausiai programa slapta, kurią matau audite. Spustelėjimo stebėjimo galutinis taškas:

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

Ataka: SMS „Jūsų bank.pl sąskaita bus užblokuota, patvirtinkite: https://bank.pl/go?url=https://bank-login-verify.evil/auth". Auka mato patikimą domeną, spustelėja, gauna 302 Location: https://bank-login-verify.evil/auth, nusidaro phishinge. Slaptažodžio davimas = perimta sąskaita.

Pataisymas — leistina sąrašo:

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

  // Tik HTTPS, tik žinomi šeimininkai
  if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.host)) {
    return reply.code(400).send({ error: 'domain_not_allowed' })
  }

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

Pasirinkimas 302 (ne 301) yra tikslingas — nechceme, kad naršyklė talpintų „spustelėk čia → nusidark ten", tai turi būti šviežias sprendimas kaskart spustelėjus.

4. Įkelimas ir diapazonų prašymai — įskaitant Apache Killer

Failų įkelimas yra vieta, kur atsiranda 413 (failas per didelis), 415 (blogas tipas), 416 (netinkamas diapazonas) ir 206 (dalingas turinys — video su ieška). Be Apache Killer — 15 metų ataka, kuri vis dar veikia šešėlinai sukonfigūruotuose serveriuose.

Įkelimo galutinis taškas su validacija

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
    }

    // Sveikatos patikrinimas — multipart limitas gali būti aplenktas, patikrinti tikrą dydį
    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' })
    }

    // Magiškai baitai patikrinimas — mimetype iš nagrinėko gali meluoti
    const realMime = await detectMimeFromBytes(path)   // pvz., 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 })
  })
}

Kodėl magiškai baitai patikrinimas — nešvęsti kliento

data.mimetype yra Content-Type iš multipart nagrinėko — klienatas gali ten parašyti bet kokią eilutę. Žmogus atakuojantis siuntinės .exe su Content-Type: image/png, o vėliau kitur aplikacija tas failas bus duotas į img src — kas nėra pavojinga — bet į iframe arba Content-Type atsakyme, ir naršyklė paleiks binarką. Tikrinimas per file-type/libmagic skaito pirmuosius baitus ir tikrai atpažįsta tipą.

Diapazonų prašymai — video sveisina su paieška

Video grotuvas siunčia Range: bytes=1048576-2097151 — nori vidurinio fragmento. Serveris atsakai 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, visuma
  }

  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

Žmogus atakuojantis siunčia nagrinėką su tūkstančiais persidengimo diapazonų:

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

Serveris bando paruošti ir sujungti visus šiuos fragmentus — OOM, crash. Pataisymas Apache/Nginx jau seniai nupuola, bet jei rašai savąjį Range tvarkymo (aukščiau) — tu turi apriboti skaičių ir diapazonų dydį. Mano tvarkylis priima tik vieną diapazoną, kas yra saugus. Pilna RFC 7233 palaida multipart/byteranges su keliais diapazonais — įgyvendink tik jei tikrai reikia, su max 10 diapazonų limitu.

5. Asinchroniniai darbai — 202 Accepted + buvimo stebėjimas

Eksportas ZIP turi ~62 sekundes dideliam kontui (PageForYou GDPR eksportas). Sinchroniniai — naršyklė grąžina timeout, Cloudflare 524. Teisinga — 202 Accepted + būsenos galutinis taškas + kliento pusė buvimo stebėjimas.

Darbą kuriamąs galutinis taškas

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

Būsenos galutinis taškas — ką grąžinti, priklausomai nuo stadijos

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

  // Autorizacija — tik darbo savininkas
  if (job.data.userId !== req.user.id) {
    return reply.code(404).send({ error: 'job_not_found' })   // 404 tikslingai, ne 403
  }

  const state = await job.getState()

  if (state === 'completed') {
    // Paruoštas — nukreipimas į atsisiuntimo 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,
    })
  }

  // Vis dar vykdomas
  return reply
    .code(200)
    .header('Retry-After', '5')    // užuomina klientui — vėl stebėti per 5s
    .send({
      jobId: job.id,
      status: state,                 // waiting, active, delayed
      progress: job.progress,
    })
})

Kodėl 404 vietoj 403 svetimame darbe

Žmogus atakuojantis žinantis darbo ID formatą galėtu numeroti /exports/0, /exports/1, ... ir pagal būseną (403 vs 404) mapt kitų vartotojų veiklą. Grąžinant visada 404, kai darbas nėra tavo — žmogus atakuojantis neatskirs „neegzistuoja" nuo „ne tavo".

Kodėl 303 baigus, ne 200

Klienatas klausiąs apie būseną nori URL pasiuntus kai paruoštas. 303 See Other su Location: signed-url sako „eik GET". Klienatas gali follow-redirect automatiškai, vartotojas gauna failą. 200 su { downloadUrl: "..." } reikalauja kliento atskiro GET ir atskiros logikos — 303 švariau.

6. Podėlio tikrinimas — ETag ir 304

Pilnas podėlio tikrinimo pavyzdys blog API, tokiame, kurį skaitai. Tikslas: sumažinti pasma 10×, sumažinti SSR render laiką.

Galutinis taškas su ETag ir tikrinimais

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, /* ir likusi kūno */
              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']

  // Podėlio tikrinimas — klienatas turi esamą versiją?
  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()                                      // tuščias kūnas
  }

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

Kaip tai naudoja Next.js / Cloudflare

Next.js ISR su revalidate: 60 iškels fetch į jūsų API kas 60 s. Su ETag:

  • Pirmasis fetch → 200 OK + ETag + kūnas (50 KB)
  • Sekanti revalidation fetch (kas 60 s) → If-None-Match: "abc"304 + 0 baitų

Taupymas: ~50 KB × revalidation kiekis × edge serverių kiekis. Su 10 Cloudflare regionų × 60 revalidation/h = 600 fetch/h/post. 50 KB × 600 = 30 MB/h → be ETag. Su ETag → 600 × ~200 B (tik nagrinėkas) = 120 KB/h. 250× mažiau.

Kas TURI patekti į ETag

Kiekviena kliento stebima savybė. Minimumas:

  • id — skirtingi įrašai turi skirtingus etagus net jei updatedAt atsitiktinai identinis
  • updatedAt — bump redagavus
  • version — staigios kaskados pokyčių atveju (komentarai, peržiūros skaitiklis) paprastai geriau turėti atskirą lauką nei pasikliauti updatedAt

Ko NE dėti į ETag: slaptažodžio heš, paslaptys, kitų vartotojų duomenis. ETag yra viešas — patekia CDN žurnalus, naršyklės nagrinėkus.

7. Klaidų tvarkymas gamyboje (500, 502, 503, 504)

Šie keturi kodai atrodo panašūs, bet kiekvienas reiškia ką nors skirtingo ir kiekvienas turi skirtingą reakciją kliento / monitoringo pusėje.

500 — jūsų kodas sugedo

Išimtis tvarkytuve. Setup, kuris nepaslepia klaidos detalių prod ir duoda requestId korelacijai:

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

  // Verslo klaidos — praleisk
  if (err.statusCode && err.statusCode < 500) {
    return reply.code(err.statusCode).send({
      error: err.code || 'error',
      message: err.message,
    })
  }

  // Tikra 500 — paslėpk detales
  return reply.code(500).send({
    error: 'internal_error',
    requestId: req.id,   // į žurnalus
    // NE: err.message, err.stack, err.sql, failų keliai
  })
})

Kai vartotojas pranešti „gavau klaidą", paklauski „kokia prašyme ID?" ir randi žurnale pilnoje klaidos ataskaitoje. Vartotojas nieko jautrio nematai.

502 ir 504 — Nginx → backend

Abi automatines iš Nginx. 502 = backend grąžino šiukšles (upstream sugedo viduryje, reset connection, neteisingas HTTP atsakymas). 504 = backend neatsakai laike (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;

    # Bandym atgalinį upstream
    proxy_next_upstream error timeout http_502 http_503;
    proxy_next_upstream_tries 2;

    # Pašalink nuotėkio nagrinėkus
    proxy_hide_header X-Powered-By;
    proxy_hide_header Server;
  }
}

503 — laikinai, Retry-After

503 yra VIENINTELĖ iš 5xx, kuri aiškiai sako klientui „bandyk vėl". Naudok kai:

  • Planuota priežiūra (deploy blue-green)
  • Circuit breaker atidarytas (priklausomas serveris sugedo)
  • Load shedding (per didelis RPS, apsaugome likusius)

Paprastai su Retry-After + custom kūnas:

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

Pagrindiniai skirtumai 500 vs 503: 500 = klaida, SRE gauna puslapį. 503 = tikėtina, monitoringa atskirs (ne alerting). Grąžinimas 503 vietoj 500 norėdamas „ne perspėti" — anti-pattern. Perspėjimai yra perspėti.

8. Pagalbinė lentelė — visi 63 kodai su RFC priskyrimu

Kai greitai reikia patikrinti konkretų kodą — lentelė žemiau. Visi 63 oficialūs įrašai iš IANA registracijos su RFC apibrėžimu nuoroda.

1xx — Informaciniai (4 kodai)

KodasPavadinimasRFCKada
100ContinueRFC 9110 §15.2.1Klienatas pasiuntė Expect: 100-continue, serveris sutinka su kūnu
101Switching ProtocolsRFC 9110 §15.2.2WebSocket handshake, HTTP/2 atnaujinimas
102Processing ⚠RFC 2518 (deprecated)WebDAV, pašalinti iš RFC 4918
103Early HintsRFC 8297Preload užuomina prieš galutinį atsakymą (Chrome 103+)

2xx — Sėkmė (10 kodų)

KodasPavadinimasRFCKada
200OKRFC 9110 §15.3.1Sėkmė su kūnu
201CreatedRFC 9110 §15.3.2Naujas išteklis, + Location
202AcceptedRFC 9110 §15.3.3Asinchroninis darbas sukeliamas į eilę
203Non-AuthoritativeRFC 9110 §15.3.4Atsakymas iš transformuojančio proxy
204No ContentRFC 9110 §15.3.5DELETE OK, nėra kūno
205Reset ContentRFC 9110 §15.3.6Atstatyti formą (retai)
206Partial ContentRFC 9110 §15.3.7Diapazonų prašymas
207Multi-StatusRFC 4918 §11.1WebDAV batch
208Already ReportedRFC 5842 §7.1WebDAV binding, venkti duplikatų
226IM UsedRFC 3229Delta kodavimas (retai)

3xx — Peradresavimai (9 kodai)

KodasPavadinimasRFCKada
300Multiple ChoicesRFC 9110 §15.4.1Kelios reprezentacijos (retai)
301Moved PermanentlyRFC 9110 §15.4.2Nuolatiai, GET (gali pakeisti POST→GET)
302FoundRFC 9110 §15.4.3Laikinai, GET (dviguba reikšmė)
303See OtherRFC 9110 §15.4.4Post-Redirect-Get
304Not ModifiedRFC 9110 §15.4.5Podėlio tikrinimas ETag/If-Modified-Since
305Use Proxy ⚠RFC 9110 §15.4.6 (deprecated)Saugos priežastis, nenaudoti
306(Unused)RFC 9110 §15.4.7Rezervuota
307Temporary RedirectRFC 9110 §15.4.8Laikinai, išsaugo metodą
308Permanent RedirectRFC 9110 §15.4.9Nuolatiai, išsaugo metodą

4xx — Kliento klaidos (28 kodai)

KodasPavadinimasRFCKada
400Bad RequestRFC 9110 §15.5.1Blogas prašymo formatas
401UnauthorizedRFC 9110 §15.5.2Trūksta / blogas žetono, + WWW-Authenticate
402Payment RequiredRFC 9110 §15.5.3Rezervuota, Stripe „card declined"
403ForbiddenRFC 9110 §15.5.4Žetono OK, nėra šio ištekliaus leidimo
404Not FoundRFC 9110 §15.5.5Išteklius neegzistuoja
405Method Not AllowedRFC 9110 §15.5.6+ Allow
406Not AcceptableRFC 9110 §15.5.7Turinio derybos neatitinka
407Proxy Auth RequiredRFC 9110 §15.5.8Kaip 401 proxy
408Request TimeoutRFC 9110 §15.5.9Per lėtai siunčiant kūną
409ConflictRFC 9110 §15.5.10Unikalus pažeidimas, valstybės mašina
410GoneRFC 9110 §15.5.11Naikintas visam laikui
411Length RequiredRFC 9110 §15.5.12Trūksta Content-Length
412Precondition FailedRFC 9110 §15.5.13If-Match / If-Unmodified-Since
413Content Too LargeRFC 9110 §15.5.14Kūnas viršija limitą
414URI Too LongRFC 9110 §15.5.15URL per ilgas
415Unsupported Media TypeRFC 9110 §15.5.16Blogas Content-Type
416Range Not SatisfiableRFC 9110 §15.5.17Blogas Range
417Expectation FailedRFC 9110 §15.5.18Neišpildo Expect
418I'm a teapotRFC 2324 (žaismas)Honeypot skenerams
421Misdirected RequestRFC 9110 §15.5.20HTTP/2 jungties sujungimas
422Unprocessable ContentRFC 9110 §15.5.21Verslo validacija
423LockedRFC 4918 §11.3WebDAV
424Failed DependencyRFC 4918 §11.4WebDAV
425Too EarlyRFC 8470TLS 1.3 0-RTT replay apsauga
426Upgrade RequiredRFC 9110 §15.5.22Verčia naujesnį protokolą
428Precondition RequiredRFC 6585 §3Verčia If-Match (prarasta atnaujinimas)
429Too Many RequestsRFC 6585 §4Normo limitas, + Retry-After
431Request Headers Too LargeRFC 6585 §5Nagrinėkai viršijo limitą
451Unavailable For Legal ReasonsRFC 7725GDPR, DMCA, geoblock

5xx — Serverio klaidos (11 kodų)

KodasPavadinimasRFCKada
500Internal Server ErrorRFC 9110 §15.6.1Išimtis kode
501Not ImplementedRFC 9110 §15.6.2Nežinoma HTTP metoda
502Bad GatewayRFC 9110 §15.6.3Proxy gavo šiukšles iš backend
503Service UnavailableRFC 9110 §15.6.4Laikinai, + Retry-After
504Gateway TimeoutRFC 9110 §15.6.5Proxy → backend timeout
505HTTP Version Not SupportedRFC 9110 §15.6.6Blogas HTTP/X
506Variant Also NegotiatesRFC 2295 §8.1Turinio derybos klaida (retai)
507Insufficient StorageRFC 4918 §11.5Trūksta vietos diske (įkelimas)
508Loop DetectedRFC 5842 §7.2WebDAV recursion
510Not Extended ⚠RFC 2774 (deprecated)HTTP Extension Framework
511Network Authentication RequiredRFC 6585 §6Captive portal (WiFi viešbutis/oro uostas)

Šaltiniai

  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, Birželis 2022, rfc-editor.org/rfc/rfc9110
  3. L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, Birželis 2007, rfc-editor.org/rfc/rfc4918
  4. M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, Balandis 2012, rfc-editor.org/rfc/rfc6585
  5. T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, Vasaris 2016
  6. K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, Gruodis 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 kodai — visapusiskas programuotojų vadovas: visi statusai, pritaikymas ir saugumo spąstai — PageForYou.pl