Загрузка файлов: поток мимо памяти, лимиты, чужое имя файла

эксплуатация≈ 22 минут

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

Телефон уже договорился с сервером обо всём: id фото рождается на устройстве, снимок уходит своей очередью, а повтор перезаписывает прежний файл. Осталась серверная половина — та, которой нет в учебниках по каркасам: тело с файлом приходит кусками, и собирать его в памяти нельзя; предел размера срабатывает посреди файла, и оборванный файл в этот момент уже лежит на диске; имя файла придумал клиент, а photoId — тоже клиент; хранилище в разработке папка, в проде S3, и подменять его придётся не переписывая обработчик. Разбираем всё это на своём разборщике границ, потом сверяем с настоящим @fastify/multipart — и заодно смотрим, что из этого сделано в боевом API автора, а что там ещё только предстоит.

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

Загрузка фото в «Подходе» живёт не в tRPC, а в отдельном маршруте POST /api/upload/photo с полем file. Причина посчитана ещё в части V: тот же снимок в JSON едет на треть тяжелее — 327 709 байт против 246 033, — и вдобавок целиком через память обеих сторон. Комментарий в боевом контроллере говорит то же самое почти теми же словами.

Формат тела читатель уже видел дважды: в браузере, где тело запроса собирает сам браузер, и на телефоне, где его складывает натив из частей getParts, — там читатель повторил эту сборку в песочнице. Устройство одно и то же: строка-граница, заголовки части, пустая строка, содержимое, и так по кругу до закрывающей границы. Границу выбирает отправитель и объявляет её в content-type; у натива iOS это 70 случайных знаков, у expo/fetch — ----ExpoFetchFormBoundary и ещё шестнадцать.

Дальше начинается серверная работа, и она не похожа на разбор JSON. Тело приезжает кусками произвольного размера — байтами, а не текстом, — и граница куска ложится куда угодно, в том числе посреди строки-границы. А главное: собрать тело целиком, как мы делали с JSON, здесь нельзя. Пять мегабайт на запрос при сотне одновременных загрузок — это полгигабайта памяти под данные, которым место на диске.

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

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

Первое число — цена формата: в теле 3257 байт при снимке 3000. Двести пятьдесят семь байт — это границы, заголовки частей и поле photoId, которое приехало первым, до файла. Порядок здесь и решает: части едут в теле в том порядке, в каком их добавили, а photoId телефон добавляет раньше файла — значит, сервер знает его до того, как получит первый байт снимка. На этом будет стоять весь следующий раздел. Но порядок выбирает отправитель, и молча на нём стоять нельзя: часть с файлом, приехавшая раньше своего поля, оставляет приёмник без photoId — и это 400, а не повод складывать путь из того, чего нет.

Второе — имя файла. Пикер такого имени не выдавал, его подставили в примере; сервер получил ровно то, что ему прислали, — ../../etc/passwd, вместе с точками и косыми. Никто это имя не почистил, потому что чистить его некому: разборщик читает заголовок части, а не проверяет намерения.

И третье — тот самый ответ, ради которого пример написан: снимок на 3000 байт приехал 48 кусками, а наибольший буфер разборщика — 139 байт. Держать файл целиком не пришлось ни на одном шаге. Работает это одной строчкой: в состоянии «тело части» разборщик отдаёт приёмнику всё, кроме хвоста длиной в разделитель без одного байта. В этом хвосте граница может только начинаться — значит, отдать его нельзя, а всё, что левее, к границе отношения уже не имеет.

Разборщик держит в памяти не файл, а хвост длиной в границу: только в нём граница может начаться.

Две мелочи в коде стоят того, чтобы их заметить. name= ищется от начала строки или после точки с запятой — иначе он нашёлся бы внутри filename=, и у файла имя поля оказалось бы обрезком имени файла. А буфер начинается с CRLF, которого в теле нет: перед самой первой границей перевода строки не бывает, и без этой подпорки первую границу пришлось бы искать отдельным правилом.

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

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

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

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

