Тело запроса: байты, Content-Length, разорванный символ, лимиты
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья разобрала запрос на метод, цель и заголовки, а тело оставила строкой — и честно призналась, что это упрощение ради песочницы. Сегодня упрощение снимается. Тело — единственная часть запроса, которую нельзя считать текстом: в нём и русские буквы, и картинка, и архив, а сокет режет его на куски там, где придётся, — в том числе посреди буквы. Отсюда три вещи, без которых сервер не живёт: склейка кусков, которая не портит текст; длина, посчитанная в байтах, а не в символах; и предел, за которым сервер перестаёт слушать. Последнее — не про аккуратность: без предела любой желающий просит у вашего процесса столько памяти, сколько у того есть.
Тело запроса вы уже читали — в обработчике маршрута это была одна строка await request.json(), и там же выяснилось, что тело вычерпывается ровно один раз, а негодные данные — это код ответа, а не текст «что-то пошло не так». Одной строкой это было потому, что всю работу делал фреймворк. Сегодня работа наша.
Разборщик прошлой статьи держал буфер строкой и на этом стоял: indexOf("\r\n\r\n"), slice, склейка +=. Для головы это законно, а для тела — нет. В куске настоящего потока лежат байты: Buffer в Node, Uint8Array в Web Streams. Байты становятся текстом только после декодирования, и делать это можно не в любой момент.
Байт — не буква. В UTF-8 латиница весит один байт, кириллица два, длинное тире три, эмодзи четыре. Граница куска об этом не знает: она ложится по тому, сколько данных сеть успела собрать. Предскажите, что будет с телом из восемнадцати символов, если разрезать его в двух неудачных местах и склеить обратно.
Двадцать четыре байта, три куска — и один потерянный «р». Каждый кусок декодировался сам по себе, недоеденные байты буквы оказались непонятными, и на их месте встал U+FFFD — ромбик со знаком вопроса, официальное «здесь было что-то, чего я не понял». Ромбиков два, потому что обрубка тоже два: хвост первого куска и голова второго.
Самое неприятное — третья строка. Порча не ломает ничего: текст остался правильным JSON, JSON.parse отработал, объект получился, в базу уедет заголовок «П��ивет». Ни исключения, ни кода ответа, ни строчки в журнале — только жалоба пользователя через неделю. Лечится это одним аргументом: { stream: true } означает «кусок не последний», и недоеденный хвост буквы декодер оставляет у себя до следующего вызова. Последний decode() без аргументов говорит, что продолжения не будет.
UTF-8 придуман так, чтобы это было видно по любому байту. Ведущий байт сам объявляет длину: 0xxxxxxx — символ из одного байта (ASCII), 110xxxxx — из двух, 1110xxxx — из трёх, 11110xxx — из четырёх. Продолжения всегда начинаются с 10 и ни с чем не путаются. Поэтому декодер, досчитав кусок до конца и увидев, что ведущий байт обещал больше, чем осталось, знает: это обрубок, а не ошибка, — и с stream: true ждёт. Это же свойство спасает при потере данных: упереться можно в любой байт и от него найти начало ближайшего символа, не разбирая файл сначала.
В Node то же самое делают за вас двумя способами: req.setEncoding("utf8") у потока и класс StringDecoder из node:string_decoder — внутри у обоих ровно этот приём. Знать его всё равно нужно: с Web Streams, с сырым сокетом и в браузере вы остаётесь один на один с TextDecoder.
Что content-length считает байты, а не символы, прошлая статья уже показала: двадцать восемь символов тела оказались тридцатью восемью байтами. Тогда из-за этого пришлось писать cutBody — перебирать текстовый буфер по символам и взвешивать каждый, чтобы отрезать ровно обещанное. В байтовом буфере эта функция не нужна вовсе: взять тридцать восемь байт — это subarray(0, 38).
Заодно посмотрим на буфер целиком. Пример берёт тот же запрос, что мы разбирали, дописывает к нему второй (соединение живое, и клиент отправил следующий запрос, не дожидаясь ответа) и переводит всё это в байты — то есть в то, что на самом деле приезжает из сокета.
Голову мы декодируем целиком и не боимся: RFC 9110 держит в именах и значениях заголовков US-ASCII, а всё, что приходит сверх него, велит считать непрозрачными байтами. Тело — наоборот: декодировать его можно только целиком и только когда оно целиком собрано. Пустая строка в байтах — это четыре числа подряд, 13 10 13 10; никакой магии в findHeadEnd нет, он ищет ровно их.
Последние две строки — про то, ради чего всё затевалось. Тело кончилось на тридцать восьмом байте, а в буфере осталось ещё сорок шесть, и это не мусор: это начало следующего запроса. Отрезать тело по числу символов значило бы взять двадцать восемь байт вместо тридцати восьми — десять байт собственного тела остались бы в буфере, и разбор следующего запроса начался бы с середины русского слова.
Искать пустую строку в байтах руками в Node не приходится: у Buffer есть indexOf, который принимает строку и сравнивает байты сам. У Uint8Array такого indexOf нет — он ищет одно число, — поэтому в песочнице цикл написан явно.
Соберём приёмник. Обязанностей у него три: складывать куски так, чтобы текст не портился, считать принятое в байтах и остановиться, когда байтов стало слишком много. Первое — уже знакомый декодер со stream: true, и важно, что декодер один на всё тело: хвост недоеденной буквы живёт внутри него, и новый экземпляр на каждый кусок вернул бы нас к первому примеру.
Предскажите два числа в каждой строке: сколько байт принято и сколько символов к этому моменту собрано.
Тринадцать байт — одиннадцать символов: десять ASCII и «П» весят двенадцать байт, а тринадцатый декодер придержал. Дальше приехали три байта, а прибавилось два символа. Разница между колонками — это и есть тот самый хвост, из-за которого наивная склейка портила текст; здесь он никуда не делся, просто лежит внутри декодера, а не в нашем буфере.
Счётчик size прибавляет chunk.length — длину куска в байтах, до всякого декодирования. Это принципиально: с content-length сравнивать можно только её, и предел тоже ставится в байтах. Предел в символах врал бы в обе стороны — русское тело проскочило бы вдвое большее, а эмодзи вчетверо.
Для тела, которое всё равно станет строкой, — JSON, форма, XML — держать байты незачем: декодер и так собирает текст по ходу дела, а склейка строк обойдётся не дороже, чем массив кусков и слияние в конце. Для тела, которое строкой не станет, всё наоборот: картинку декодировать нельзя, и в память её класть тоже не надо — её место на диске или в хранилище, а через процесс она должна проехать потоком. Это отдельный разговор, и он будет в главе про загрузку файлов. Различает эти два случая заголовок content-type, и решение принимается до чтения первого байта.
У приёмника без предела на два вопроса один ответ. Сколько памяти он готов отдать под одно тело? Сколько попросят. Кому? Кому угодно, в том числе тому, кто открыл сто соединений и шлёт в каждое бесконечный поток. Это не редкая экзотика и не изощрённая атака — это первое, что находит любой сканер.
Предел у нашего приёмника уже есть — limit в конструкторе. Посмотрим, как он срабатывает, когда заголовок обещал одно, а клиент шлёт другое. Предскажите, на каком куске приёмник остановится и что вернёт end().
Третий кусок довёл счётчик до 1536 при пределе 1024 — и с этого момента приёмник ничего не копит: собранный текст выброшен, дальнейшие куски отбрасываются, end() возвращает null вместо тела. Выбросить уже собранное важно: держать полтора килобайта тела, которое всё равно не примем, незачем, а на ста соединениях эта мелочь превращается в память.
Обратите внимание на первую строку. content-length обещал сорок два байта, приехало две с лишним тысячи — и ничего страшного в этом нет: заголовок пишет клиент, а клиент бывает сломанным или злым. Поэтому предел считается по факту, а не по обещанию. Обратное тоже верно: если обещание уже больше предела, читать тело незачем — отказать можно до первого байта, и это дешевле. Одно другого не заменяет, нужны оба.
Имя у 413 двойное: node:http до сих пор зовёт его Payload Too Large, а RFC 9110 переименовал в Content Too Large. По проводу едет только число, так что спор чисто человеческий — но в документации вам встретятся оба.
Модель готова — перенесём её на node:http. Сервер в прогоне держит предел в один килобайт и получает три запроса: обычный POST с русским телом, тело вдвое больше предела и клиент, который отправил заголовки и замолчал. Перед запуском решите: какой код ответа увидит каждый и через сколько.
import { createServer } from 'node:http'; import { connect } from 'node:net'; const LIMIT = 1024; // 1 КиБ: предел тела, за которым мы отказываем // Таймауты — опциями createServer: присваивание server.requestTimeout после // создания проверяется раз в connectionsCheckingInterval (30 с по умолчанию). const server = createServer({ requestTimeout: 300, connectionsCheckingInterval: 50 }, (req, res) => { let size = 0; let text = ''; const decoder = new TextDecoder(); req.on('data', (chunk) => { size += chunk.length; if (size > LIMIT) { console.log(`сервер: предел перейден на ${size} байтах — 413`); res.writeHead(413, { 'content-type': 'application/json' }); // дочитывать чужой мегабайт мы не обязаны: ответ ушёл — соединение рвём res.end(JSON.stringify({ error: 'body_too_large', limit: LIMIT }), () => req.destroy()); return; } text += decoder.decode(chunk, { stream: true }); }); req.on('end', () => { if (size > LIMIT) return; text += decoder.decode(); console.log(`сервер: тело — байт ${size}, символов ${text.length}`); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: true, bytes: size, chars: text.length })); }); }); // Терпение кончилось — об этом узнают здесь. Сам node:http ответил бы 408 и без // нас, но любой слушатель clientError отменяет ответ по умолчанию: теперь 408 — // наша забота, а молчаливый слушатель оставил бы клиента висеть без ответа. server.on('clientError', (err, socket) => { if (err.code !== 'ERR_HTTP_REQUEST_TIMEOUT') { socket.destroy(); return; } console.log(`сервер: ${err.code} — тело так и не доехало, отвечаю 408`); socket.end('HTTP/1.1 408 Request Timeout\r\nconnection: close\r\n\r\n', () => socket.destroy()); }); const post = async (base, body) => { const res = await fetch(`${base}/notes`, { method: 'POST', headers: { 'content-type': 'application/json' }, body, }); console.log(`клиент: тело ${new TextEncoder().encode(body).length} байт → ${res.status} ${await res.text()}`); }; // Клиент, замолчавший после заголовков: тело обещано, но не отправлено const silent = (port) => new Promise((resolve) => { const started = Date.now(); const socket = connect(port, '127.0.0.1', () => { socket.write('POST /notes HTTP/1.1\r\nhost: 127.0.0.1\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\n'); console.log('клиент: заголовки отправил, тело слать не буду'); }); let answer = ''; socket.on('data', (chunk) => { answer += chunk; }); socket.on('close', () => { const waited = Date.now() - started; console.log(`клиент: ${answer.split('\r\n')[0] || 'ответа нет'}`); console.log(`клиент: ждал дольше предела в 300 мс — ${waited >= 300 ? 'да' : 'нет'}; уложился в 5 с — ${waited < 5000 ? 'да' : 'нет'}`); resolve(); }); }); server.listen(0, '127.0.0.1', async () => { const { port } = server.address(); const base = `http://127.0.0.1:${port}`; console.log(`слушаю ${base}, предел тела ${LIMIT} байт`); await post(base, JSON.stringify({ title: 'HTTP — это текст' })); await post(base, JSON.stringify({ note: 'а'.repeat(1000) })); await silent(port); server.close(() => console.log('сервер закрыт')); });
слушаю http://127.0.0.1:<порт>, предел тела 1024 байт
сервер: тело — байт 38, символов 28
клиент: тело 38 байт → 200 {"ok":true,"bytes":38,"chars":28}
сервер: предел перейден на 2011 байтах — 413
клиент: тело 2011 байт → 413 {"error":"body_too_large","limit":1024}
клиент: заголовки отправил, тело слать не буду
сервер: ERR_HTTP_REQUEST_TIMEOUT — тело так и не доехало, отвечаю 408
клиент: HTTP/1.1 408 Request Timeout
клиент: ждал дольше предела в 300 мс — да; уложился в 5 с — да
сервер закрытПервая пара строк — та же арифметика, что в песочнице, только уже на настоящем сокете: тридцать восемь байт, двадцать восемь символов. Вторая — отказ: счётчик увидел 2011 байт при пределе 1024, сервер ответил 413 и оборвал соединение, не дочитывая. Оборвал сам, строкой в коде: пока тело не прочитано до конца, node:http считает запрос незавершённым и ждёт остаток — то есть принимает то самое тело, которое только что отверг.
Здесь же — граница честности этого приёма. Клиент получил 413 потому, что тело было маленьким: 2011 байт уместились в буферы и уже лежали у сервера. С телом в мегабайт на том же сервере fetch не получает ничего — он падает с EPIPE, «пишу в закрытую трубу»: сервер отказался и оборвал соединение, пока клиент был на середине отправки. Ответ отправлен, но читать его некому — и виноват тут не протокол, а наша собственная строка req.destroy(). Уберите её, и 413 дойдёт даже на мегабайте (проверено на node 26.5.0), только цена — ровно та работа, ради которой предел и ставился: чужой мегабайт придётся дочитать до конца. Выбор всегда из этих двух, и обойти его можно одним способом — отказать по заголовку, до первого байта.
Третий клиент — самый дешёвый способ занять сервер. Он не шлёт мегабайты, он не шлёт ничего: заголовки отправлены, content-length обещает сто байт, и соединение просто висит. Тысяча таких соединений стоит атакующему одного процесса, а серверу — тысячи открытых сокетов. Предел тут не спасает — спасает ограниченное терпение: через 300 мс сервер ответил 408 и закрыл соединение.
| Опция | По умолчанию | Что меряет |
|---|---|---|
requestTimeout | 300 000 мс | весь запрос: от первого байта до конца тела |
headersTimeout | 60 000 мс или requestTimeout, если он меньше | только голову — от первого байта до пустой строки |
keepAliveTimeout | 5000 мс | простой живого соединения между запросами |
connectionsCheckingInterval | 30 000 мс | как часто сервер вообще смотрит на часы |
Последняя строка таблицы объясняет, почему в прогоне обе опции стоят в createServer, а не присваиваются потом. Сроки соединений сервер проверяет не непрерывно, а по таймеру, и по умолчанию тот срабатывает раз в тридцать секунд: createServer({ requestTimeout: 300 }) без второй опции отвечает тому же молчаливому клиенту не через 300 мс, а на первой же проверке — через 30 секунд. Присваивание server.requestTimeout = 300 после создания тянет ещё дольше и вразнобой: срок ловится не на первой проверке соединений, а на второй или третьей, и в замерах на node 26.5.0 ответ приходил то через минуту, то через полторы; за первые три секунды не приходило ничего ни разу. Столько не ждёт ни клиент, ни прогонщик этого учебника — у него терпения двадцать секунд. С connectionsCheckingInterval: 50 в опциях ответ приходит на исходе тех самых трёхсот миллисекунд: в замере через 307.
Слушатель clientError отменяет ответ по умолчанию. Без него node:http сам присылает 408 Request Timeout и закрывает сокет; с молчаливым слушателем — проверено — клиент не получает ни ответа, ни разрыва и висит, пока не сдастся сам. Поэтому в прогоне 408 пишется в сокет руками.
Весь наш приём держится на content-length, а его может не быть вовсе. Когда длина заранее неизвестна — ответ генерируется на ходу, файл ещё пишется, — HTTP/1.1 передаёт тело кусками: заголовок Transfer-Encoding: chunked, и каждый кусок сам объявляет свой размер шестнадцатеричным числом, а нулевой размер значит конец. Наш приёмник такого тела не поймёт и обязан сказать об этом честно, а не принять первый кусок за тело. Настоящий node:http разбирает chunked сам и отдаёт обработчику уже склеенный поток.
Второе — предел вернётся не только в разговоре о защите. Когда телефон после долгого офлайна отправит на сервер пачку накопленных правок, она упрётся ровно в этот килобайт, и величина предела станет договором между клиентом и сервером, а не просто числом в коде.
И главное: запрос теперь собран целиком — метод, цель, заголовки, тело, — а попадает по-прежнему в один-единственный обработчик, который сам разбирается, что ему пришло. POST /notes и GET /notes/17 должны расходиться в разные функции раньше, чем начнётся их код. Следующая статья займётся адресом: сегменты пути, параметры, строка запроса и честные 404 и 405.
- Кусок из сокета — байты, а не текст:
Bufferв Node,Uint8Arrayв Web Streams. Граница куска ложится куда угодно, в том числе посреди буквы, и декодировать кусок отдельно от соседей нельзя. - Склейка «текст += decode(кусок)» портит многобайтовые символы молча: на месте разрезанной буквы встаёт
U+FFFD, JSON остаётся правильным, и никто не жалуется. Лекарство — одинTextDecoderна всё тело и{ stream: true }у каждого куска, кроме последнего. content-lengthобещан в байтах, поэтому и счётчик принятого, и предел считаются в байтах — поchunk.length, до декодирования. В байтовом буфере отрезать обещанное — этоsubarray, а остаток буфера принадлежит следующему запросу.- Заголовок — обещание клиента, а не факт: отказывать надо и по нему (до первого байта, дёшево), и по фактическому счётчику (обязательно). Тело без предела — это обещание отдать всю память любому желающему.
- Ранний 413 виден клиенту только на теле, которое он успел дописать: если после отказа оборвать соединение, на мегабайте при пределе в килобайт
fetchполучаетEPIPEвместо ответа. Дочитать отказанное тело можно, и тогда ответ дойдёт, — но это и есть та работа, которой предел избегал. - Клиент, замолчавший после заголовков, стоит атакующему ничего, а серверу — открытого сокета. Лечится опциями
createServer:requestTimeoutвместе сconnectionsCheckingInterval, потому что сроки сервер проверяет по таймеру, а не непрерывно.
1/4Кусок тела оборвался на середине русской буквы. Что вернёт new TextDecoder().decode(chunk) для этого куска?
2/4Приёмник считает предел так: this.text += decoder.decode(chunk, { stream: true }); if (this.text.length > limit). Что с этим не так?
3/4Сервер из прогона — предел 1 КиБ, а после отказа req.destroy() — получает POST с телом в мегабайт. Что увидит клиент?
4/4Сервер создан как createServer({ requestTimeout: 300 }), без connectionsCheckingInterval. Клиент прислал заголовки и замолчал. Когда он получит 408?
Обещание и факт
Приёмник считает байты, но никто пока не сверяет их с тем, что обещал content-length. А обещание расходится с фактом двумя способами: клиент оборвался на середине (соединение упало, браузер закрыли) или прислал больше обещанного. Оба случая — 400: тело негодное, и чинить его нельзя. Допишите readBody, чтобы он возвращал 413 при перейдённом пределе, 400 при расхождении и 200 с телом в остальных случаях.
Порядок проверок важен: сначала предел, потом длина. У тела, которое не влезло, счётчик остановился на первом лишнем куске, и сравнивать его с обещанием бессмысленно — приёмник не дочитал специально.
Без проверки второй случай выглядит особенно обидно: readBody честно возвращает 200 и текст {"title":"П с ромбиком на конце — недоеденный хвост буквы декодер закрыл сам. Это тот же класс ошибок, что и в первом примере статьи: данные испорчены, а никто не жалуется.
Отказать, не читая
Самый дешёвый отказ — до первого байта тела. Всё, что для него нужно, уже есть в голове запроса. Напишите plan: по одному значению заголовка решить, читать ли тело вообще. Заголовка нет — тело брать неоткуда и длину узнать не из чего; значение не похоже на целое число — запрос негодный; обещано больше предела — отказ сразу. Подсказка: Number тут плохой судья.
Проверять надо строку целиком, а не результат приведения: Number("1e3") даёт тысячу, Number(" 38") — тридцать восемь, Number("0x20") — тридцать два, и ни одна из этих записей в content-length не разрешена. Регулярное выражение из одних цифр отсекает их все разом, заодно вместе с отрицательными.
Код 411 (Length Required) — это и есть «я умею только тело с объявленной длиной». Ответ честный ровно до тех пор, пока сервер не научится chunked: после этого тело без content-length станет законным. И заметьте, что "0" проходит: тело нулевой длины — нормальный запрос, а не отсутствие тела.
Битые байты
По умолчанию TextDecoder вежлив: непонятные байты он заменяет ромбиком и продолжает. Для сервера это плохая вежливость — испорченный текст уедет в базу. У декодера есть строгий режим: new TextDecoder("utf-8", { fatal: true }) на негодных байтах бросает исключение. Сделайте строгий приёмник: тот же BodyReader, но битое тело — отказ. Печатать нужно имя ошибки, а не её текст: у каждого движка он свой.
Достаточно подменить декодер полем и обернуть оба метода: поле подкласса перекрывает поле базового класса, а super.push и super.end продолжают работать как раньше.
Ловить нужно в обоих местах, и причины у них разные. В push исключение прилетает от байтов, которые не складываются в символ ни при каком продолжении, — как в этом примере. В end — от целого символа, которого не хватило: куски кончились, а хвост буквы остался. Второй случай — это ровно оборванное тело из первой задачи, только пойманное декодером, а не счётчиком.