Настоящий node:http и восемь дыр вашего сервера
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Глава собрала сервер: разборщик превращает байты в запросы, маршрутизатор раздаёт их обработчикам, конвейер проводит каждый через общие дела. Поставим рядом настоящий node:http — и окажется, что новых идей в нём нет ни одной: те же куски байтов, та же голова, то же тело. Расходятся две реализации не в идеях, а в доверии: ваш сервер верит всему, что пришло по проводу, а настоящий половину запросов из этой статьи не пустит дальше разбора. Сверим их на одних и тех же байтах и закончим списком из восьми дыр — это техзадание на остаток части, и следующая глава начнёт его закрывать.
Перенесём сервер на node:http. Разборщика в нём не будет вовсе: createServer получает функцию, которой запрос приходит уже разобранным — с методом, целью и заголовками. Сервер в прогоне отвечает на два адреса и сам же стучится к себе fetch, чтобы клиент был настоящий. Прогноз: в каком порядке встанут строки сервера и клиента, если оба живут в одном процессе?
import { createServer } from 'node:http'; const server = createServer((req, res) => { console.log(`сервер: ${req.method} ${req.url}`); if (req.url === '/health') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: true })); return; } res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); res.end('не найдено'); }); // Порт 0 — свободный порт выбирает система: два запуска подряд // не спорят за один номер, и занятый 3000 пример не уронит. server.listen(0, '127.0.0.1', async () => { const { port } = server.address(); const base = `http://127.0.0.1:${port}`; console.log(`слушаю ${base}`); for (const path of ['/health', '/missing']) { const res = await fetch(base + path); console.log(`клиент: ${path} → ${res.status} ${await res.text()}`); } // без close процесс не завершится: открытый сервер держит цикл событий server.close(() => console.log('сервер закрыт')); });
слушаю http://127.0.0.1:<порт>
сервер: GET /health
клиент: /health → 200 {"ok":true}
сервер: GET /missing
клиент: /missing → 404 не найдено
сервер закрытСтроки чередуются, и это не случайность: сервер печатает свою в самом начале обработчика — то есть раньше, чем клиент дождётся ответа на тот же запрос. Порт в примере нулевой: система выдаёт свободный, и два запуска подряд не спорят за один номер (в записи он закрыт маской). А последняя строка объясняет, почему у сервера вообще есть метод close: пока сокет слушает, процесс не завершится — это дескриптор, который держит цикл событий.
Теперь сверим построчно, во что превратилось то, что вы писали главу. Левый столбец — ваш код, средний — то же самое в node:http.
| Ваш код главы | В node:http | Кто это пишет |
|---|---|---|
RequestParser.feed: копить буфер и разбирать, пока получается целый запрос | разборщик внутри сервера — конечный автомат llhttp, написанный на C | node:http |
parseHead: метод, цель, версия, заголовки в нижнем регистре | req.method, req.url, req.httpVersion, req.headers, req.rawHeaders | node:http |
cutBody и приёмник тела с пределом | req — читающий поток: куски уже очищены от обрамления | node:http |
ответ объектом { status, body } | res.writeHead(код, заголовки) и res.end(тело) | node:http |
Router: сегменты, параметры, 404 и 405 с Allow | ничего: маршрутизатора у него нет | вы или фреймворк |
runPipeline и свой next() | ничего: конвейера у него тоже нет | вы или фреймворк |
| HTTP/1.1 текстом — всё, что вы разбирали руками | node:http — это HTTP/1.1; HTTP/2 живёт в отдельном модуле node:http2, а встроенного HTTP/3 в node 26.5.0 нет | наружу h2 и h3 отдаёт прокси, а до приложения запрос доходит по HTTP/1.1 — глава о выкладке |
Две строки таблицы отвечают «ничего», и это главное, что стоит унести из сверки. node:http доводит до конца ровно нижний этаж — байты, голову, тело, соединение — и на этом останавливается. Ни маршрутизатора, ни конвейера в нём нет и не будет: их пишет либо автор приложения, либо фреймворк. Вы написали оба и потому знаете, что внутри Express и Nest на этом месте лежит.
Одну связь стоит проговорить отдельно. req и res — не выдумка модуля HTTP, а те самые потоки из главы про потоки: первый читающий, второй пишущий. Поэтому отдать файл в ответ — это pipeline, а не чтение в память; поэтому у ответа есть drain и противодавление; поэтому обрыв соединения приходит обычным событием потока. Глава 3 писалась ради этой строки: HTTP в Node — это два потока и разборщик между ними.
llhttp — тот самый разборщик, модель которого вы собрали: в node 26.5.0 это версия 9.4.2, её печатает process.versions.llhttp. Он на C и потому быстрый, но цикл у него ваш: копить, пока не собралась голова, потом отмерить тело.
У вашей модели нет понятия соединения: feed принимает поток байтов без начала и конца, и вопрос «а что после ответа» в ней просто не возникает. На настоящем сервере он возникает на каждом запросе. HTTP/1.1 по умолчанию соединение не закрывает: клиент присылает второй запрос тем же сокетом, и сервер обслуживает его тем же разборщиком — это keep-alive, и ровно поэтому страница с сотней картинок не открывает сотню соединений.
Решение «закрыть или держать» принимается по версии и заголовку connection — это задача 2 в конце статьи. А простаивающее соединение закрывает сам сервер, по сроку keepAliveTimeout. Тот же простой потом мешает остановке: server.close закрывает только те соединения, что простаивают в этот самый миг, а занятое ждёт, пока оно освободится само, — в главе про эксплуатацию из-за этого честная остановка расходится с наивной на три секунды.
Начнём с испорченных запросов. Первый — вообще не запрос: строка текста, которую кто-то отправил на порт 80. Второй — путь с кириллицей сырыми байтами: в цели запроса разрешён только ASCII, всё остальное клиент обязан закодировать процентами. Третий обещает тело дважды: и длиной, и кусочной передачей. Прогноз: сколько из трёх ваш разборщик отвергнет?
Ни одного. «кто здесь?» стало запросом с методом кто и целью здесь?, а версия — undefined, потому что третьей части в строке не нашлось; маршрутизатор честно поищет такой адрес и ответит 404, будто клиент ошибся дверью. Кириллица прошла как есть: для разборщика это просто символы. А третий запрос показал характер: content-length: 4 велел отмерить четыре байта, и телом стали четыре байта чужого обрамления — 0\r\n\r, начало кусочной передачи.
Отправим настоящему серверу те же две поломки: сырую кириллицу в пути и две длины сразу. fetch для этого не годится — он всё исправит сам и кириллицу закодирует, — поэтому клиент в прогоне открывает сокет через node:net и пишет в него текст запроса руками. Рядом с испорченными едут два целых запроса: обычный GET и тело кусками, про которое речь в следующем разделе. Прогноз: сколько из четырёх запросов дойдёт до обработчика?
import { createServer } from 'node:http'; import { connect } from 'node:net'; const server = createServer(async (req, res) => { // тело читаем потоком до конца: печатать его есть смысл только целиком req.setEncoding('utf8'); let body = ''; for await (const chunk of req) body += chunk; console.log(`сервер: обработчик вызван для ${req.method} ${req.url}, тело «${body}»`); res.end('ok'); }); // разборщик отказал до обработчика: сюда приходит его ошибка, а не запрос server.on('clientError', (err, socket) => { console.log(`сервер: clientError ${err.code}`); socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); }); // каждый кусок сам объявляет свой размер шестнадцатеричным числом БАЙТ, нулевой — конец const chunked = (...parts) => parts.map((part) => `${Buffer.byteLength(part).toString(16)}\r\n${part}\r\n`).join('') + '0\r\n\r\n'; // сырой запрос байтами: fetch такое отправить не даст, он сам всё исправит function send(port, label, raw) { return new Promise((resolve) => { const socket = connect(port, '127.0.0.1', () => socket.end(raw)); let answer = ''; socket.on('data', (chunk) => (answer += chunk)); socket.on('close', () => { console.log(`клиент: ${label} → ${answer.split('\r\n')[0]}`); resolve(); }); }); } server.listen(0, '127.0.0.1', async () => { const { port } = server.address(); await send(port, 'обычный GET', 'GET /notes HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n'); await send( port, 'тело кусками, без длины', 'POST /notes HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n' + chunked('тело едет', ' кусками'), ); await send(port, 'кириллица в пути', 'GET /заметки HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n'); await send( port, 'Content-Length вместе с Transfer-Encoding', 'POST /notes HTTP/1.1\r\nHost: x\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n0\r\n\r\n', ); server.close(); });
сервер: обработчик вызван для GET /notes, тело «» клиент: обычный GET → HTTP/1.1 200 OK сервер: обработчик вызван для POST /notes, тело «тело едет кусками» клиент: тело кусками, без длины → HTTP/1.1 200 OK сервер: clientError HPE_INVALID_URL клиент: кириллица в пути → HTTP/1.1 400 Bad Request сервер: clientError HPE_INVALID_TRANSFER_ENCODING клиент: Content-Length вместе с Transfer-Encoding → HTTP/1.1 400 Bad Request
Два из четырёх. На кириллице и на двух длинах сразу обработчик не вызван ни разу — до него не дошло: разбор сломался раньше, и сервер испустил clientError. Это событие не про ошибку в вашем коде, а про ошибку клиента: вторым аргументом в него приходит сокет, а не запрос, — запроса ведь и не получилось. Коды приходят из llhttp: HPE_INVALID_URL и HPE_INVALID_TRANSFER_ENCODING. Отвечать на них приходится вручную, строкой в сокет: обычного res здесь нет.
Соседние коды вы уже видели: голый перевод строки вместо \r\n даёт HPE_INVALID_VERSION или HPE_CR_EXPECTED — во врезке про перевод строки. Список у llhttp длинный, и каждое имя точнее, чем «400 Bad Request»: по нему видно, на каком именно шаге разбор сказал «нет».
Строгость тут не придирчивость, а отказ гадать. Разборщик, который догадывается, что имел в виду клиент, обязательно догадается иначе, чем тот, кто стоит перед ним или за ним, — а на разнице догадок и живут атаки.
Ошибка, которая приходит в clientError, знает о поломке больше, чем код ответа. Проверено 22.09.2026 на node 26.5.0 на том же запросе с кириллицей: err.code — HPE_INVALID_URL, err.reason — «Invalid char in url path», err.bytesParsed — 5, то есть разбор дошёл до пятого байта и там споткнулся, а err.rawPacket — весь кусок сырых байтов, как он приехал. Последнее дороже всего: это единственное место, где видно запрос до разбора, и в журнале инцидента оно отвечает на вопрос «что вообще нам прислали».
Слушателя можно и не ставить: без него сервер отвечает сам — на той же кириллице в сокет уходит ровно та строка, что в прогоне написана руками, с Connection: close и без тела. Но как только слушатель появился, ответ становится вашей заботой целиком: у вас на руках голый сокет, никакого res здесь нет, и строку ответа приходится писать руками — ровно так это сделано в прогоне.
Вторая пара строк прогона — тот случай, который обещала статья про тело: длины в голове нет вовсе. Так отвечают, когда размер заранее неизвестен — ответ считается на ходу, файл ещё пишется. Вместо content-length в голове стоит transfer-encoding: chunked, и дальше каждый кусок сам объявляет свой размер шестнадцатеричным числом байт, а нулевой размер значит конец. В прогоне так и отправлено: 11 — это 17 байт «тело едет», f — 15 байт « кусками». Обработчик получил их уже склеенными, одной строкой: сборкой занялся node:http.
Прогноз: что сделает с теми же байтами ваш разборщик?
Тело исчезло, а вместо него появился запрос, которого никто не отправлял. content-length в голове нет, Number(undefined ?? 0) — ноль, тело пустое; запрос отдан наружу, и обработчик увидит POST /notes без тела. Но байты-то остались в буфере — и следующий заход разобрал их как новую голову, где стартовой строкой стал размер первого куска. В маршрутизатор ушли бы два запроса вместо одного.
Отсюда же и отказ на голову с двумя длинами. Сервер, который читает content-length, и сервер, который читает transfer-encoding, видят в одном потоке байтов разные запросы. Поставьте одного перед другим — прокси и приложение — и в эту щель пролезет запрос, которого прокси не видел: его протащили внутри тела (по-английски это называют request smuggling). Поэтому node:http не выбирает одну из двух длин, а отвечает 400: выбор здесь и есть дыра.
Разбор выглядит простым: прочитать строку с размером, отмерить столько байт, повторить до нулевого размера. Ловушек в нём ровно столько же, сколько в разборе головы, и они той же природы: размер записан шестнадцатеричным числом, но parseInt("0x10", 16) и parseInt("10junk", 16) оба что-нибудь вернут; после размера бывает точка с запятой и расширение, которое надо отбросить; после нулевого куска бывают ещё заголовки-прицепы. Каждая мелочь, понятая иначе, чем её понял сосед по цепочке, — это новая щель. Ваш разборщик остаётся моделью, а настоящую работу делает llhttp; ваше дело — знать, что именно он для вас делает.
Повторяющийся заголовок — обычное дело, и разборщик складывает значения через запятую, как велит RFC 9110. Для accept-encoding это ровно то, что нужно: два заголовка и один с перечислением через запятую значат одно и то же. А для длины тела? Прогноз: что напечатает пример, если content-length придёт дважды.
Строки от onRequest нет ни одной: запрос не собрался. Два заголовка склеились в "4, 4", Number сделал из этого NaN, а cutBody сравнивает с ним накопленные байты — и любое сравнение с NaN ложно, поэтому он честно возвращает «ещё не всё, ждём следующий кусок». Ждать он будет вечно. Клиент не получил ни ответа, ни ошибки; соединение занято, в журнале пусто. Это худшая из поломок: не падение, а молчание.
Настоящий сервер, как вы уже знаете, такую голову не берёт вовсе: 400 и clientError с кодом HPE_UNEXPECTED_CONTENT_LENGTH (перепроверено 22.09.2026 на node 26.5.0). Отказ здесь не придирчивость: он приходит там, где ваш разборщик молчит навсегда. Второй предел — размер самой головы. У вашего буфера его нет: заголовки можно слать, пока не кончится память сервера, и это задача 3. У node:http он есть, и вы его уже видели: maxHeaderSize, 16 КиБ. Имя у поломки такое же говорящее, как у соседей, — HPE_HEADER_OVERFLOW, а ответ 431 Request Header Fields Too Large уходит тоже до обработчика.
Проверено 22.09.2026 на node 26.5.0, каждый раз с одним и тем же итогом: обработчик не вызывается. Пробел перед двоеточием (Host : x) — HPE_INVALID_HEADER_TOKEN: имя заголовка не может кончаться пробелом, иначе «Host » и «Host» были бы разными именами для разных участников цепочки. Голова, оборванная на середине, — HPE_INVALID_EOF_STATE: клиент закрыл сокет, не дописав заголовки. Запрос HTTP/1.1 без host — 400 без всякого clientError: это единственный обязательный заголовок протокола, и отвечает на его отсутствие сам сервер.
А вот повтор обычного заголовка node:http складывает так же, как вы: X-A: 1 и X-A: 2 дают "1, 2". Отступлений от этого правила три, и два вы уже видели: cookie склеивается точкой с запятой, а заголовок, у которого второго значения быть не может, побеждает первым — два host дают первый, второй отброшен, хотя в req.rawHeaders лежат оба. Третье отступление — set-cookie: он приходит массивом, и склеить его запятой нельзя, потому что дата внутри значения сама содержит запятую.
Сведём всё, чего вашему серверу не хватает. Список не в укор главе: разборщик, маршрутизатор и конвейер вы написали, и написали верно — просто сервер этим не кончается.
| Дыра | Как она выглядит | Кто её закрывает |
|---|---|---|
| Строгость разбора | мусор вместо стартовой строки, сырая кириллица в пути, две длины сразу — принято всё | node:http: 400 и clientError до обработчика |
| Кусочная передача | тело без content-length теряется, а его обрамление становится следующим «запросом» | node:http: куски склеены до обработчика |
| Заголовки, которым верят | повтор длины вешает разбор молча, у головы нет предела, host принят на слово | node:http: 400 и 431; чужой host отсекает прокси |
| Соединение | понятия соединения нет вовсе: некому решать, закрывать его или держать, и некому закрывать простаивающее | node:http: keep-alive, connection и четыре срока |
| Ошибки | ловец переводит в 500 всё подряд: и промах клиента, и отказ базы, и свою же опечатку | своя ошибка — свой код ответа |
| Конфигурация | пределы и сроки записаны числами в коде: поменять их можно только правкой файла и выкатом | конфигурация, разобранная на старте |
| Слои | маршруты, зависимости и границы лежат одним списком, и каждый обработчик сам достаёт себе всё нужное | каркас главы 5 поверх контейнера, который соберётся там же |
| Тесты | проверить обработчик можно, только запустив сервер и постучавшись к нему по сети | свой раннер и вызов без сокета — глава 12 |
Верхние четыре дыры закрывает не фреймворк, а node:http: они про протокол, и решаются они один раз и для всех. Express, Fastify и Nest берут запрос уже разобранным — их код начинается там, где ваш разборщик заканчивается. Поэтому «переписать свой сервер на node:http» — это не поражение: ваш разборщик был моделью, и он свою работу сделал — теперь вы знаете, за что именно отвечает нижний этаж.
Нижние четыре — и есть фреймворк. В них нет ничего, чего вы не написали бы сами: перевод ошибки в код ответа — это try в нужном месте, конфигурация — разбор окружения, слои — контейнер и список маршрутов, тесты — вызов функции вместо запроса по сети. Фреймворк не даёт новых идей; он даёт то же самое, написанное один раз и одинаково во всех проектах, — а это и есть разница между «работает у меня» и «работает у команды».
Половина части позади, и она была про машину: цикл событий, потоки, байты, HTTP. Вторая половина — про то, как из ваших же кусков собирается каркас: декоратор кладёт маршрут в таблицу, контейнер создаёт объекты, конвейер превращается в стражей и перехватчики. Начнётся это с миниатюры, которая соберёт ваш маршрутизатор и ваш контейнер в одно целое и ответит 200 и 404 уже как настоящий каркас.
node:http— это ваш разборщик, доведённый до конца: те же куски, та же голова, то же тело, только разбирает их конечный автомат llhttp на C. Маршрутизатора и конвейера в нём нет — это уже приложение.- Настоящий сервер отвергает испорченный запрос до обработчика: сырая кириллица в пути —
HPE_INVALID_URL, две длины сразу —HPE_INVALID_TRANSFER_ENCODING, голова больше 16 КиБ —HPE_HEADER_OVERFLOWи 431. Ошибка разбора приходит не в обработчик, а в событиеclientError, у которого на руках сокет. - Тело без
content-lengthедет кусками, и каждый кусок объявляет свой размер сам.node:httpсклеивает их до обработчика; разборщик, который знает только длину, теряет такое тело и принимает его обрамление за следующий запрос. - Строгость разбора — не придирчивость: там, где два участника цепочки понимают одни байты по-разному, и живёт протаскивание запроса. Поэтому на голову с двумя длинами отвечают 400, а не выбирают одну из них.
- Повтор заголовка складывается через запятую, и для
content-lengthэтоNaNи вечное ожидание тела — поломка без падения и без единой строки в журнале. У каждого буфера должен быть предел, а у каждого ожидания — срок. - Восемь дыр вашего сервера делятся пополам: строгость, кусочную передачу, заголовки и соединение закрывает
node:http, а ошибки, конфигурацию, слои и тесты — то, что называют фреймворком. Новых идей во второй половине нет: есть написанное один раз и одинаково.
1/4В голове запроса стоят и content-length: 4, и transfer-encoding: chunked. Что сделает node:http?
2/4Вашему разборщику пришло тело кусками: transfer-encoding: chunked и ни одного content-length. Что он сделает?
3/4Клиент прислал content-length: 4 дважды. Что увидит ваш разборщик в headers["content-length"]?
4/4Чего в node:http нет и не появится?
Отказать до обработчика
Научите свой сервер отказывать так же рано, как настоящий. Напишите checkHead: он получает разобранную голову и возвращает причину отказа строкой — или null, если голову можно пускать дальше. Отвергать надо три вещи: стартовую строку, которая не похожа на стартовую; content-length вместе с transfer-encoding; content-length, который не является числом. Принятыми должны остаться только первая и последняя головы списка.
Три проверки — три вида недоверия, и каждая отвечает на свой вопрос: «это вообще запрос?», «сколько тут тела?», «это число?».
Проверка на число намеренно строгая: Number("4, 4") даёт NaN, но Number(" 4 ") — четвёрку, а Number("0x10") — шестнадцать. Длина тела приходит от клиента, и считать её десятичным числом без пробелов и знаков — не придирчивость, а отказ гадать.
В настоящем сервере эта функция зовётся не после разбора, а внутри него: отказывать надо до того, как принято тело, — иначе вы уже прочитали то, что отвергаете.
Закрывать или держать
На настоящем сервере после каждого ответа решается судьба соединения. Правило протокола такое: с HTTP/1.1 соединение по умолчанию живёт дальше, с HTTP/1.0 — по умолчанию кончается, а заголовок connection меняет умолчание в обе стороны. Напишите shouldClose. Четвёртый запрос в списке — ловушка: посмотрите, в каком регистре пришло значение заголовка во втором.
Имена заголовков разборщик привёл к нижнему регистру, а значения оставил как есть — и правильно сделал: в user-agent или в теле токена регистр значим. А вот connection несёт токены, и они регистронезависимы, поэтому приводить к нижнему регистру надо здесь, на месте разбора значения.
Разбор на токены, а не сравнение целой строки, — тоже не придирчивость: в connection законно приходит список, и close бывает в нём не первым.
Решение — половина дела. Вторая половина в том, что connection: close надо ещё и повторить в своём ответе: клиент должен узнать о закрытии из головы, а не из оборванного сокета.
Предел на голову
Клиент открыл соединение, начал голову и шлёт заголовки, не собираясь её заканчивать. Ваш разборщик копит их в буфере, пока у сервера не кончится память, — тысяча таких клиентов стоит атакующему одного процесса. Поставьте предел: пока голова не разобрана, буфер вместе с новым куском не должен переходить limit байт. Байты, а не символы: мерить длину строки здесь так же неверно, как в content-length.
Признак «голова ещё не кончилась» у разборщика уже есть: head остаётся null, пока в буфере не нашлось двух переводов строки подряд. Считать надо до того, как кусок попал в буфер, — иначе предел проверяется уже на съеденной памяти.
Вместо ста кусков разборщик принимает десять и отказывается на одиннадцатом. Ровно это делает node:http со своим maxHeaderSize в 16 КиБ: HPE_HEADER_OVERFLOW и ответ 431.
Предел на голову — брат предела на тело, но срабатывает раньше: тело читают, когда уже известно, кто и что просит, а голова приходит от кого угодно. Поэтому число здесь маленькое, и трогать его почти никогда не нужно.