Перейти к содержимому
Skein
← Все проекты

backend · intermediate · 6d

Загрузка через presigned URL

Прямая загрузка в хранилище через presigned URL с ограничениями размера и content-type и webhook завершения, проверяющим, что объект действительно прибыл, — чтобы API-сервер никогда не касался байт файлов.

Проксирование загрузок через API-сервер — распространённая ошибка новичков: это тратит пропускную способность, блокирует потоки запросов и создаёт единую точку отказа. Presigned URL сдвигают тяжёлые байты напрямую в хранилище, оставляя авторизацию на сервере. Сложные части продуктового уровня: вшивание ограничений в подпись, чтобы хранилище применяло их до записи байта, ограничение CORS-политики до минимума, предотвращение перезаписи ключей серверными UUID, верификация получения через HEAD ETag вместо доверия клиенту и наблюдаемость всего потока, чтобы флуд просроченных или сверхразмерных загрузок обнаруживался по дашборду, а не по тикету поддержки.

Результат

Поток загрузки, где клиент получает presigned PUT URL (серверный UUID-ключ, ограниченные content-type/размер, короткий срок), грузит напрямую в S3-совместимое хранилище без прокси, а сервер подтверждает получение, запрашивая ETag/размер — с дашбордом успешности загрузок и латентности webhook.

Этапы

