Тело запроса: байты, Content-Length, разорванный символ, лимиты

HTTP≈ 14 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Прошлая статья разобрала запрос на метод, цель и заголовки, а тело оставила строкой — и честно призналась, что это упрощение ради песочницы. Сегодня упрощение снимается. Тело — единственная часть запроса, которую нельзя считать текстом: в нём и русские буквы, и картинка, и архив, а сокет режет его на куски там, где придётся, — в том числе посреди буквы. Отсюда три вещи, без которых сервер не живёт: склейка кусков, которая не портит текст; длина, посчитанная в байтах, а не в символах; и предел, за которым сервер перестаёт слушать. Последнее — не про аккуратность: без предела любой желающий просит у вашего процесса столько памяти, сколько у того есть.

Разделы темы:·

Тело запроса вы уже читали — в обработчике маршрута это была одна строка await request.json(), и там же выяснилось, что тело вычерпывается ровно один раз, а негодные данные — это код ответа, а не текст «что-то пошло не так». Одной строкой это было потому, что всю работу делал фреймворк. Сегодня работа наша.

Разборщик прошлой статьи держал буфер строкой и на этом стоял: indexOf("\r\n\r\n"), slice, склейка +=. Для головы это законно, а для тела — нет. В куске настоящего потока лежат байты: Buffer в Node, Uint8Array в Web Streams. Байты становятся текстом только после декодирования, и делать это можно не в любой момент.

Байт — не буква. В UTF-8 латиница весит один байт, кириллица два, длинное тире три, эмодзи четыре. Граница куска об этом не знает: она ложится по тому, сколько данных сеть успела собрать. Предскажите, что будет с телом из восемнадцати символов, если разрезать его в двух неудачных местах и склеить обратно.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Двадцать четыре байта, три куска — и один потерянный «р». Каждый кусок декодировался сам по себе, недоеденные байты буквы оказались непонятными, и на их месте встал 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).

Заодно посмотрим на буфер целиком. Пример берёт тот же запрос, что мы разбирали, дописывает к нему второй (соединение живое, и клиент отправил следующий запрос, не дожидаясь ответа) и переводит всё это в байты — то есть в то, что на самом деле приезжает из сокета.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Голову мы декодируем целиком и не боимся: RFC 9110 держит в именах и значениях заголовков US-ASCII, а всё, что приходит сверх него, велит считать непрозрачными байтами. Тело — наоборот: декодировать его можно только целиком и только когда оно целиком собрано. Пустая строка в байтах — это четыре числа подряд, 13 10 13 10; никакой магии в findHeadEnd нет, он ищет ровно их.

Последние две строки — про то, ради чего всё затевалось. Тело кончилось на тридцать восьмом байте, а в буфере осталось ещё сорок шесть, и это не мусор: это начало следующего запроса. Отрезать тело по числу символов значило бы взять двадцать восемь байт вместо тридцати восьми — десять байт собственного тела остались бы в буфере, и разбор следующего запроса начался бы с середины русского слова.

Искать пустую строку в байтах руками в Node не приходится: у Buffer есть indexOf, который принимает строку и сравнивает байты сам. У Uint8Array такого indexOf нет — он ищет одно число, — поэтому в песочнице цикл написан явно.

Соберём приёмник. Обязанностей у него три: складывать куски так, чтобы текст не портился, считать принятое в байтах и остановиться, когда байтов стало слишком много. Первое — уже знакомый декодер со stream: true, и важно, что декодер один на всё тело: хвост недоеденной буквы живёт внутри него, и новый экземпляр на каждый кусок вернул бы нас к первому примеру.

Предскажите два числа в каждой строке: сколько байт принято и сколько символов к этому моменту собрано.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Тринадцать байт — одиннадцать символов: десять ASCII и «П» весят двенадцать байт, а тринадцатый декодер придержал. Дальше приехали три байта, а прибавилось два символа. Разница между колонками — это и есть тот самый хвост, из-за которого наивная склейка портила текст; здесь он никуда не делся, просто лежит внутри декодера, а не в нашем буфере.

Счётчик size прибавляет chunk.length — длину куска в байтах, до всякого декодирования. Это принципиально: с content-length сравнивать можно только её, и предел тоже ставится в байтах. Предел в символах врал бы в обе стороны — русское тело проскочило бы вдвое большее, а эмодзи вчетверо.

Для тела, которое всё равно станет строкой, — JSON, форма, XML — держать байты незачем: декодер и так собирает текст по ходу дела, а склейка строк обойдётся не дороже, чем массив кусков и слияние в конце. Для тела, которое строкой не станет, всё наоборот: картинку декодировать нельзя, и в память её класть тоже не надо — её место на диске или в хранилище, а через процесс она должна проехать потоком. Это отдельный разговор, и он будет в главе про загрузку файлов. Различает эти два случая заголовок content-type, и решение принимается до чтения первого байта.

У приёмника без предела на два вопроса один ответ. Сколько памяти он готов отдать под одно тело? Сколько попросят. Кому? Кому угодно, в том числе тому, кто открыл сто соединений и шлёт в каждое бесконечный поток. Это не редкая экзотика и не изощрённая атака — это первое, что находит любой сканер.

Предел у нашего приёмника уже есть — limit в конструкторе. Посмотрим, как он срабатывает, когда заголовок обещал одно, а клиент шлёт другое. Предскажите, на каком куске приёмник остановится и что вернёт end().

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Третий кусок довёл счётчик до 1536 при пределе 1024 — и с этого момента приёмник ничего не копит: собранный текст выброшен, дальнейшие куски отбрасываются, end() возвращает null вместо тела. Выбросить уже собранное важно: держать полтора килобайта тела, которое всё равно не примем, незачем, а на ста соединениях эта мелочь превращается в память.

