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 201 iš Location: /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 jeiupdatedAtatsitiktinai identinisupdatedAt— bump redagavusversion— staigios kaskados pokyčių atveju (komentarai, peržiūros skaitiklis) paprastai geriau turėti atskirą lauką nei pasikliautiupdatedAt
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)
| Kodas | Pavadinimas | RFC | Kada |
|---|---|---|---|
| 100 | Continue | RFC 9110 §15.2.1 | Klienatas pasiuntė Expect: 100-continue, serveris sutinka su kūnu |
| 101 | Switching Protocols | RFC 9110 §15.2.2 | WebSocket handshake, HTTP/2 atnaujinimas |
| 102 | Processing ⚠ | RFC 2518 (deprecated) | WebDAV, pašalinti iš RFC 4918 |
| 103 | Early Hints | RFC 8297 | Preload užuomina prieš galutinį atsakymą (Chrome 103+) |
2xx — Sėkmė (10 kodų)
| Kodas | Pavadinimas | RFC | Kada |
|---|---|---|---|
| 200 | OK | RFC 9110 §15.3.1 | Sėkmė su kūnu |
| 201 | Created | RFC 9110 §15.3.2 | Naujas išteklis, + Location |
| 202 | Accepted | RFC 9110 §15.3.3 | Asinchroninis darbas sukeliamas į eilę |
| 203 | Non-Authoritative | RFC 9110 §15.3.4 | Atsakymas iš transformuojančio proxy |
| 204 | No Content | RFC 9110 §15.3.5 | DELETE OK, nėra kūno |
| 205 | Reset Content | RFC 9110 §15.3.6 | Atstatyti formą (retai) |
| 206 | Partial Content | RFC 9110 §15.3.7 | Diapazonų prašymas |
| 207 | Multi-Status | RFC 4918 §11.1 | WebDAV batch |
| 208 | Already Reported | RFC 5842 §7.1 | WebDAV binding, venkti duplikatų |
| 226 | IM Used | RFC 3229 | Delta kodavimas (retai) |
3xx — Peradresavimai (9 kodai)
| Kodas | Pavadinimas | RFC | Kada |
|---|---|---|---|
| 300 | Multiple Choices | RFC 9110 §15.4.1 | Kelios reprezentacijos (retai) |
| 301 | Moved Permanently | RFC 9110 §15.4.2 | Nuolatiai, GET (gali pakeisti POST→GET) |
| 302 | Found | RFC 9110 §15.4.3 | Laikinai, GET (dviguba reikšmė) |
| 303 | See Other | RFC 9110 §15.4.4 | Post-Redirect-Get |
| 304 | Not Modified | RFC 9110 §15.4.5 | Podėlio tikrinimas ETag/If-Modified-Since |
| 305 | Use Proxy ⚠ | RFC 9110 §15.4.6 (deprecated) | Saugos priežastis, nenaudoti |
| 306 | (Unused) | RFC 9110 §15.4.7 | Rezervuota |
| 307 | Temporary Redirect | RFC 9110 §15.4.8 | Laikinai, išsaugo metodą |
| 308 | Permanent Redirect | RFC 9110 §15.4.9 | Nuolatiai, išsaugo metodą |
4xx — Kliento klaidos (28 kodai)
| Kodas | Pavadinimas | RFC | Kada |
|---|---|---|---|
| 400 | Bad Request | RFC 9110 §15.5.1 | Blogas prašymo formatas |
| 401 | Unauthorized | RFC 9110 §15.5.2 | Trūksta / blogas žetono, + WWW-Authenticate |
| 402 | Payment Required | RFC 9110 §15.5.3 | Rezervuota, Stripe „card declined" |
| 403 | Forbidden | RFC 9110 §15.5.4 | Žetono OK, nėra šio ištekliaus leidimo |
| 404 | Not Found | RFC 9110 §15.5.5 | Išteklius neegzistuoja |
| 405 | Method Not Allowed | RFC 9110 §15.5.6 | + Allow |
| 406 | Not Acceptable | RFC 9110 §15.5.7 | Turinio derybos neatitinka |
| 407 | Proxy Auth Required | RFC 9110 §15.5.8 | Kaip 401 proxy |
| 408 | Request Timeout | RFC 9110 §15.5.9 | Per lėtai siunčiant kūną |
| 409 | Conflict | RFC 9110 §15.5.10 | Unikalus pažeidimas, valstybės mašina |
| 410 | Gone | RFC 9110 §15.5.11 | Naikintas visam laikui |
| 411 | Length Required | RFC 9110 §15.5.12 | Trūksta Content-Length |
| 412 | Precondition Failed | RFC 9110 §15.5.13 | If-Match / If-Unmodified-Since |
| 413 | Content Too Large | RFC 9110 §15.5.14 | Kūnas viršija limitą |
| 414 | URI Too Long | RFC 9110 §15.5.15 | URL per ilgas |
| 415 | Unsupported Media Type | RFC 9110 §15.5.16 | Blogas Content-Type |
| 416 | Range Not Satisfiable | RFC 9110 §15.5.17 | Blogas Range |
| 417 | Expectation Failed | RFC 9110 §15.5.18 | Neišpildo Expect |
| 418 | I'm a teapot | RFC 2324 (žaismas) | Honeypot skenerams |
| 421 | Misdirected Request | RFC 9110 §15.5.20 | HTTP/2 jungties sujungimas |
| 422 | Unprocessable Content | RFC 9110 §15.5.21 | Verslo validacija |
| 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 apsauga |
| 426 | Upgrade Required | RFC 9110 §15.5.22 | Verčia naujesnį protokolą |
| 428 | Precondition Required | RFC 6585 §3 | Verčia If-Match (prarasta atnaujinimas) |
| 429 | Too Many Requests | RFC 6585 §4 | Normo limitas, + Retry-After |
| 431 | Request Headers Too Large | RFC 6585 §5 | Nagrinėkai viršijo limitą |
| 451 | Unavailable For Legal Reasons | RFC 7725 | GDPR, DMCA, geoblock |
5xx — Serverio klaidos (11 kodų)
| Kodas | Pavadinimas | RFC | Kada |
|---|---|---|---|
| 500 | Internal Server Error | RFC 9110 §15.6.1 | Išimtis kode |
| 501 | Not Implemented | RFC 9110 §15.6.2 | Nežinoma HTTP metoda |
| 502 | Bad Gateway | RFC 9110 §15.6.3 | Proxy gavo šiukšles iš backend |
| 503 | Service Unavailable | RFC 9110 §15.6.4 | Laikinai, + Retry-After |
| 504 | Gateway Timeout | RFC 9110 §15.6.5 | Proxy → backend timeout |
| 505 | HTTP Version Not Supported | RFC 9110 §15.6.6 | Blogas HTTP/X |
| 506 | Variant Also Negotiates | RFC 2295 §8.1 | Turinio derybos klaida (retai) |
| 507 | Insufficient Storage | RFC 4918 §11.5 | Trūksta vietos diske (įkelimas) |
| 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 viešbutis/oro uostas) |
Šaltiniai
- IANA, Hypertext Transfer Protocol (HTTP) Status Code Registry, iana.org/assignments/http-status-codes
- R. Fielding, M. Nottingham, J. Reschke, RFC 9110: HTTP Semantics, Birželis 2022, rfc-editor.org/rfc/rfc9110
- L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, Birželis 2007, rfc-editor.org/rfc/rfc4918
- M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, Balandis 2012, rfc-editor.org/rfc/rfc6585
- T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, Vasaris 2016
- K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, Gruodis 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