Выберите продукт

Прямые загрузки в S3/Object Storage из браузера: pre‑signed URL и CORS

Разбираем практику прямых загрузок в S3/object storage без проксирования через бэкенд. Что выбрать — pre‑signed PUT или POST, как правильно настроить CORS, защититься от абьюза, подключить multipart и отлавливать типовые ошибки.
Прямые загрузки в S3/Object Storage из браузера: pre‑signed URL и CORS

Прямые загрузки в S3/object storage из браузера — это способ отправлять файлы сразу в хранилище, минуя ваш веб‑сервер. Вы экономите трафик и CPU бэкенда, разгружаете очередь запросов и получаете более предсказуемую стоимость. Ключ к такой схеме — pre‑signed URL и корректная настройка CORS. Разберём, какие есть варианты (PUT/POST, multipart), как их защитить, какие заголовки и методы открыть в CORS, и на что чаще всего спотыкаются интеграции.

Когда прямые загрузки в S3 уместны

Схема особенно полезна, если пользователи грузят медиафайлы, архивы бэкапов, логи или большие фото/видео. Чем крупнее файл и чем больше одновременных аплоадов, тем ощутимее выгода: ваш бэкенд перестаёт быть «прокси‑трубой», а отвечает только за аутентификацию и выдачу короткоживущих разрешений (pre‑signed URL). В результате:

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

Бэкенд‑подписант может жить где угодно: от простого PHP на нашем виртуальном хостинге до микросервиса на VDS. Если публикуете загруженные файлы с собственного домена, не забудьте о HTTPS и корректных SSL-сертификатах.

Почему не давать браузеру доступ по ключам

Давать браузеру прямые ключи доступа к S3/object storage небезопасно: их украдут, они «живут» слишком долго и открывают слишком широкие права. Вместо этого бэкенд генерирует pre‑signed URL с жёсткими ограничениями: конкретный объект (ключ), метод, заголовки и короткое время жизни. Для браузера это обычный одноразовый «адрес для загрузки», не требующий знания секретов.

Сравнение подходов pre‑signed PUT и POST при загрузках в S3

Два основных паттерна: PUT и POST (form)

Существует два базовых подхода к прямым загрузкам в S3‑совместимые хранилища:

  • Pre‑signed PUT. Бэкенд выдаёт URL для PUT одного объекта. В запросе можно закрепить Content-Type, опционально Content-MD5, заголовки шифрования и метаданные. На фронтенде удобно отправлять через fetch или XMLHttpRequest, контролируя прогресс.
  • Pre‑signed POST (form upload). Бэкенд формирует policy с условиями (префикс ключа, лимит размера через content-length-range, разрешённые заголовки). Браузер собирает FormData и делает POST на S3. Часто применяется, когда надо гибко ограничить поля, а также хорошо поддерживается мобилками.

Оба способа совместимы с CORS, но набор разрешённых методов и заголовков отличается. PUT проще для одного файла «как есть»; POST даёт дополнительный уровень валидации через policy.

Минимальная теория по CORS для S3

Браузер сначала делает preflight (OPTIONS) при кросс‑доменных запросах, чтобы выяснить, какие методы и заголовки разрешены. Поэтому в конфигурации CORS на бакете нужно явно открыть:

  • Origins — домены, с которых фронтенд обращается к бакету. Лучше указывать точные источники, а не *.
  • Methods — как минимум PUT/POST для загрузки, GET/HEAD для чтения/проверок, а также OPTIONS для preflight.
  • Headers — список заголовков, которые браузер будет отправлять: как стандартные (например, Content-Type), так и специфичные для S3 (x-amz-meta-*, x-amz-content-sha256, x-amz-server-side-encryption и т. п.).
  • ExposeHeaders — какие заголовки можно «выдать» браузеру в ответе (например, ETag).

Чем уже CORS — тем лучше. Разрешайте только нужные источники, методы и заголовки, без «на всякий случай». Для pre‑signed URL обычно не требуется Authorization, поэтому не добавляйте его в CORS без необходимости.

Пример CORS для PUT/POST

Ниже — типовой минимальный набор правил, которого хватает для PUT и POST, а также для чтения и проверки ETag. Учитывайте, что синтаксис и способ применения правил зависит от конкретного S3‑совместимого провайдера; значения доменов подставьте свои.