0/5 · 0%
  1. 01Выдай ограниченный presigned PUT

    Выдавай presigned PUT URL, где каждое ограничение вшито в подпись, а не проверяется постфактум. API генерирует серверный UUID-ключ (никогда не выбираемый вызывающим), короткий срок (например, 60–300с), разрешённый Content-Type (например, image/jpeg) и максимальный Content-Length (например, 5 МБ) — всё закодировано в политике/подписи presigned URL, так что сам S3 применяет их до записи байта. Подделанный URL (не тот content-type, превышение размера, просрочка) отклоняется хранилищем с 403/SignatureDoesNotMatch, а не логикой приложения, срабатывающей после того как байты уже сохранены. Это важно, потому что webhook срабатывает после хранилища — если ограничения проверяются только там, злоумышленник уже сохранил скрипт под видом изображения. Докажи: попробуй загрузить с поддельным content-type и сверхразмерным payload и покажи, что хранилище отклоняет оба до срабатывания webhook.

    Критерии готовности
    • POST /uploads/presign возвращает presigned PUT URL с серверным UUID-ключом, коротким сроком (≤300с), фиксированными Content-Type и максимальным Content-Length, вшитыми в подпись; подделанные/сверхразмерные/просроченные URL отклоняются хранилищем (403), а не API.
    • Ключ никогда не задаётся вызывающим; два вызова presign дают разные UUID-ключи, вызывающий не может угадать ключ другого пользователя.
    Самопроверка

    Покажи, что поддельный content-type и сверхразмерная загрузка оба отклонены хранилищем (403) до срабатывания webhook. Senior-ревьюер проверяет, что ограничения в подписи, ключ — серверный UUID, срок короткий (не 1ч).

  2. 02CORS-прямая загрузка без прокси

    Настрой бакет так, чтобы браузер грузил напрямую без байт через API-сервер — проксирование привязывает один поток обработчика на загрузку на всё время передачи (100 параллельных загрузок по 100 МБ при 10 МБ/с = 1000 поток-секунд I/O). Задай минимальную CORS-политику: точные разрешённые источники (не `*`), конкретные нужные заголовки (`Content-Type`, `x-amz-*`), короткий `Access-Control-Max-Age` для кеша preflight и разрешённый метод `PUT`. Проверь поток: браузер шлёт OPTIONS preflight, получает CORS-заголовки, затем PUTит напрямую на presigned URL. Ни один байт файла не касается API — сервер видит только presign-запрос и позже подтверждение завершения. Объясни, почему `*` на публичном бакете без кук не уязвимость, но всё равно неверно на приватном, где presigned URL несут авторизацию.

    Критерии готовности
    • CORS бакета разрешает только источнику приложения PUT с Content-Type/x-amz-*; OPTIONS preflight из браузера проходит, последующий PUT идёт напрямую в хранилище — проверено, что ни один байт файла не проходит через API (хендлер никогда не видит тело).
    • Запрос с неразрешённого источника падает на CORS preflight; wildcard `*` не используется, max-age короткий и задокументирован.
    Самопроверка

    Покажи поток OPTIONS→PUT в DevTools Network и докажи, что байты не попали в API. Senior-ревьюер проверяет минимальность CORS-политики (точный источник, конкретные заголовки, без wildcard на приватном бакете) и спрашивает, почему `*` там всё равно неверно.

  3. 03Серверные ключи и защита от перезаписи

    Предотврати перезапись ключей и replay-злоупотребления. Если вызывающий выбирает ключ объекта (например, username или имя файла от клиента), любой авторизованный пользователь может перезаписать файл другого, угадав ключ, и многократно PUTить на тот же ключ в пределах TTL URL, заменяя легитимный файл после того как webhook уже его подтвердил. Почини: presign-эндпоинт генерирует UUID-ключ, сохраняет запись ожидающей загрузки `{uploadId, key, expectedContentType, maxSize, createdAt, status:'pending'}`, и webhook завершения помечает только загрузку, чей UUID совпадает. Запись ожидания также хранит окно истечения, чтобы рассуждать о гонке: клиент мог загрузить корректный файл, пройти верификацию, затем повторно PUTнуть другой файл на тот же ключ до истечения URL. Смягчи, сохраняя контракт ожидаемого ETag или через короткий срок + одноразовость (инвалидируй запись ожидания при первом завершении). Задокументируй компромисс: короткий срок сужает окно гонки, но заставляет клиентов чаще пере-presign.

    Критерии готовности
    • Ключи — серверные UUID, сохранённые в записи ожидающей загрузки; ключ от клиента игнорируется/отклоняется, угадать ключ другого пользователя невозможно.
    • Повторный PUT на тот же ключ в пределах TTL presigned после подтверждения webhook обнаружен (запись ожидания инвалидирована или несовпадение ETag) и молча не заменяет подтверждённый файл — окно гонки и смягчение задокументированы.
    Самопроверка

    Покажи, что два presign дают разные UUID-ключи и что повторный PUT после подтверждения отклонён. Senior-ревьюер проверяет наличие записи ожидания, указание окна гонки и защиту смягчения (короткий TTL + инвалидация при complete), а не только 'используй UUID'.

  4. 04Webhook завершения с верификацией ETag

    Проверяй получение на сервере — никогда не доверяй сообщению клиента 'я загрузил'. Webhook завершения (или клиентский POST /uploads/:id/complete) запрашивает метаданные объекта из хранилища (HEAD Object → ETag + Content-Length) и подтверждает соответствие ожидаемым значениям из контракта presign (content-type, размер в пределах max, ключ совпадает с записью ожидания). Webhook идемпотентен: повтор или дублирующая доставка (retry сети, fan-out событий S3) с тем же uploadId — no-op, а не двойная обработка. Важны два режима отказа: (1) подмена — HEAD показывает другой ETag/размер, чем ожидалось, потому что клиент повторно PUTнул другой файл в окне TTL (см. этап 3); (2) призрачное завершение — клиент сообщает о завершении, но объект не прибыл (HEAD 404). Оба отклоняются, никогда не помечаются 'получено'. Задокументируй, почему верификация ETag ломает multipart-загрузки (составной ETag из хешей частей) и что бы ты сделал там вместо этого (хранить ETag частей или перейти на checksum S3 Multipart Complete).

    Критерии готовности
    • POST /uploads/:id/complete запрашивает HEAD Object и подтверждает соответствие ETag + размера контракту presign; призрачное завершение (HEAD 404) и подмена (несовпадение ETag после повторного PUT) отклоняются и логируются.
    • Повторная доставка webhook с тем же uploadId идемпотентна — второй вызов возвращает тот же результат без побочных эффектов, доказано двукратным вызовом webhook в тесте.
    Самопроверка

    Покажи отклонение HEAD 404, отклонение несовпадения ETag после повторного PUT и no-op дублирующего webhook. Senior-ревьюер проверяет, что webhook никогда не доверяет телу клиента, и спрашивает, как бы ты обработал multipart ETag.

  5. 05Нагрузи, наблюдай и отработай инцидент

    Докажи под прод-подобной нагрузкой и сделай наблюдаемым, когда оно ведёт себя плохо. Нагрузи полный поток (presign → прямой PUT → завершение) множеством параллельных клиентов (например, 50 параллельных загрузок, смесь валидных/невалидных, просроченные URL, сверхразмерные попытки) и найди QPS, где узким местом становится presign-эндпоинт или webhook, а не прямой PUT в хранилище. Снимай RED-метрики (rate presign, error rate presign, rate завершений, длительность верификации webhook p50/p99) и трейс-span на HEAD-верификацию, чтобы собственная латентность системы была видна в водопаде. Затем отработай инцидент: инжектируй флуд повторов просроченных URL или всплеск сверхразмерных загрузок и смотри, как взлетает error rate presign, пока прямые PUT остаются здоровыми. Обнаружь это по дашборду (не логам), смягчи (короче срок / отклонение размера на presign / rate-limit presign) и напиши пост-мортем на 5 строк, чья превенция — не 'увеличь хранилище'.

    Критерии готовности
    • Устойчивый нагрузочный тест (≥50 параллельных загрузок, смесь валидных/невалидных/просроченных) сообщает presign QPS, rate завершений, p50/p99 webhook, дашборд показывает все четыре с видимым трейс-span HEAD.
    • Ты воспроизвёл инцидент (флуд просроченных URL или всплеск сверхразмерных), обнаружил его по дашборду, смягчил и написал пост-мортем с корневой причиной и превенцией, которая не 'увеличь хранилище'.
    Самопроверка

    Вставь дашборд и пост-мортем. Senior-ревьюер проверяет, что узкое место отнесено к presign/webhook (не прямому PUT), трейс его локализовал, а превенция адресует срок/верификацию/rate-limit, а не 'масштабируй хранилище'.

