HTTP кодове — пълно ръководство за програмисти: всички статуси, приложения и безопасни капани
Повечето документи за HTTP кодовете са списъци от 63 позиции с едноредово определение. Много малко произтича от това. Този документ отива на противоположния път — седем реални сценария, които пишеш в бекенда, всеки с полния работещ код Fastify + Prisma, показан в две версии: със сигурностни бъгове и без. HTTP кодовете се появяват там, където всички се появяват — в контекст. В края — референтна таблица на всички 63 кода с присвояване на RFC — когато трябва да търсиш по номер.
1. REST API — CRUD за публикации
Започваме с най-честия случай. Пишем крайна точка за управление на публикации на блог. В нея естествено ще се появят осем HTTP кода: 200, 201, 204, 304, 400, 404, 409, 422. Всеки на своето място, не защото „трябвало е да го използвам", а защото решава конкретен проблем.
Пълна крайна точка — 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) {
// СПИСЪК — 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
})
// ПРОЧИТАЙ — GET /posts/:id (с ETag и 304 валидирани)
fastify.get<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
const post = await prisma.post.findFirst({
where: { id: req.params.id, deletedAt: null },
})
if (!post) {
return reply.code(404).send({ error: 'post_not_found' })
}
const currentEtag = etag(post.updatedAt)
if (req.headers['if-none-match'] === currentEtag) {
return reply.code(304).send() // без тяло
}
return reply.header('ETag', currentEtag).send(post) // 200 OK с ETag
})
// СЪЗДАЙ — POST /posts
fastify.post('/posts', async (req, reply) => {
const parsed = CreatePostSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ // 400 — лоша структура
error: 'invalid_body',
issues: parsed.error.flatten(),
})
}
const data = parsed.data
// Валидирани на бизнеса — 422, защото форматът беше OK
if (data.publishAt && new Date(data.publishAt) < new Date()) {
return reply.code(422).send({
error: 'publish_at_in_past',
message: 'publishAt трябва да е в бъдещето',
})
}
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
}
})
// ОБНОВИ — PATCH /posts/:id (оптимистични паралелизъм чрез 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' })
}
// Оптимистични блокиране — клиентът изпрати If-Match с 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: 'Някой промени публикацията междуинфекцията — получете актуалната версия',
})
}
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 /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
})
}
Защо тези кодове, а не други — решение след решение
Защо 201 + Location вместо 200
200 OK казва „операцията е успешна". 201 Created казва „успешна е и се е появил нов обект". Разликата е практична: клиент, който получи 201 с Location: /posts/abc, знае точно, че може да направи GET /posts/abc и да получи новосъздадения ресурс. Клиент, който получи 200, трябва да извлече ID от тялото — което изисква договор. Договорът може да се промени и да счупи клиентите; Location е стандарт.
Защо 204 за DELETE вместо 200
Мекото изтриване е успешно, нямаме нищо разумно да върнем — 204 No Content. RFC 9110 забранява тяло при 204, така че fetch() в браузъра автоматично третира този отговор като „без тяло" и не се опитва да парсира JSON. Връщането на 200 с {"deleted":true} принуждава клиента да обработи такъв формат; 204 е стандарт.
Защо разграничавам 400 и 422
Zod връща два типа грешки. „Липсва поле title" — синтаксис, форматът на заявката е лош, 400 Bad Request. „publishAt в миналото" — форматът е OK (това е валидна дата ISO), но логиката на домейна отказва. Това е 422 Unprocessable Content. GitHub, Stripe, Rails последователно правят така — и има смисъл, защото клиентът третира 400 като бъг (съставяме лошо заявката), а 422 като отказ (потребителят се опитва да направи нещо незаконно от бизнес гледна точка).
Защо 409 за unique constraint
Prisma хвърля P2002 когато двама потребители едновременно вметнат публикация със същия slug. Кодът 400 би бил подвеждащ — заявката беше правилна. 422 би бил странен — това не е бизнес правило, това е конфликт на състоянието. 409 Conflict е точно това: „не мога да направя това, защото коидира с текущото състояние".
Защо 412 при оптимистични блокиране
If-Match е предварително условие. Когато не е изпълнено, спецификацията (RFC 9110 §13.1.1) казва ясно: 412 Precondition Failed. Не 409 — клиентът не е в конфликт, той е попаднал в него, защото има стара версия.
Защо 304 вместо да пресервира целия отговор
Клиент със ETag "abc123" изпраща If-None-Match: "abc123". Ако публикацията не се е променила — връщаме 304, пусто тяло. Това пестели честотна лента (публикацията може да има 50 KB съдържание), пестели сериализирането на JSON, пестели gzip. Cloudflare/Varnish разбират 304 и не се опитват да го кешират като отговор. В типичен CMS — 80%+ заявки към публикувани публикации могат да приключат с 304.
Тест проверяващ решенията
// apps/api/tests/posts.test.ts
import { test, expect } from 'vitest'
import { buildApp } from '../src/app'
test('POST /posts връща 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 с publishAt в миналото → 422 (не 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', // миналото
},
})
expect(res.statusCode).toBe(422) // семантика, не синтаксис
expect(res.json().error).toBe('publish_at_in_past')
})
test('DELETE връща 204 без тяло', async () => {
const res = await app.inject({ method: 'DELETE', url: '/posts/existing-id' })
expect(res.statusCode).toBe(204)
expect(res.body).toBe('') // празен низ, не "{}"
})
test('GET с If-None-Match връща 304 без тяло', 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 със стара If-Match → 412', async () => {
const res = await app.inject({
method: 'PATCH', url: '/posts/abc',
headers: { 'if-match': '"stara-wersja"' },
payload: { title: 'Nowy' },
})
expect(res.statusCode).toBe(412)
})
2. Вход в системата и сесии — 4 реални бъга, 4 поправки
Крайната точка на входа е едно място, където четири различни сигурностни бъга се разкриват чрез неприметни решения по код HTTP / форма на отговор / логиране. По-долу пълния път: първо версия с бъгове (точно такава, както я пишем при първата итерация), след това всеки бъг извлечен на стол и поправен, след това окончателната версия с тест.
Версия с бъгове (НЕ ИЗПОЛЗВАЙ)
// apps/api/src/routes/auth/login.ts — версия с 4 бъга
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') // БЪГ #4
const user = await prisma.user.findUnique({ where: { email } })
if (!user) { // БЪГ #1
return reply.code(404).send({ error: 'user_not_found' })
}
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) { // БЪГ #2
return reply.code(401).send({ error: 'wrong_password' })
}
const attempts = await redis?.incr(`login:${email}`).catch(() => null)
if (attempts === null) { // БЪГ #3
// Redis отпадна — позволяваме напълно, сигурност < наличност
} else if (attempts > 5) {
return reply.code(429).send({ error: 'too_many' })
}
const token = fastify.jwt.sign({ userId: user.id })
return reply.send({ token })
})
}
Бъг #1 — изброителност на потребителят чрез различни кодове
Крайната точка връща 404 user_not_found когато имейлът не е там, 401 wrong_password когато е там, но паролата е лоша. Нападателят със списък от 500 хиляди имейли (течение от друг сайт) ще филтрира в няколко часа всички активни акаунти на вашата фирма. Кодовете HTTP попадат в прокси/CDN/access логове — не е нужно даже да четете тялото.
Поправка:
// DUMMY_HASH — предварително изчислен bcrypt хеш на низа "dummy" (cost 10)
const DUMMY_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
const user = await prisma.user.findUnique({ where: { email } })
const hashToCompare = user?.passwordHash ?? DUMMY_HASH
const ok = await bcrypt.compare(password, hashToCompare)
if (!user || !ok) {
return reply.code(401).send({ error: 'invalid_credentials' })
}
Сега нападателят получава идентичния 401 invalid_credentials независимо дали акаунтът съществува. Няма начин да се различи.
Бъг #2 — timing атака
Поправката от #1 само привидно решава проблема. Чейтайки го внимателно — когато user не съществува, hashToCompare = DUMMY_HASH, bcrypt.compare работи ~80 ms. Когато user съществува, bcrypt сравнява с неговия истински хеш — също ~80 ms (ако цената е същата). OK, в този конкретен фрагмент редактирането е балансирано.
Но внимание: в оригиналната версия с бъгове първият клон if (!user) return ... се изпълнява ~2 ms (само заявка към базата), втория ~82 ms (заявка + bcrypt). Нападателят измерва средния час от 100 опита:
// attacker.ts — показва разликата
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 е вечност — нападателят брои акаунти без да гледа HTTP кода. Поправката изисква dummy bcrypt.compare за несъществуващи потребители (вижте кода по-горе — той е вече правилен). Допълнително тест за регресия:
test('вход не разкрива съществуването на потребител чрез редактирането', async () => {
const warmup = 10, samples = 200
// загрев
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) // по-малко от 5ms разлика
})
Бъг #3 — отворено на честотата лимит
redis?.incr(...).catch(() => null) — когато Redis отпадне, attempts === null, условието attempts > 5 е false, лимитът е отключен. Реален инцидент (от одит на PageForYou.pl през април 2026): OOM на Redis + нападателят в същия момент + 200 хиляди опита за пароли преди някой да забелязва.
Поправка — отворено затворено:
const key = `login:${email}:${req.ip}`
let attempts: number
try {
attempts = await redis.incr(key)
if (attempts === 1) await redis.expire(key, 60) // плъзгащ прозорец 1 мин
} catch {
// Redis отпадна — НЕ позволяваме напълно
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' })
}
Трейд-оф, осъзнат: при падане на Redis никой не се влиза в системата през ~30 сек (време на възвращане на Redis + истичане на срока). Приемаме — сигурност > наличност. Алтернативата е локален fallback (in-memory map), но тогава разпределената система има некоординиран лимит — всеки под брои отделно, реално N× 5 опита вместо 5.
Бъг #4 — пароля в логовете
req.log.info({ email, password }, ...) → Loki/ELK/Splunk получават паролата в plaintext. Всеки с достъп до мониторинга (SRE, DevOps, изпълнител) ще я види. Класически течение.
Поправка — 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]',
},
},
})
Редактирането работи на ниво сериализер Pino — паролите никога не попадат в транспорта, дори по случайност в друго място на кода.
Окончателна версия на крайната точка
// apps/api/src/routes/auth/login.ts — след всички 4 поправки
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. Честотна лимит (отворено затворено)
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. Търсене на потребител с постоянно време + сравнение
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' })
}
// Успех — нулиран честотния лимит + издаване на жетон
await redis.del(rateKey).catch(() => {})
const token = fastify.jwt.sign({ userId: user.id, role: user.role }, { expiresIn: '15m' })
return reply.send({ token, expiresIn: 900 })
})
}
Защо 401 вместо 403 за неудачен вход
Класическа грешка: тъй като потребителят е въвел „лоша пароля", може би 403 Forbidden? Не. 401 означава „не знам кой си, удостовер се". 403 означава „знам кой си, но нямаш разрешение за този ресурс". При входа удостоверяването е неудачно — 401. Използвам 403 само за отключен акаунт, където идентичността е била OK, но политиката отказва да продължи.
3. Пренасочвания — 5 сценария без самораняване
Пет различни пренасочвания, всеки с различна причина. Избора на грешния код променя POST на GET, разбива SEO, или отвори отворено пренасочване.
Сценарий 1: HTTP → HTTPS (глобален)
Код: 301 + HSTS. Трайно, за всички методи (GET доминира; за POST е същото е правилно, браузърът ще направи каквото трябва).
# /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;
# ...
}
Капан: първата заявка HTTP може да съдържа бисквитки за сесия в plaintext. HSTS решава това само от втората посещение — след първия успешен HTTPS отговор браузърът запомня и не опитва вече HTTP. Първата посещение трябва да е или чрез explicit https://, или преекспортирана листа (hstspreload.org).
Сценарий 2: Стар URL → нов след преименуване на брандинга
Код: 301. Google ще отиндексира стария URL и ще препишат rang на страницата.
fastify.get('/blog-old/:slug', (req, reply) => {
return reply.redirect(301, `/blog/${req.params.slug}`)
})
Капан: 301 е агресивно кеширан от браузъра (понякога завинаги). Ако направиш грешка на целевия URL, потребителите ще ландват там дори след поправката, докато не изчистят кеша. Ако нямаш 100% сигурност — използвай 302 в началото, промени на 301 когато си сигурен.
Сценарий 3: Post-Redirect-Get след формуляр
Код: 303 See Other. Налага GET вместо POST — F5 на страница на потвърждение не причинява дублиране на поръчка.
fastify.post('/checkout', async (req, reply) => {
const order = await createOrder(req.body)
return reply.code(303).header('Location', `/orders/${order.id}`).send()
})
Разлика с 302: 302 исторически е трябвало да означава „запазване на метода", но браузърите винаги са менели POST на GET. RFC 9110 документира това несъответствие. 303 е експлицитно за това — без двусмисленост.
Сценарий 4: Трайна промяна на крайна точка API с POST
Код: 308 Permanent Redirect. За разлика от 301, гарантира запазване на метода и тялото.
// Стара крайна точка /api/v1/orders/create (POST) → /api/v2/orders (POST)
fastify.post('/api/v1/orders/create', (req, reply) => {
return reply.redirect(308, '/api/v2/orders')
})
Клиент, който е изпратил POST с тяло 2 KB, ще получи 308 Location: /api/v2/orders и ще изпълни POST със същото тяло. 301 е НЕ БЕЗОПАСЕН — някои HTTP библиотеки (стари curl, стари axios) ще променят POST на GET.
Сценарий 5: Отворено пренасочване — класически фишинг атака
Най-честата уязвимост на приложението, която видя на одит. Крайна точка за проследяване на щракване:
// УЯЗВИМО — НЕ ИЗПОЛЗВАЙ
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
return reply.redirect(302, req.query.url)
})
Атака: SMS „Твоята сметка bank.pl ще бъде блокирана, проверете: https://bank.pl/go?url=https://bank-login-verify.evil/auth". Жертвата вижда доверена домейна, щракна, получава 302 Location: https://bank-login-verify.evil/auth, ландва на фишинга. Въвеждане на пароля = превземат сметка.
Поправка — whitelist:
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' })
}
// Само HTTPS, само известни хостове
if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.host)) {
return reply.code(400).send({ error: 'domain_not_allowed' })
}
return reply.redirect(302, target.toString())
})
Избора на 302 (не 301) е целен — не искаме браузърът да кеширани „щракни тук → ландва там", това трябва да е свежо решение при всяко щракване.
4. Качване и range заявки — включително Apache Killer
Качване на файл е място, където се появяват 413 (файл твърде голям), 415 (грешен тип), 416 (невалидна дължина) и 206 (частично съдържание — видео със преместване). Плюс Apache Killer — 15-годишна атака, която все още работи на неправилно конфигурирани сървъри.
Крайна точка за качване с валидирани
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 проверка — multipart лимит може да бъде заобиколен, проверете реалния размер
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 байтове проверка — mimetype от заглавка може да лъже
const realMime = await detectMimeFromBytes(path) // напр. file-type библ.
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 })
})
}
Защо magic байтове проверка — не вярвайте на клиента
data.mimetype е Content-Type от multipart заглавката — клиентът може да въведе всеки низ там. Нападателят ще прати .exe с Content-Type: image/png, а след това в друго място на приложението този файл ще бъде даден на img src — което не е опасно — но на iframe или Content-Type в отговор, и браузърът ще изпълни бинарката. Проверка чрез file-type/libmagic чете първите байтове и разпознава типа наистина.
Range заявки — сервиране на видео със преместване
Видео плейър изпраща Range: bytes=1048576-2097151 — иска средната част. Сървърът отговаря 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, целост
}
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
Нападателят изпраща заглавка с хиляди непокриващи се диапазони:
Range: bytes=0-,0-1,0-2,0-3,0-4,0-5,0-6,0-7,...×1300
Сървърът се опитва да подготви и обедини всички тези фрагменти — OOM, падане. Поправка в Apache/Nginx е била приемена преди дълго време, но ако пишеш собствена обработка на Range (по-горе) — трябва да ограничиш брой и размер на диапазонроссии. Мойто обработчик приема само един диапазон, което е безопасно. Пълният RFC 7233 поддържа multipart/byteranges с няколко диапазона — реализирай само ако наистина се нуждаеш, с лимит максимално 10 диапазона.
5. Асинхронни работи — 202 Accepted + polling
Export ZIP има ~62 секунди при голяма сметка (PageForYou RODO експорт). Синхронно — браузърът връща timeout, Cloudflare 524. Правилно — 202 Accepted + крайна точка на статус + polling на клиентската страна.
Крайна точка създаваща работа
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}`,
})
})
Крайна точка на статус — какво да върнеш, зависимо от етап
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' })
// Оторизирани — само собственик на работа
if (job.data.userId !== req.user.id) {
return reply.code(404).send({ error: 'job_not_found' }) // 404 целено, не 403
}
const state = await job.getState()
if (state === 'completed') {
// Готова — пренасочване към 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,
})
}
// Все още се изпълнява
return reply
.code(200)
.header('Retry-After', '5') // намек за клиента — poll след 5s
.send({
jobId: job.id,
status: state, // waiting, active, delayed
progress: job.progress,
})
})
Защо 404 вместо 403 при чужда работа
Нападателят познаващ формата на ID работа може да брои /exports/0, /exports/1, ... и по статусите (403 vs 404) картира активност на други потребители. Връщането винаги 404 когато работата не е Твоя — нападателят не разграничава „не съществува" от „не Твоя".
Защо 303 след завършване, не 200
Клиент питащ за статус иска URL за сваляне когато готова. 303 See Other с Location: signed-url казва „отидете там GET-ем". Клиентът може да следва пренасочването автоматично, потребителят получава файл. 200 с { downloadUrl: "..." } изисква от клиента отделен GET и отделна логика — 303 е по-чисто.
6. Валидирани кеш — ETag и 304
Пълен пример на кеш валидирани в блог API, както този, който четеш. Цел: намали честотна лента 10×, понижи времето на рендиране SSR.
Крайна точка с ETag и валидирани
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, /* и остатък от полята */
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']
// Валидирани кеш — клиентът има актуална версия?
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() // пусто тяло
}
return reply
.code(200)
.header('ETag', currentEtag)
.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
.header('Vary', 'Accept-Language')
.send(post)
})
Как това използва Next.js / Cloudflare
Next.js ISR с revalidate: 60 ще вика fetch към твоя API всеки 60 сек. С ETag:
- Първи fetch →
200 OK+ ETag + тяло (50 KB) - Следваща переваличане на fetch (всеки 60 сек) →
If-None-Match: "abc"→304+ 0 байта
Спестяване: ~50 KB × брой преваличане × брой сървъри на ръба. При 10 региона Cloudflare × 60 переваличане/ч = 600 fetch/ч/публикация. 50 KB × 600 = 30 MB/ч → без ETag. С ETag → 600 × ~200 B (само заглавка) = 120 KB/ч. 250× по-малко.
Какво ТРЯБВА да отиде в ETag
Всяка промяна видима за клиента. Минимум:
id— различни публикации имат различни etagi дори когатоupdatedAtслучайно идентичниupdatedAt— bump при редактиранеversion— в случай на каскадни промени (коментари, брой преглеждания) обикновено по-добре е да имаш отделно поле отколкото да разчиташ наupdatedAt
Какво НЕ давай на ETag: хеш на пароли, секрети, данни на други потребители. ETag е публичен — попада в логовете на CDN, заглавките на браузъра.
7. Обработка на грешки в продукция (500, 502, 503, 504)
Тези четири кода изглеждат подобни, но всеки означава нещо различно и всеки има различна реакция на страната на клиента / мониторинга.
500 — твойте код е падал
Изключение в обработчик. Setup, който не течи stack trace на prod и дава requestId за корелирани:
fastify.setErrorHandler((err, req, reply) => {
req.log.error({ err, reqId: req.id, url: req.url, method: req.method }, 'unhandled')
// Бизнес грешки — отпусни
if (err.statusCode && err.statusCode < 500) {
return reply.code(err.statusCode).send({
error: err.code || 'error',
message: err.message,
})
}
// Истинския 500 — скрий детайли
return reply.code(500).send({
error: 'internal_error',
requestId: req.id, // към логовете
// НЕ: err.message, err.stack, err.sql, пътища до файлове
})
})
Когато потребителят докладва „получих грешка", пита „какво е request ID?" и намираш в логовете пълния stack trace. Потребителят не вижда нищо чувствително.
502 и 504 — Nginx → бекенд
Обеопти автоматични от Nginx. 502 = бекенд върна смет (upstream падна в лета, сброс връзка, невалиден HTTP отговор). 504 = бекенд не отговори в час (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;
# Повторна опит на следващия upstream
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
# Премахни заглавките течане
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
}
}
503 — временно, Retry-After
503 е ЕДИНСТВЕ от 5xx, който ясно казва на клиента „опитайте отново". Използвай когато:
- Планирана поддържане (deploy blue-green)
- Circuit breaker отворен (зависим сервис падна)
- Load shedding (твърде много RPS, защитавам останалите)
Обикновено с Retry-After + персонализирано тяло:
// Circuit breaker — когато DB е претоварена
fastify.addHook('preHandler', async (req, reply) => {
if (await circuitBreaker.isOpen()) {
return reply
.code(503)
.header('Retry-After', '30')
.send({
error: 'service_degraded',
retryAfter: 30,
})
}
})
Ключова разлика 500 vs 503: 500 = бъг, SRE получава页面. 503 = очаквано, мониторинга разграничава (не алармиране). Връщането на 503 вместо 500 за да „не аларминирам" — анти-пътека. Аларми са за това, за да алармирам.
8. Референтна таблица — всички 63 кода с присвояване на RFC
Когато трябва бързо да провериш конкретен код — таблица по-долу. Всички 63 официални входове от регистъра IANA с връзка към дефиниращия RFC.
1xx — Информационни (4 кода)
| Код | Име | RFC | Кога |
|---|---|---|---|
| 100 | Continue | RFC 9110 §15.2.1 | Клиентът е изпратил Expect: 100-continue, сървърът се съглася на тяло |
| 101 | Switching Protocols | RFC 9110 §15.2.2 | WebSocket handshake, HTTP/2 upgrade |
| 102 | Processing ⚠ | RFC 2518 (deprecated) | WebDAV, премахнато от RFC 4918 |
| 103 | Early Hints | RFC 8297 | Preload намек преди окончателен отговор (Chrome 103+) |
2xx — Успех (10 кода)
| Код | Име | RFC | Кога |
|---|---|---|---|
| 200 | OK | RFC 9110 §15.3.1 | Успех с тяло |
| 201 | Created | RFC 9110 §15.3.2 | Нов ресурс, + Location |
| 202 | Accepted | RFC 9110 §15.3.3 | Асинхронна работа заредена |
| 203 | Non-Authoritative | RFC 9110 §15.3.4 | Отговор от трансформиран прокси |
| 204 | No Content | RFC 9110 §15.3.5 | DELETE OK, без тяло |
| 205 | Reset Content | RFC 9110 §15.3.6 | Нулирай формуляр (редко) |
| 206 | Partial Content | RFC 9110 §15.3.7 | Range заявка |
| 207 | Multi-Status | RFC 4918 §11.1 | WebDAV пакет |
| 208 | Already Reported | RFC 5842 §7.1 | WebDAV binding, избегни дупликати |
| 226 | IM Used | RFC 3229 | Delta encoding (редко) |
3xx — Пренасочвания (9 кода)
| Код | Име | RFC | Кога |
|---|---|---|---|
| 300 | Multiple Choices | RFC 9110 §15.4.1 | Няколко представяния (редко) |
| 301 | Moved Permanently | RFC 9110 §15.4.2 | Трайно, GET (може да промени POST→GET) |
| 302 | Found | RFC 9110 §15.4.3 | Временно, GET (двусмислено) |
| 303 | See Other | RFC 9110 §15.4.4 | Post-Redirect-Get |
| 304 | Not Modified | RFC 9110 §15.4.5 | Кеш валидирани ETag/If-Modified-Since |
| 305 | Use Proxy ⚠ | RFC 9110 §15.4.6 (deprecated) | Security vulnerability, не използвай |
| 306 | (Unused) | RFC 9110 §15.4.7 | Заседнало |
| 307 | Temporary Redirect | RFC 9110 §15.4.8 | Временно, запазва метода |
| 308 | Permanent Redirect | RFC 9110 §15.4.9 | Трайно, запазва метода |
4xx — Грешки на клиент (28 кода)
| Код | Име | RFC | Кога |
|---|---|---|---|
| 400 | Bad Request | RFC 9110 §15.5.1 | Лош формат заявка |
| 401 | Unauthorized | RFC 9110 §15.5.2 | Липса / лош жетон, + WWW-Authenticate |
| 402 | Payment Required | RFC 9110 §15.5.3 | Заседнало, Stripe „карта отклонена" |
| 403 | Forbidden | RFC 9110 §15.5.4 | Жетон OK, липса разрешения |
| 404 | Not Found | RFC 9110 §15.5.5 | Ресурс не съществува |
| 405 | Method Not Allowed | RFC 9110 §15.5.6 | + Allow |
| 406 | Not Acceptable | RFC 9110 §15.5.7 | Съдържане преговор не отговаря |
| 407 | Proxy Auth Required | RFC 9110 §15.5.8 | Както 401 за прокси |
| 408 | Request Timeout | RFC 9110 §15.5.9 | Твърде бавна испращане на тяло |
| 409 | Conflict | RFC 9110 §15.5.10 | Unique нарушение, държавна машина |
| 410 | Gone | RFC 9110 §15.5.11 | Изтрито завинаги |
| 411 | Length Required | RFC 9110 §15.5.12 | Липса Content-Length |
| 412 | Precondition Failed | RFC 9110 §15.5.13 | If-Match / If-Unmodified-Since |
| 413 | Content Too Large | RFC 9110 §15.5.14 | Тяло надвишава лимит |
| 414 | URI Too Long | RFC 9110 §15.5.15 | URL твърде дълъг |
| 415 | Unsupported Media Type | RFC 9110 §15.5.16 | Лош Content-Type |
| 416 | Range Not Satisfiable | RFC 9110 §15.5.17 | Лош Range |
| 417 | Expectation Failed | RFC 9110 §15.5.18 | Не изпълнява Expect |
| 418 | I'm a teapot | RFC 2324 (шега) | Honeypot за скенери |
| 421 | Misdirected Request | RFC 9110 §15.5.20 | HTTP/2 връзка коалесценция |
| 422 | Unprocessable Content | RFC 9110 §15.5.21 | Бизнес валидирани |
| 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 преиграване защита |
| 426 | Upgrade Required | RFC 9110 §15.5.22 | Наложи по-нов протокол |
| 428 | Precondition Required | RFC 6585 §3 | Наложи If-Match (загубена актуализиране) |
| 429 | Too Many Requests | RFC 6585 §4 | Честотна лимит, + Retry-After |
| 431 | Request Headers Too Large | RFC 6585 §5 | Заглавки надвишават лимит |
| 451 | Unavailable For Legal Reasons | RFC 7725 | RODO, DMCA, геоблокиране |
5xx — Грешки на сървър (11 кода)
| Код | Име | RFC | Кога |
|---|---|---|---|
| 500 | Internal Server Error | RFC 9110 §15.6.1 | Изключение в кода |
| 501 | Not Implemented | RFC 9110 §15.6.2 | Неизвестен HTTP метод |
| 502 | Bad Gateway | RFC 9110 §15.6.3 | Прокси получи смет от бекенда |
| 503 | Service Unavailable | RFC 9110 §15.6.4 | Временно, + Retry-After |
| 504 | Gateway Timeout | RFC 9110 §15.6.5 | Прокси → бекенд timeout |
| 505 | HTTP Version Not Supported | RFC 9110 §15.6.6 | Лош HTTP/X |
| 506 | Variant Also Negotiates | RFC 2295 §8.1 | Грешка съдържане преговор (редко) |
| 507 | Insufficient Storage | RFC 4918 §11.5 | Липса място на диск (качване) |
| 508 | Loop Detected | RFC 5842 §7.2 | WebDAV рекурсия |
| 510 | Not Extended ⚠ | RFC 2774 (deprecated) | HTTP Extension Framework |
| 511 | Network Authentication Required | RFC 6585 §6 | Плен портал (WiFi хотел/летище) |
Източники
- IANA, Hypertext Transfer Protocol (HTTP) Status Code Registry, iana.org/assignments/http-status-codes
- R. Fielding, M. Nottingham, J. Reschke, RFC 9110: HTTP Semantics, Юни 2022, rfc-editor.org/rfc/rfc9110
- L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, Юни 2007, rfc-editor.org/rfc/rfc4918
- M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, Април 2012, rfc-editor.org/rfc/rfc6585
- T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, Февруари 2016
- K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, Декември 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