Прямые загрузки в 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 с жёсткими ограничениями: конкретный объект (ключ), метод, заголовки и короткое время жизни. Для браузера это обычный одноразовый «адрес для загрузки», не требующий знания секретов.

Два основных паттерна: 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 — максимально жёсткой.
Фронтенд: загрузка через 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. Это снижает риск злоупотреблений со стороны клиента.
Большие файлы: multipart upload с pre‑signed URL на части
Для файлов размером в сотни мегабайт и гигабайты используйте multipart upload. Общий сценарий:
- Бэкенд инициирует multipart (создаёт
uploadId) и возвращает клиентуuploadId, размер части, а также набор pre‑signed URL на конкретные части (или эндпоинт, чтобы получать их по мере надобности). - Браузер режет файл на части, параллельно отправляет
PUT ?partNumber=N&uploadId=...на соответствующие URL и копитETagкаждой части. - По завершении клиент отправляет список частей и их ETag — либо в бэкенд (который вызывает CompleteMultipartUpload), либо делает завершающий запрос по pre‑signed URL на complete.
- В случае ошибки клиент вызывает AbortMultipartUpload (обычно через бэкенд).

Обязательно откройте в 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.
Контрольная карта внедрения
- Определите формат ключей: пользовательские префиксы, датированные папки, схема именования, недопущение коллизий.
- Реализуйте эндпоинт выдачи pre‑signed URL: проверка прав и лимитов, TTL, тип контента, size cap.
- Пропишите CORS: источники, методы, заголовки, ExposeHeaders. Проверьте preflight.
- Соберите фронтенд: PUT и/или POST, обработка ошибок, прогресс, повторная выдача URL при истечении TTL.
- Добавьте multipart для больших файлов: оркестрация частей, параллелизм, abort забытых сессий.
- Пост‑обработку: валидация MIME, сканирование, генерация превью, перемещение в «готовую» локацию.
- Наблюдаемость: логи, метрики длительности, процент ошибок по кодам, алерты по росту отказов и времени загрузки.
Короткие рецепты
Если нужно быстро стартовать:
- Для простых аватарок и документов: 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‑конфигурации — и система будет и быстрой, и безопасной.