Наивная ручка ответила 200 и выдала телефону адрес — по которому лежит килобайт от четырёх, то есть картинка, которая не откроется. Осторожная на том же теле ответила 413 своим кодом и удалила обрезок. Разница между ними — одна строка: if (sink.truncated). Ничего другого в природе загрузки не изменилось: в обоих случаях предел сработал посреди файла, в обоих случаях обрезок был записан на диск, — но во втором его не стало до ответа.

Обрезок — это файл, прошедший все проверки формата и не прошедший одну: он не целый.

Соблазн ответить раньше — оборвать соединение на середине тела — читатель уже разбирал: на теле чуть больше предела это работает, а на мегабайте клиент получает EPIPE вместо ответа. Для загрузок это решает вопрос окончательно: файл — это как раз тот случай, когда тело большое. Значит, тело дочитывается до конца, и цена предела — не сэкономленные байты сети, а несобранная память и удалённый обрезок.

Перенесём приём на настоящий сокет и настоящий диск. В прогоне тот же разборщик и тот же приёмник, только вместо модели — node:http, createWriteStream и временный каталог, а запросы отправляет fetch с браузерным FormData: границу выбирает он, и наш разборщик её впервые видит. Предел — тот же килобайт. Прогноз: сойдётся ли вывод с моделью и что окажется в хранилище после наивной ручки?

прогон node 26.5.0 · 29 сентября 2026
Приём загрузки на node:http: снимок в предел, наивная ручка на снимке вчетверо больше предела и осторожная на нём же
// Приём загрузки на настоящем сокете и настоящем диске: файл едет мимо памяти
import { once } from 'node:events';
import { createWriteStream } from 'node:fs';
import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MultipartParser } from './multipart.mjs';

const LIMIT = 1024;
const storage = await mkdtemp(join(tmpdir(), 'uploads-'));

// Приёмник тела: поля собирает строками, файл пишет на диск куском за куском
class UploadSink {
  fields = {};
  path = '';
  size = 0;
  truncated = false;
  #file = null;
  #part = { name: '' };
  #decoder = new TextDecoder();
  #value = '';
  #pause = null;

  start(headers) {
    this.#part = headers;
    this.#decoder = new TextDecoder();
    this.#value = '';
    if (headers.filename === undefined) return;
    // имя на диске складывают поля, а не имя файла: поля приезжают раньше
    this.path = join(storage, this.fields.photoId + '.jpg');
    this.#file = createWriteStream(this.path);
  }

  data(chunk) {
    if (this.#part.filename === undefined) {
      this.#value += this.#decoder.decode(chunk, { stream: true });
      return;
    }
    if (this.truncated) return;
    if (this.size + chunk.length > LIMIT) {
      this.#write(chunk.subarray(0, LIMIT - this.size));
      this.size = LIMIT;
      this.truncated = true;
      return;
    }
    this.#write(chunk);
    this.size += chunk.length;
  }