<CORSConfiguration>
  <CORSRule>
    <AllowedOrigin>https://app.example.com</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedMethod>HEAD</AllowedMethod>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedMethod>POST</AllowedMethod>
    <AllowedHeader>Content-Type</AllowedHeader>
    <AllowedHeader>Content-MD5</AllowedHeader>
    <AllowedHeader>x-amz-meta-*</AllowedHeader>
    <AllowedHeader>x-amz-server-side-encryption</AllowedHeader>
    <ExposeHeader>ETag</ExposeHeader>
    <MaxAgeSeconds>300</MaxAgeSeconds>
  </CORSRule>
</CORSConfiguration>

Если у вас несколько фронтендов (админка, клиентское SPA), добавьте несколько AllowedOrigin или отдельных правил. Не используйте *, если планируете cookie/авторизацию в запросах — браузер этого не позволит совместно с credentials. Если проксируете S3 через свой сервер, пригодится материал CORS‑заголовки в Nginx и Apache.

Бэкенд: генерация pre‑signed URL и policies

Бэкенд‑эндпоинт должен проверять права пользователя, параметры файла (тип, предполагаемый размер, категорию контента), генерировать URL/policy и возвращать фронтенду только минимум необходимой информации. Убедитесь, что:

  • URL живёт недолго (пару минут) и привязан к конкретному ключу.
  • Путь (префикс) зашит на уровне серверной логики: например, users/{userId}/uploads/....
  • По возможности закреплён Content-Type; для POST — ограничен content-length-range в policy.
  • ACL по умолчанию приватная; не позволяйте клиенту передавать x-amz-acl: public-read, если это не строго необходимо и контролируемо.

Пример: pre‑signed PUT (Node.js)

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "us-east-1" });

export async function issuePutUrl({ bucket, key, contentType }) {
  const cmd = new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    ContentType: contentType,
    ACL: "private"
  });
  const url = await getSignedUrl(s3, cmd, { expiresIn: 120 });
  return { url, method: "PUT", headers: { "Content-Type": contentType } };
}

Пример: pre‑signed PUT (Python)

import boto3

def issue_put_url(bucket, key, content_type):
    s3 = boto3.client("s3", region_name="us-east-1")
    url = s3.generate_presigned_url(
        ClientMethod="put_object",
        Params={
            "Bucket": bucket,
            "Key": key,
            "ContentType": content_type,
            "ACL": "private"
        },
        ExpiresIn=120
    )
    return {"url": url, "method": "PUT", "headers": {"Content-Type": content_type}}

Пример: pre‑signed POST policy

POST использует policy с условиями. Бэкенд подписывает policy и возвращает набор полей, которые браузер положит в FormData.

{
  "expiration": "2030-01-01T00:00:00Z",
  "conditions": [
    {"bucket": "my-bucket"},
    ["starts-with", "$key", "users/123/uploads/"],
    {"acl": "private"},
    ["content-length-range", 1, 104857600],
    {"Content-Type": "image/jpeg"},
    ["starts-with", "$x-amz-meta-origin", "webapp"]
  ]
}

В ответ фронтенду верните: url приёмной точки, а также поля формы (включая policy, подпись, дату и т. п.). Экспирацию делайте короткой, policy — максимально жёсткой.

FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

Фронтенд: загрузка через PUT

PUT удобен простотой. Если важен прогресс, используйте XMLHttpRequest; если хватает «пустить и забыть» — fetch.

// Получаем от бэкенда { url, headers }
async function uploadWithFetch(file, presigned) {
  const res = await fetch(presigned.url, {
    method: "PUT",
    headers: presigned.headers,
    body: file
  });
  if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
  const etag = res.headers.get("ETag");
  return { etag };
}