Стартер

fallowlone/skein-projects

projects/presigned-upload

Открыть на GitHub ↗
  • README.md
  • src/presign.ts
  • test/presign.test.ts
Забрать только этот проект npx degit fallowlone/skein-projects/projects/presigned-upload presigned-upload

Реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Форкни репозиторий и запушь свою работу — workflow grade прогонит тесты и статические проверки на твоих раннерах.

Рубрика

Джуниор Миддл Сеньор
Ограничения presigned URL API выдаёт presigned PUT без ограничений; клиент может загрузить 5 ГБ видео там, где ожидалось изображение 2 МБ, или подставить произвольный content-type. URL подписан со сроком действия, фиксированным разрешённым Content-Type и максимальным Content-Length; подделанный или превышающий размер PUT отклоняется хранилищем, а не API — таким образом, принуждение встроено в подпись, а не в логику приложения на каждый запрос. Ты рассматриваешь злоупотребление перезаписью ключей: presigned PUT на предсказуемый ключ позволяет любому авторизованному пользователю перезаписать файл другого. Ты решаешь это серверно-назначенными UUID-ключами (никогда не выбираемыми вызывающим) и рассуждаешь об окне гонки между загрузкой и завершением: клиент может загрузить корректный файл, затем заменить его до срабатывания webhook, повторно использовав URL в пределах срока действия.
CORS и поток прямой загрузки Загрузка из браузера проксируется через API-сервер; каждая загрузка проходит через память приложения и блокирует поток запроса на всё время. CORS-политика S3-бакета разрешает браузерному источнику PUT напрямую; preflight OPTIONS-запрос проходит чисто, и ни один байт файла не касается API-сервера. Ты ограничиваешь CORS-политику до необходимого минимума: точные разрешённые источники, конкретные нужные заголовки (Content-Type, x-amz-*) и короткий max-age для кеша preflight. Ты объясняешь, почему wildcard CORS-политика на публичном бакете не является уязвимостью (нет кук, нет ambient authority), но почему она всё равно неверна на приватном бакете, где presigned URL несут авторизацию.
Webhook завершения и верификация получения Клиент самостоятельно сообщает о завершении; сервер доверяет сообщению и помечает загрузку как полученную, не проверяя, что объект существует или соответствует запрошенному. Webhook завершения запрашивает ETag и размер объекта из хранилища и подтверждает их соответствие ожидаемым значениям; webhook идемпотентен, так что повторная доставка при retry не приводит к двойной обработке. Ты рассуждаешь об окне подмены: между истечением presigned PUT и срабатыванием webhook существует гонка, когда другой файл может быть загружен на тот же ключ. Ты предотвращаешь это, сохраняя ожидаемый ETag (производный от предзагрузочного контракта) и отказываясь помечать получение как валидное, если сохранённый ETag отличается — и документируешь компромисс: верификация ETag ловит подмену, но ломает multipart-загрузки, где ETag собирается из хешей частей.
Эталонный разбор (спойлер)