Обратите внимание на первую строку. content-length обещал сорок два байта, приехало две с лишним тысячи — и ничего страшного в этом нет: заголовок пишет клиент, а клиент бывает сломанным или злым. Поэтому предел считается по факту, а не по обещанию. Обратное тоже верно: если обещание уже больше предела, читать тело незачем — отказать можно до первого байта, и это дешевле. Одно другого не заменяет, нужны оба.

Имя у 413 двойное: node:http до сих пор зовёт его Payload Too Large, а RFC 9110 переименовал в Content Too Large. По проводу едет только число, так что спор чисто человеческий — но в документации вам встретятся оба.

Модель готова — перенесём её на node:http. Сервер в прогоне держит предел в один килобайт и получает три запроса: обычный POST с русским телом, тело вдвое больше предела и клиент, который отправил заголовки и замолчал. Перед запуском решите: какой код ответа увидит каждый и через сколько.

прогон node 26.5.0 · 21 сентября 2026
Сервер с пределом тела в 1 КиБ: обычный 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('сервер закрыт'));
});
node server.mjs

Первая пара строк — та же арифметика, что в песочнице, только уже на настоящем сокете: тридцать восемь байт, двадцать восемь символов. Вторая — отказ: счётчик увидел 2011 байт при пределе 1024, сервер ответил 413 и оборвал соединение, не дочитывая. Оборвал сам, строкой в коде: пока тело не прочитано до конца, node:http считает запрос незавершённым и ждёт остаток — то есть принимает то самое тело, которое только что отверг.

Здесь же — граница честности этого приёма. Клиент получил 413 потому, что тело было маленьким: 2011 байт уместились в буферы и уже лежали у сервера. С телом в мегабайт на том же сервере fetch не получает ничего — он падает с EPIPE, «пишу в закрытую трубу»: сервер отказался и оборвал соединение, пока клиент был на середине отправки. Ответ отправлен, но читать его некому — и виноват тут не протокол, а наша собственная строка req.destroy(). Уберите её, и 413 дойдёт даже на мегабайте (проверено на node 26.5.0), только цена — ровно та работа, ради которой предел и ставился: чужой мегабайт придётся дочитать до конца. Выбор всегда из этих двух, и обойти его можно одним способом — отказать по заголовку, до первого байта.

Третий клиент — самый дешёвый способ занять сервер. Он не шлёт мегабайты, он не шлёт ничего: заголовки отправлены, content-length обещает сто байт, и соединение просто висит. Тысяча таких соединений стоит атакующему одного процесса, а серверу — тысячи открытых сокетов. Предел тут не спасает — спасает ограниченное терпение: через 300 мс сервер ответил 408 и закрыл соединение.

ОпцияПо умолчаниюЧто меряет
requestTimeout300 000 мсвесь запрос: от первого байта до конца тела
headersTimeout60 000 мс или requestTimeout, если он меньшетолько голову — от первого байта до пустой строки
keepAliveTimeout5000 мспростой живого соединения между запросами
connectionsCheckingInterval30 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 с телом в остальных случаях.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Порядок проверок важен: сначала предел, потом длина. У тела, которое не влезло, счётчик остановился на первом лишнем куске, и сравнивать его с обещанием бессмысленно — приёмник не дочитал специально.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Без проверки второй случай выглядит особенно обидно: readBody честно возвращает 200 и текст {"title":"П с ромбиком на конце — недоеденный хвост буквы декодер закрыл сам. Это тот же класс ошибок, что и в первом примере статьи: данные испорчены, а никто не жалуется.

Отказать, не читая

Самый дешёвый отказ — до первого байта тела. Всё, что для него нужно, уже есть в голове запроса. Напишите plan: по одному значению заголовка решить, читать ли тело вообще. Заголовка нет — тело брать неоткуда и длину узнать не из чего; значение не похоже на целое число — запрос негодный; обещано больше предела — отказ сразу. Подсказка: Number тут плохой судья.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Проверять надо строку целиком, а не результат приведения: Number("1e3") даёт тысячу, Number(" 38") — тридцать восемь, Number("0x20") — тридцать два, и ни одна из этих записей в content-length не разрешена. Регулярное выражение из одних цифр отсекает их все разом, заодно вместе с отрицательными.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Код 411 (Length Required) — это и есть «я умею только тело с объявленной длиной». Ответ честный ровно до тех пор, пока сервер не научится chunked: после этого тело без content-length станет законным. И заметьте, что "0" проходит: тело нулевой длины — нормальный запрос, а не отсутствие тела.

Битые байты

По умолчанию TextDecoder вежлив: непонятные байты он заменяет ромбиком и продолжает. Для сервера это плохая вежливость — испорченный текст уедет в базу. У декодера есть строгий режим: new TextDecoder("utf-8", { fatal: true }) на негодных байтах бросает исключение. Сделайте строгий приёмник: тот же BodyReader, но битое тело — отказ. Печатать нужно имя ошибки, а не её текст: у каждого движка он свой.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Достаточно подменить декодер полем и обернуть оба метода: поле подкласса перекрывает поле базового класса, а super.push и super.end продолжают работать как раньше.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Ловить нужно в обоих местах, и причины у них разные. В push исключение прилетает от байтов, которые не складываются в символ ни при каком продолжении, — как в этом примере. В end — от целого символа, которого не хватило: куски кончились, а хвост буквы остался. Второй случай — это ровно оборванное тело из первой задачи, только пойманное декодером, а не счётчиком.