function uploadWithXHR(file, presigned, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("PUT", presigned.url);
    Object.entries(presigned.headers).forEach(([k, v]) => xhr.setRequestHeader(k, v));
    xhr.upload.onprogress = evt => {
      if (evt.lengthComputable) onProgress(Math.round((evt.loaded / evt.total) * 100));
    };
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        const etag = xhr.getResponseHeader("ETag");
        resolve({ etag });
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`));
      }
    };
    xhr.onerror = () => reject(new Error("Network error"));
    xhr.send(file);
  });
}

Важный момент: если фиксируете Content-Type в pre‑signed URL, на клиенте передавайте тот же тип. Несоответствие вызовет ошибку подписи.

Фронтенд: загрузка через POST (form)

Браузер отправляет multipart/form-data на адрес бакета. Поля формы возвращает бэкенд вместе с policy.

// serverFields: { url, fields: { key, policy, x-amz-..., ... } }
async function uploadWithPost(file, serverFields) {
  const form = new FormData();
  Object.entries(serverFields.fields).forEach(([k, v]) => form.append(k, v));
  form.append("file", file);
  const res = await fetch(serverFields.url, { method: "POST", body: form });
  if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
  // Успешный ответ может быть пустым 204 или XML; ETag читайте из заголовка/тела, если доступен
  return true;
}

Сильная сторона POST — точные ограничения по размеру и префиксам прямо в policy. Это снижает риск злоупотреблений со стороны клиента.

Виртуальный хостинг FastFox
Виртуальный хостинг для сайтов
Универсальное решение для создания и размещения сайтов любой сложности в Интернете от 95₽ / мес

Большие файлы: multipart upload с pre‑signed URL на части

Для файлов размером в сотни мегабайт и гигабайты используйте multipart upload. Общий сценарий:

  1. Бэкенд инициирует multipart (создаёт uploadId) и возвращает клиенту uploadId, размер части, а также набор pre‑signed URL на конкретные части (или эндпоинт, чтобы получать их по мере надобности).
  2. Браузер режет файл на части, параллельно отправляет PUT ?partNumber=N&uploadId=... на соответствующие URL и копит ETag каждой части.
  3. По завершении клиент отправляет список частей и их ETag — либо в бэкенд (который вызывает CompleteMultipartUpload), либо делает завершающий запрос по pre‑signed URL на complete.
  4. В случае ошибки клиент вызывает AbortMultipartUpload (обычно через бэкенд).

Схема multipart‑загрузки: части, ETag и завершение сессии

Обязательно откройте в CORS PUT/POST/HEAD, так как при multipart часто используется HEAD для проверки наличия и чтения ETag.

Эскиз фронтенд‑логики multipart

async function uploadMultipart(file, control) {
  // control: { partSize, uploadId, getPartUrl(partNumber), complete(parts) }
  const parts = [];
  let partNumber = 1;
  for (let offset = 0; offset < file.size; offset += control.partSize) {
    const blob = file.slice(offset, offset + control.partSize);
    const url = await control.getPartUrl(partNumber);
    const res = await fetch(url, { method: "PUT", body: blob });
    if (!res.ok) throw new Error(`Part ${partNumber} failed`);
    const etag = res.headers.get("ETag");
    parts.push({ PartNumber: partNumber, ETag: etag });
    partNumber += 1;
  }
  await control.complete(parts);
}

Не забывайте ограничивать максимальный размер файла и число параллельных частей на стороне клиента, а на бэкенде — общее число активных multipart на пользователя и время их жизни. Регулярно абортируйте «забытые» загрузки планировщиком.

Безопасность: минимизация поверхности атаки

  • Короткое TTL. Делайте pre‑signed URL максимально короткими (60–180 секунд). Не генерируйте их «про запас» пачками.
  • Жёсткая привязка ключа. Генерируйте URL только к префиксу пользователя. Никогда не принимайте key «как есть» из браузера; строите его на сервере.
  • Контроль типа и размера. На бэкенде валидируйте предполагаемый Content-Type и размер. Для POST добавьте content-length-range в policy.
  • Приватность по умолчанию. Используйте приватный ACL. Публичный доступ отдавайте через CDN/прокси и отдельные политики, а не во время аплоада.
  • Ограничение заголовков. В CORS и в pre‑signed подписи разрешайте только необходимые заголовки. Не давайте клиенту управлять чувствительными полями, вроде нестандартного шифрования, если это не нужно.
  • Контроль частоты. Рейт‑лимит на эндпоинт выдачи pre‑signed URL, привязка к сессии/пользователю, защита от массовых запросов.
  • Проверка содержимого. После загрузки переиндексируйте файл: проверьте MIME по сигнатуре, при необходимости запускайте антивирусную проверку и перекодирование.
  • Журналы и события. Включите логи доступа к бакету и события загрузок, чтобы отслеживать аномалии и проводить расследования.
  • Часы. Поддерживайте синхронизацию времени на серверах. Сдвиг часов ломает подписи и приводит к 403.

Типичные ошибки и их диагностика

  • CORS preflight 403/405. В бакете не разрешён метод OPTIONS или отсутствуют нужные AllowedHeader. Сравните фактические заголовки запроса с тем, что перечислено в CORS.
  • SignatureDoesNotMatch. На стороне клиента меняется Content-Type или добавляется «лишний» заголовок по сравнению с тем, что было подписано. Фиксируйте тип и передавайте ровно его.
  • AccessDenied. Ключ выходит за разрешённый префикс, policy не покрывает поля формы, либо URL/политика истекли.
  • ETag и multipart. Для multipart итоговый ETag не равен MD5 файла; не используйте его для проверки целостности всего файла. Сверяйте части или храните хеш отдельно.
  • Непредвиденные редиректы. Некоторые хранилища перенаправляют на другой endpoint. Браузеру это обычно прозрачно, но CORS должен совпадать для конечной точки.

Особенности разных S3‑совместимых провайдеров

Поведение CORS, подстановки региона и строгие требования к заголовкам могут отличаться. Минимизация расхождений — следовать базовым правилам:

  • Подписывайте ровно те заголовки, которые отправляет браузер.
  • Делайте CORS максимально конкретным: точные источники, только нужные методы и заголовки.
  • Тестируйте PUT и POST на небольших файлах, затем переходите к multipart.

Контрольная карта внедрения

  1. Определите формат ключей: пользовательские префиксы, датированные папки, схема именования, недопущение коллизий.
  2. Реализуйте эндпоинт выдачи pre‑signed URL: проверка прав и лимитов, TTL, тип контента, size cap.
  3. Пропишите CORS: источники, методы, заголовки, ExposeHeaders. Проверьте preflight.
  4. Соберите фронтенд: PUT и/или POST, обработка ошибок, прогресс, повторная выдача URL при истечении TTL.
  5. Добавьте multipart для больших файлов: оркестрация частей, параллелизм, abort забытых сессий.
  6. Пост‑обработку: валидация MIME, сканирование, генерация превью, перемещение в «готовую» локацию.
  7. Наблюдаемость: логи, метрики длительности, процент ошибок по кодам, алерты по росту отказов и времени загрузки.

Короткие рецепты

Если нужно быстро стартовать:

  • Для простых аватарок и документов: pre‑signed PUT с фиксированным Content-Type и приватным ACL; CORS с PUT, GET, HEAD, OPTIONS.
  • Для более точного контроля и жёстких лимитов: pre‑signed POST с policy и content-length-range.
  • Для больших файлов: multipart с выдачей pre‑signed URL на части, лимит параллелизма 3–6, контроль тайм‑аутов и повторные попытки по частям.

Итоги

Прямые загрузки в S3/object storage через pre‑signed URL и корректно настроенный CORS позволяют разгрузить бэкенд, ускорить UX и точнее контролировать безопасность. Выбирайте PUT, когда нужен минимализм, POST — когда важны policy‑ограничения, и подключайте multipart для больших объёмов. Уделите внимание TTL, префиксам, типам контента, лимитам размера и сдержанной CORS‑конфигурации — и система будет и быстрой, и безопасной.

Поделиться статьей

Вам будет интересно

Debian/Ubuntu: mount: wrong fs type, bad option, bad superblock — как быстро найти и исправить причину OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: mount: wrong fs type, bad option, bad superblock — как быстро найти и исправить причину

Ошибка mount: wrong fs type, bad option, bad superblock в Debian/Ubuntu может означать и простую опечатку в имени раздела, и пробл ...
Debian/Ubuntu: XFS metadata corruption и emergency read-only — пошаговое восстановление OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: XFS metadata corruption и emergency read-only — пошаговое восстановление

Если XFS-раздел внезапно стал доступен только для чтения, а сервер ушёл в emergency mode, главное — не спешить. Разберём безопасны ...
Debian/Ubuntu: как исправить Failed to fetch при apt update OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: как исправить Failed to fetch при apt update

Ошибка Failed to fetch при apt update в Debian и Ubuntu обычно связана не с самим APT, а с DNS, сетью, зеркалом, прокси, временем ...