Почему presigned PUT вместо проксирования: проксирование каждой загрузки через API-сервер привязывает один поток обработчика запросов на загрузку на всё время передачи. При 10 МБ/с и файле 100 МБ это 10 секунд времени потока на загрузку — сервер, обрабатывающий 100 параллельных загрузок, тратит 1000 поток-секунд на I/O. Presigned URL переносят этот I/O напрямую в объектное хранилище, которое масштабируется горизонтально с ничтожной стоимостью.

Принуждение content-type и размера должно быть в подписи, а не в webhook: webhook срабатывает после того, как байты уже в хранилище. Если ограничения проверяются только там, злоумышленник может сохранить скрипт под видом изображения, и приложение уже приняло его в свой бакет. Кодирование content-type и максимального размера в подписи presigned URL означает, что само хранилище применяет политику до записи единого байта.

Серверно-назначенные ключи предотвращают атаки перезаписи: если вызывающий выбирает ключ объекта (например, своё имя пользователя), он может перезаписать любой ранее загруженный файл или угадать ключ другого пользователя. UUID, сгенерированный сервером во время presign, сохранённый в записи ожидающей загрузки и проверяемый в webhook, делает ключ неугадываемым, а загрузку — непереигрываемой на другой слот.

Гонка завершения и верификация ETag: presigned URL действителен на всё своё TTL после выдачи. Клиент может загрузить легитимный файл, получить проходящий ETag от webhook, а затем загрузить другой файл на тот же ключ в оставшееся TTL URL. Сохранение ожидаемого ETag во время presign и отказ принимать получение, не совпадающее с ним, закрывает это окно — ценой несовместимости с multipart-загрузками, которые производят составной ETag.

Сделай по-сеньорски

  • Замени однофрагментный PUT на S3 multipart upload для файлов больше 5 МБ: создавай, грузи части параллельно и завершай — с хранением возобновляемого состояния на сервере и верификацией по ETag частей вместо одиночного ETag.
  • Добавь серверную проверку на вирусы в webhook завершения через триггер Lambda/Worker перед пометкой загрузки как безопасной — с состоянием карантина и асинхронным колбэком результата сканирования.
  • Добавь прогресс загрузки + возобновляемый retry на клиенте: отслеживай отправленные байты, ретрай упавшие части и показывай прогресс-бар, переживающий перезагрузку страницы через IndexedDB.

Навыки

presigned URL signing (expiry + policy)CORS preflight for direct browser uploadcontent-type + size enforcement in signatureserver-assigned keys & clobber preventioncompletion webhook with ETag verification & idempotency

Рекомендуемый стек

nodehonoaws-sdk (S3-compatible)zod

Материалы