backend · intermediate · 6d
Загрузка через presigned URL
Прямая загрузка в хранилище через presigned URL с ограничениями размера и content-type и webhook завершения, проверяющим, что объект действительно прибыл, — чтобы API-сервер никогда не касался байт файлов.
Результат
Поток загрузки, где клиент получает presigned PUT URL (серверный UUID-ключ, ограниченные content-type/размер, короткий срок), грузит напрямую в S3-совместимое хранилище без прокси, а сервер подтверждает получение, запрашивая ETag/размер — с дашбордом успешности загрузок и латентности webhook.
Этапы
0/5 · 0%- 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ч).
- 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 на приватном бакете) и спрашивает, почему `*` там всё равно неверно.
- 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'.
- 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.
- 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
- 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.