  end() {
    if (this.#part.filename === undefined) {
      this.fields[this.#part.name] = this.#value + this.#decoder.decode();
      return;
    }
    // файл дописан: ждать drain больше незачем
    this.#pause = null;
    this.#file.end();
    this.#file = null;
  }

  // договор потока из главы 3: write вернул false — ждать drain
  #write(chunk) {
    if (!this.#file.write(chunk)) this.#pause = once(this.#file, 'drain');
  }

  // читать сокет дальше — только когда диск успел принять записанное
  async ready() {
    if (this.#pause === null) return;
    const drain = this.#pause;
    this.#pause = null;
    await drain;
  }
}

// граница едет в content-type запроса, иногда в кавычках
const boundaryOf = (type) => {
  const found = (type ?? '').match(/boundary=(?:"([^"]+)"|([^;]+))/);
  return found === null ? '' : found[1] ?? found[2];
};

async function receive(request) {
  const sink = new UploadSink();
  const parser = new MultipartParser(boundaryOf(request.headers['content-type']), sink);
  for await (const chunk of request) {
    parser.push(chunk);
    await sink.ready();
  }
  return sink;
}

const server = createServer(async (request, response) => {
  const sink = await receive(request);
  const answer = (status, body) => {
    response.writeHead(status, { 'content-type': 'application/json' });
    response.end(JSON.stringify(body));
  };
  if (request.url === '/naive') {
    answer(200, { url: '/photos/' + sink.fields.photoId + '.jpg' });
    return;
  }
  if (sink.truncated) {
    // у неполного файла применения нет
    await rm(sink.path);
    answer(413, { code: 'FILE_TOO_LARGE', limit: LIMIT });
    return;
  }
  answer(200, { url: '/photos/' + sink.fields.photoId + '.jpg' });
});

server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = 'http://127.0.0.1:' + server.address().port;

const photo = (size) => {
  const bytes = new Uint8Array(size);
  for (let i = 0; i < size; i += 1) bytes[i] = 65 + (i % 26);
  return bytes;
};

const send = async (path, photoId, size) => {
  const bytes = photo(size);
  const form = new FormData();
  form.append('photoId', photoId);
  form.append('file', new Blob([bytes], { type: 'image/jpeg' }), 'снимок.jpg');
  const response = await fetch(base + path, { method: 'POST', body: form });
  console.log('POST ' + path + ' · photoId ' + photoId + ' · снимок ' + size + ' Б');
  console.log('  ответ: ' + response.status + ' ' + (await response.text()));
  const files = await Promise.all((await readdir(storage)).sort().map(async (name) => name + ' ' + (await stat(join(storage, name))).size + ' Б'));
  console.log('  в хранилище: ' + (files.join(', ') || '(пусто)'));
  const saved = files.length > 0 ? await readFile(join(storage, photoId + '.jpg')).catch(() => null) : null;
  if (saved !== null) console.log('  на диске — начало отправленного снимка: ' + (Buffer.compare(saved, Buffer.from(bytes.subarray(0, saved.length))) === 0 ? 'да' : 'нет'));
};

await send('/photo', 'A-2', 900);
await send('/naive', 'A-3', 4096);
await send('/photo', 'A-4', 4096);

server.close();
await rm(storage, { recursive: true });
node server.mjs

Модель сошлась с настоящим сервером по всем трём запросам: 900 байт легли на диск целиком и совпали с отправленными, наивная ручка оставила обрезок ровно в предел, осторожная ответила 413, и в хранилище после неё ничего не прибавилось. Проверка «на диске — начало отправленного снимка» здесь не украшение: тело собрал FormData, и сверка байтов доказывает, что из чужого тела разборщик вырезал ровно содержимое части — не прихватив перевод строки перед границей и не потеряв байт в начале. А разрезанную границу проверяет не этот прогон, а пример выше: там тело резалось на куски по 64 байта, а здесь размер кусков выбирает сокет.

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

В проекте разборщик границ пишут не сами: у Fastify для этого есть @fastify/multipart (внутри — @fastify/busboy), а регистрируется он одной строкой с limits: { fileSize }. Дальше обработчик просит await request.file() и получает часть: filename, mimetype, поля, приехавшие раньше файла, и file — поток содержимого. Дорог у этого потока две: toBuffer() собирает файл в памяти, а pipeline отправляет его прямо на диск.

Дорога с потоком выглядит как наша осторожная ручка, только предел считает пакет. Прогноз: что придёт телефону на снимок вчетверо больше предела у ручки с pipeline без единой проверки, у такой же с проверкой и у ручки с toBuffer — и что при этом останется в хранилище?

запись с машины: fastify 5.12.4 и @fastify/multipart 10.1.1 (внутри @fastify/busboy 3.2.2), node 26.5.0, macOS 27 на arm64, 29.09.2026. Запись ручная, потому что @fastify/multipart в носителе runs/_deps не объявлен: пакеты стоят в отдельном стенде (~/.cache/jsp-check/p6-write/api-uploads/probe), вывод — целиком, два прогона подряд совпали · 29 сентября 2026
Настоящий @fastify/multipart через app.inject: поток без проверки, поток с проверкой, toBuffer и что пакет оставляет от присланного имени
// Приём загрузки настоящим @fastify/multipart: четыре ручки через app.inject
import Fastify from 'fastify';
import multipart from '@fastify/multipart';
import { createWriteStream } from 'node:fs';
import { mkdtemp, readdir, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';

const LIMIT = 1024;
const storage = await mkdtemp(join(tmpdir(), 'uploads-'));
const app = Fastify({ logger: false });
await app.register(multipart, { limits: { fileSize: LIMIT } });

// Поток: файл уходит на диск куском за куском
app.post('/naive', async (request) => {
  const part = await request.file();
  const path = join(storage, part.fields.photoId.value + '.jpg');
  await pipeline(part.file, createWriteStream(path));
  return { url: '/photos/' + part.fields.photoId.value + '.jpg' };
});

// То же и вопрос приёмнику после конца потока
app.post('/photo', async (request, reply) => {
  const part = await request.file();
  const path = join(storage, part.fields.photoId.value + '.jpg');
  await pipeline(part.file, createWriteStream(path));
  if (part.file.truncated) {
    await rm(path);
    return reply.code(413).send({ code: 'FILE_TOO_LARGE', limit: LIMIT });
  }
  return { url: '/photos/' + part.fields.photoId.value + '.jpg' };
});

// В память: toBuffer собирает файл целиком
app.post('/buffer', async (request) => {
  const part = await request.file();
  const bytes = await part.toBuffer();
  return { size: bytes.length };
});

// Что пакет оставляет от присланного имени файла
app.post('/name', async (request) => {
  const part = await request.file();
  part.file.resume();
  return { filename: part.filename };
});

const photo = (size) => {
  const bytes = new Uint8Array(size);
  for (let i = 0; i < size; i += 1) bytes[i] = 65 + (i % 26);
  return bytes;
};

const send = async (path, { photoId = 'A-2', name = 'снимок.jpg', size = 10 } = {}) => {
  const form = new FormData();
  form.append('photoId', photoId);
  form.append('file', new Blob([photo(size)], { type: 'image/jpeg' }), name);
  const reply = await app.inject({ method: 'POST', url: path, body: form });
  console.log('POST ' + path + ' · имя ' + JSON.stringify(name) + ' · снимок ' + size + ' Б');
  console.log('  ответ: ' + reply.statusCode + ' ' + reply.body);
};

const listing = async () => {
  const files = await Promise.all((await readdir(storage)).sort().map(async (name) => name + ' ' + (await stat(join(storage, name))).size + ' Б'));
  console.log('в хранилище: ' + (files.join(', ') || '(пусто)'));
};

await send('/photo', { photoId: 'A-2', size: 900 });
await listing();
await send('/naive', { photoId: 'A-3', size: 4096 });
await listing();
await send('/photo', { photoId: 'A-4', size: 4096 });
await listing();
await send('/buffer', { photoId: 'A-5', size: 4096 });
for (const name of ['../../etc/passwd', '..\\..\\win.ini', '..', 'a/../../b.txt', '%2e%2e%2fetc']) {
  await send('/name', { name });
}

await app.close();
await rm(storage, { recursive: true });
node upload.mjs

Ручка с pipeline и без проверки ответила 200 — и оставила в хранилище обрезок в 1024 байта, ровно как наша наивная модель. Пакет про предел знает, но обработчику о нём не говорит: он молча перестаёт отдавать байты и ставит на потоке пометку file.truncated. Спросить её — работа обработчика, и README пакета велит именно это. Способов узнать о пределе у пакета три: событие limit на потоке, пометка truncated после конца потока и ошибка из toBuffer(); первые два README называет рядом — и тут же напоминает, что часть файла, записанную до предела, возможно, придётся удалить. Тогда и выходит 413 со своим кодом, как в третьей строке.

А вот toBuffer() отвечает 413 сам — FST_REQ_FILE_TOO_LARGE, ошибка самого пакета. Соблазнительная простота: предел работает, писать ничего не нужно. Цена у неё в имени метода: чтобы отдать этот отказ, пакет собрал файл в памяти — до предела, а в обычном случае и весь целиком. Пять мегабайт на запрос при сотне загрузок — те самые полгигабайта из первого раздела.

Именно этой дорогой пошёл боевой контроллер автора — тот самый, который глава 7 разбирала по декораторам. Там он был проверкой на понимание каркаса, а здесь от него нужна середина: приём файла и комментарий, объясняющий, почему 413 собирается из исключения. Выдержка ниже — как есть.

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

Комментарий точен, и он же выдаёт цену: «единственный способ узнать о превышении лимита» верен только для toBuffer(). Как только приём переписывают на поток — а переписывать его надо, — способ появляется другой и дешевле: file.truncated после pipeline. Заодно видно, чего в контроллере нет: поля photoId он не читает вовсе, а значит, договор «повтор перезаписывает» не соблюдается: очередь телефона, отправив снимок дважды, получит в хранилище два файла с разными именами. Откуда берутся имена, разбирает следующий раздел.

Пакета @fastify/multipart в носителе зависимостей прогонов (runs/_deps) нет, а версии носителя меняет интегратор: они входят в отпечаток записей и добавляются не «на статью», а на часть. Поэтому вывод снят на отдельном стенде с fastify 5.12.4 и @fastify/multipart 10.1.1, подписан как ручная запись и честно называет, где получен, — ярлык под прогоном это и говорит. Два прогона подряд дали один и тот же вывод.

Имя файла — такой же ввод, как тело запроса: его прислали. Вопрос не «сломает ли оно что-то», а «что случится, если сложить из него путь». Сложить путь — естественное желание: файл же надо куда-то записать. Сервер в прогоне заботливый: под каждый файл он создаёт каталог, потому что без каталога createWriteStream не умеет. Прогноз: какие из шести имён выведут файл за пределы хранилища — как есть и после basename, — и спасёт ли положение переход на свой photoId?

прогон node 26.5.0 · 29 сентября 2026
Куда попадает файл, если путь собрать из присланного имени, из его basename и из своего photoId
// Куда попадает файл, если его имя придумал клиент
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, join, relative, sep } from 'node:path';

const root = await mkdtemp(join(tmpdir(), 'uploads-'));
const storage = join(root, 'app', 'uploads');
await mkdir(storage, { recursive: true });

// Путь внутри каталога — это общее начало плюс разделитель. Проверка
// relative(dir, path).startsWith("..") звала бы беглецом и файл «..jpg»
const inside = (dir, path) => path === dir || path.startsWith(dir + sep);

// Заботливый сервер: складывает файл под присланным именем и сам создаёт
// каталог под него — без каталога createWriteStream не умеет
const save = async (name) => {
  const target = join(storage, name);
  const where = relative(root, target) + ' · ' + (inside(storage, target) ? 'в хранилище' : 'ВЫШЕЛ за хранилище');
  try {
    await mkdir(dirname(target), { recursive: true });
    await writeFile(target, 'снимок');
    return where;
  } catch (error) {
    return where + ' · не записан: ' + error.code;
  }
};

const NAMES = ['снимок.jpg', '../../etc/passwd', '..\\..\\win.ini', '..', 'a/../../b.txt', '%2e%2e%2fetc'];

for (const name of NAMES) {
  console.log('имя от клиента ' + JSON.stringify(name));
  console.log('  как пришло: ' + (await save(name)));
  console.log('  basename:   ' + JSON.stringify(basename(name)) + ' → ' + (await save(basename(name))));
}

console.log('в дереве после загрузок: ' + (await readdir(root, { recursive: true })).sort().join(' '));

// Своё имя: владелец, photoId и расширение из типа части — сначала как есть,
// потом с проверкой формы photoId
const PHOTO_ID = /^[A-Za-z0-9-]{1,40}$/;
const EXT = { 'image/jpeg': 'jpg', 'image/png': 'png' };

const pathOf = (userId, photoId, type, checked) => {
  if (checked && !PHOTO_ID.test(photoId)) return null;
  const ext = EXT[type];
  return ext === undefined ? null : join(storage, userId, photoId + '.' + ext);
};

for (const photoId of ['A-2', '../../../etc/passwd', '../боря/A-2']) {
  for (const checked of [false, true]) {
    const target = pathOf('аня', photoId, 'image/jpeg', checked);
    const verdict = (path) =>
      !inside(storage, path) ? 'ВЫШЕЛ за хранилище' : inside(join(storage, 'аня'), path) ? 'в каталоге Ани' : 'в ЧУЖОМ каталоге';
    console.log(
      'photoId ' + JSON.stringify(photoId) + (checked ? ' с проверкой:  ' : ' без проверки: ') +
        (target === null ? '400 BAD_PHOTO_ID' : relative(root, target) + ' · ' + verdict(target)),
    );
  }
}

await rm(root, { recursive: true });
node name.mjs

Имя ../../etc/passwd вывело файл из хранилища в два счёта — и записалось: каталог сервер создал сам. basename из node:path помогает, но не спасает: имя из двух точек он оставляет как есть, и путь снова уходит на каталог выше — в прогоне это видно по EISDIR, потому что там оказался существующий каталог, а не свободное имя. Обратная косая на macOS — обычный знак имени, и файл ..\..\win.ini лёг в хранилище целиком; на Windows тот же клиент вышел бы из него. А %2e%2e%2fetc прошёл как есть — но только потому, что имя никто не декодировал.

Настоящий @fastify/multipart путь из имени срезает сам: в @fastify/busboy у опции preservePath значение по умолчанию false, и тогда к имени применяется его собственный basename — он режет и прямую косую, и обратную, а имя из двух точек превращает в пустую строку. В прогоне предыдущего раздела это и видно по порядку имён: passwd, win.ini, пустая строка, b.txt. Но это работа библиотеки, а не защита читателя: она срезает путь, потому что имя файла — это имя, а не путь. Вывод из обоих прогонов один, и он проще любой чистки: присланное имя не использовать вовсе.

Использовать вместо него photoId — правильный ход, только не потому, что photoId «свой». Он тоже приехал в теле запроса, и вторая половина прогона показывает, чего это стоит: ../../../etc/passwd выводит файл из хранилища так же, как имя файла, а ../боря/A-2 — и вовсе в каталог другого человека, оставаясь внутри хранилища. Лечится это не срезкой пути, а проверкой формы: photoId обязан выглядеть как photoId, и что не подошло под ^[A-Za-z0-9-]{1,40}$ — это 400, а не повод чинить путь. Расширение при этом берётся из типа части по белому списку, как в боевом хранилище: имя файла не участвует и здесь.

Проверка «вышел за каталог» через relative(dir, path).startsWith("..") ошибается на именах, которые начинаются с двух точек: файл ..jpg лежит внутри, а проверка объявляет его сбежавшим. Сравнивать надо начало пути вместе с разделителем.

В прогоне %2e%2e%2fetc остался безобидной строкой, и легко решить, что процентные последовательности в именах не страшны. Страшен тот, кто их развернёт. Сервер, который «на всякий случай» зовёт decodeURIComponent над именем файла — например, чтобы починить кодировку, — получает из этой строки ../etc и возвращает себе ту же дыру. Чинить там нечего: проценты раскрывают в адресе, посегментно и после разреза, а в filename= имя приезжает готовым текстом. Для имён, которые в такую строку не помещаются, у заголовка части есть отдельный параметр с объявленной кодировкой — filename*; его @fastify/busboy разворачивает сам и сам же режет из него путь: filename*=UTF-8''%2e%2e%2fetc%2fpasswd доезжает до обработчика как passwd. Своему decodeURIComponent места не остаётся ни там, ни там.

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

Соберём это на таблице из главы 9 и приёмнике из второго раздела. Строка фото — user_id, photo_id, размер и путь; повтор загрузки не заводит вторую строку, а обновляет первую, как обещано телефону. Прогноз: что будет у Ани и у Бори после того, как Боря пришлёт её photoId — сначала у сервера, который отпирает фото одним photo_id, потом у того, кто отпирает парой?

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

У сервера с одним photo_id Боря загрузил своё фото в строку Ани: повтор нашёл её строку, обновил размер и путь — и теперь Аня по своему адресу получает 700 байт, которых не отправляла. Файлов на диске один вместо двух, потому что путь тоже звался одним photoId. Заметьте, что строка при этом осталась «Аниной»: поле владельца у неё не менялось, менялось только содержимое, — и по журналу такой подмены не видно вовсе.

У сервера с парой те же два запроса разошлись, как им и положено: две строки, два файла, у каждого свой каталог, размеры на месте. И лечение опять не в проверке внутри обработчика, а в ключе: чужой photoId не «отвергается» — он просто отпирает другую строку, ту, которой у Бори ещё нет, и заводит её.

Но ключ строки сам по себе файл не спасает, и это главное отличие от главы синхронизации. Стоило бы оставить путь на диске из одного photoId — и при двух честных строках байты у них были бы общие: вторая загрузка перезаписала бы файл первой, и обе строки показывали бы одну картинку. Владелец нужен в обоих ключах, и в примере это одна и та же функция keyOf: она даёт и ключ строки, и имя файла.

Выдача устроена так же, как всё в главе 11: чужое отвечает как несуществующее. Поиск идёт по паре, и чужой photoId не находится — 404, а не 403, потому что 403 подтвердил бы, что такое фото есть. Личность для этой пары сервер, разумеется, берёт не из тела запроса, а вычисляет на каждом запросе — и у боевой загрузки с этим отдельная история: её страж проверяет только подпись токена, а существование пользователя и его роль не спрашивает.

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

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

А выбирает реализацию фабрика в модуле, по переменной окружения. Обработчик о выборе не знает ничего: он просит FILE_STORAGE и получает то, что собрал контейнер.

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

Отсюда берутся сразу три вещи, и все три — не про файлы. Первая: в проде хранилище S3-совместимое, и в compose это MinIO с бакетом и политикой доступа. Вторая: на место хранилища в тесте можно поставить подделку, которая ничего не пишет, — той же подменой провайдера, о которой речь в статье про тесты с настоящей базой; у автора такого теста пока нет — загрузку его набор не проверяет вовсе. Третья: save принимает Buffer — то есть переход на поток меняет не только контроллер, но и этот интерфейс: в нём должен появиться поток и путь, а не собранные байты.

Отдаёт файлы наружу уже не приложение. В разработке это @fastify/static на каталоге uploads, в проде — само хранилище, куда запрос за файлом уводит прокси, а не приложение: политика бакета разрешает анонимам ровно s3:GetObject, то есть чтение по известному адресу и ничего больше. Значит, проверка владельца на загрузке чтения не защищает: кто знает адрес, тот и смотрит. Пока имя файла — randomUUID, это работает на неугадываемости; имя вида user/photoId тоже не подбирается перебором, потому что photoId телефон выдаёт как имена строк журнала — со случайным идентификатором устройства впереди. Но опираться на это стоит осознанно: если фото должны быть закрыты по-настоящему, раздачу закрывают стражем перед файлом или подписанной ссылкой с коротким сроком, и тогда анонимный s3:GetObject из политики уходит.

Из обещанного этой статье остался sharp: полка React и Next говорит, что свой sharp в зависимостях нужен только для собственной обработки загрузок на сервере, — вот это оно и есть. Уменьшить снимок до разумного размера, снять EXIF (в нём едут координаты съёмки), отдать миниатюру списку. Ставится он рядом с приёмом и в такой раскладке работает над уже сохранённым файлом: буфер он принимает тоже, но собирать его ради этого — вернуться к тому, от чего ушли; в записях прогонов этой части его нет — пакета в носителе нет, а sharp тянет за собой libvips.

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

  • Файл не собирают в памяти: разборщик границ держит только хвост длиной в разделитель без одного байта, а приёмник пишет содержимое на диск куском за куском. В примере наибольший буфер — 139 байт при снимке на 3000.
  • Поля приезжают раньше файла, и путь на диске считают они. Присланное имя файла не используют вовсе: путь из него выводит файл из хранилища, basename из node:path оставляет имя из двух точек, а срезка пути внутри @fastify/busboy — работа библиотеки, а не защита.
  • content-length описывает всё тело — границы, заголовки частей и поля, — поэтому предел на файл считают в байтах на ходу. Оборвать соединение раньше времени нельзя: на большом теле клиент получит EPIPE вместо ответа.
  • Предел срабатывает посреди файла, и обрезок к моменту ответа уже на диске: его удаляют и отвечают 413 своим кодом. У @fastify/multipart сигналов три — событие limit на потоке, пометка part.file.truncated и ошибка из toBuffer(), — но сам пакет отвечает 413 только из последнего, то есть собрав файл в памяти.
  • photoId с телефона — такой же ввод, как имя файла: его проверяют по форме, а расширение берут из типа части по белому списку. Ключ (user_id, photo_id) нужен и строке, и пути на диске, иначе строки у каждого свои, а байты общие; чужой photoId отвечает как несуществующий.
  • Хранилище живёт за токеном контейнера: папка в разработке, S3 в проде, место для подделки в тесте — и обработчик об этом не знает. Раздача файлов при этом анонимна, и секретность держится на неугадываемом имени, а не на страже загрузки.

1/4Ручка принимает файл потоком: await pipeline(part.file, createWriteStream(path)), у @fastify/multipart задан limits.fileSize. Клиент прислал файл вдвое больше предела. Что он получит?

2/4Имя файла сервер не использует вовсе: путь — join(storage, userId, photoId + ".jpg"), а photoId приехал полем формы. Чего не хватает?

3/4Строка фото отпирается парой (user_id, photo_id), а файл на диске зовётся одним photoId. Что произойдёт, когда Боря пришлёт photoId, который уже есть у Ани?

4/4Разборщик границ в состоянии «тело части» отдаёт приёмнику не весь буфер, а всё, кроме хвоста длиной в разделитель без одного байта. Зачем?

Ни байта чужого типа

Приём открывает файл на диске, как только увидит часть с именем файла, — и только потом кто-то может заметить, что это application/zip. Тип части известен раньше: он приезжает в её заголовках, до первого байта содержимого. Отказывайтесь там же: 415 с кодом BAD_MIME, и чтобы в хранилище при этом не появилось ничего.

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

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

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

На image/jpeg ответ прежний, на application/zip — 415 {"code":"BAD_MIME","type":"application/zip"}, и в обоих случаях список диска показывает только то, что разрешено: у второго запроса файл не открывался. Тело при этом дочитывается до конца — по той же причине, что и с пределом: оборвать соединение значит не отдать ответ.

Обратите внимание, чего проверка не доказывает. Тип части пишет клиент; application/zip в заголовке — это признание, а не улика. Чтобы узнать, картинка ли пришла на самом деле, нужны первые байты файла (сигнатура) или библиотека вроде sharp, которая попробует её открыть.

Предел на всё тело, а не только на файл

Предел у приёмника стоит на файле — и это всё, что он охраняет. Тело, в котором файл маленький, а поле рядом с ним на сорок килобайт, проходит целиком, и поле собирается в строку в памяти процесса. Добавьте телу свой предел: считать байты до разбора и отказывать 413 с кодом BODY_TOO_LARGE, не читая остаток.

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

Счётчик тела живёт в цикле чтения, до push: разборщику незачем разбирать то, что мы уже отвергли.

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

Обычное тело проходит, тело с полем на сорок килобайт получает 413 BODY_TOO_LARGE — и заметьте вторую строку: тело с файлом на 4096 байт тоже упирается в предел тела, а не в предел файла, потому что 4096 байт файла плюс формат больше 4096 байт тела. Два предела не заменяют друг друга и должны быть согласованы: предел тела — это предел файла плюс запас на границы, заголовки частей и поля.

Удаление обрезка в отказе не лишнее: к моменту, когда счётчик тела сработал, приёмник мог уже открыть файл и что-то в него записать. Тот же disk.remove, что и у предела файла, — и по той же причине.