RE:NODE

Эксплуатация11 мин чтения

Presigned URL в S3: загрузки из браузера, скачивание и срок действия

Как работают presigned URL для GET и PUT в S3, сколько они живут, как дать браузеру загружать файлы прямо в хранилище и какие детали CORS и безопасности всё ломают.

0 прочтений

Presigned URL - это обычный запрос к S3, у которого подпись перенесена в строку запроса, поэтому любой, у кого есть этот URL, может выполнить ровно этот запрос - скачать этот объект или загрузить файл по этому ключу - до истечения срока действия URL, ни разу не увидев ваших ключей. Ваш сервер генерирует его за несколько микросекунд, вообще не обращаясь к хранилищу, отдаёт браузеру или скрипту и больше не участвует. Это правильная схема для пользовательских загрузок: файл идёт из браузера прямо в хранилище, а не через ваше приложение, так что видео на 2 GB не занимает worker и не забивает диск сервера приложения. Ломается всегда одно и то же, четыре вещи: имя хоста, для которого подписан URL, заголовки, которые браузер отправляет без подписи, CORS и срок действия. В этой статье разобрано всё это, с рабочим кодом на Python и Node.

Как работает presigned URL#

Обычный запрос к S3 несёт подпись в заголовке Authorization. Presigned-запрос несёт ту же информацию в параметрах запроса:

code
https://s3.example.com/media/uploads/3f9c2a.jpg  ?X-Amz-Algorithm=AWS4-HMAC-SHA256  &X-Amz-Credential=AKIA...%2F20261008%2Fus-east-1%2Fs3%2Faws4_request  &X-Amz-Date=20261008T101500Z  &X-Amz-Expires=900  &X-Amz-SignedHeaders=host  &X-Amz-Signature=5c1e...

Подпись - это HMAC от метода, хоста, пути, подписанных заголовков и параметров запроса, вычисленный с вашим секретным ключом. Сервер хранилища пересчитывает её, когда приходит запрос. Всё, что меняет хотя бы один из этих входов - другой метод, другое имя хоста, лишний подписанный заголовок с другим значением, - даёт другую подпись и ответ 403 SignatureDoesNotMatch.

Из такого устройства прямо следуют три вещи:

  • URL генерируется локально. SDK выполняет вычисления с вашим секретом и не спрашивает сервер. URL для объекта, которого не существует, спокойно сгенерируется и сломается только при использовании.
  • URL - это токен на предъявителя. Любой, у кого он есть, может пользоваться им сколько угодно раз до истечения срока. Относитесь к presigned URL для PUT как к короткоживущему паролю.
  • ID ключа доступа виден в X-Amz-Credential. Секрет не виден, и вывести его из URL невозможно. Раскрытие ID ключа само по себе нормально и безвредно.

Срок действия: сколько и что это значит#

X-Amz-Expires - это время жизни в секундах, отсчитываемое от X-Amz-Date. В Signature Version 4 максимум - 604 800 секунд, то есть семь дней. Значения по умолчанию у разных инструментов разные:

ИнструментСрок по умолчаниюКак задать
boto3 generate_presigned_url3600 sExpiresIn=
JS SDK v3 getSignedUrl900 s{ expiresIn: }
AWS CLI aws s3 presign3600 s--expires-in

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

Срок проверяется в момент начала запроса, а не его окончания. В AWS загрузка, начатая за секунду до истечения срока, может завершиться, даже если идёт час; S3-совместимые серверы обычно ведут себя так же. Проверка метки времени означает ещё и то, что важны часы: URL, сгенерированный на машине, чьи часы спешат на десять минут, десять минут будет «ещё не действительным». Серверы у хостинга время держат; ноутбуки и контейнеры без NTP - не всегда.

Генерация URL в Python и Node#

Клиент нужно настроить на ваш endpoint ровно так же, как для обычных запросов: endpoint, регион, path-style. Полная настройка клиента - в статье S3 из Node.js и Python; вызовы для подписи - несколько строк поверх неё.

Python (boto3)
# Download link valid for 10 minutesurl = s3.generate_presigned_url(    "get_object",    Params={"Bucket": "media", "Key": "reports/2026/q3.pdf",            "ResponseContentDisposition": 'attachment; filename="q3.pdf"'},    ExpiresIn=600,)# Upload URL for one key, valid for 5 minutesput_url = s3.generate_presigned_url(    "put_object",    Params={"Bucket": "media", "Key": "uploads/3f9c2a.jpg", "ContentType": "image/jpeg"},    ExpiresIn=300,)
Node (SDK v3)
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";import { getSignedUrl } from "@aws-sdk/s3-request-presigner";const getUrl = await getSignedUrl(s3,  new GetObjectCommand({ Bucket: "media", Key: "reports/2026/q3.pdf" }),  { expiresIn: 600 });const putUrl = await getSignedUrl(s3,  new PutObjectCommand({ Bucket: "media", Key: "uploads/3f9c2a.jpg", ContentType: "image/jpeg" }),  { expiresIn: 300 });

Из shell команда aws s3 presign s3://media/reports/2026/q3.pdf --expires-in 600 выдаёт URL для GET с теми же настройками профиля, что и у любой другой команды CLI. CLI подписывает только скачивания; для URL загрузки нужен SDK.

Полезно знать про ResponseContentDisposition и родственные параметры (ResponseContentType, ResponseCacheControl). Они подписываются в URL для GET и велят серверу подменить соответствующий заголовок в ответе - так один и тот же сохранённый объект можно отдавать как встроенную картинку на одной странице и как скачивание с понятным именем файла на другой.

Имя хоста должно быть тем, которое использует браузер#

Поскольку хост входит в подпись, генерируйте URL клиентом, у которого endpoint - это публичный адрес, по которому будет обращаться браузер. На этом попадаются те, у кого приложение и хранилище работают в одной сети:

  • Приложение обращается к хранилищу по внутреннему адресу или по голому порту с обычным HTTP и генерирует URL этим же клиентом.
  • Браузер получает http://203.0.113.10:PORT/..., который либо недоступен, либо блокируется как смешанный контент на HTTPS-странице, либо и то и другое.
  • Если вручную переписать хост в URL, подпись станет недействительной.

Если приложению действительно нужен другой адрес для собственного трафика, создайте два клиента: один для операций приложения, другой - с публичным HTTPS endpoint, только для подписи. Поскольку генерация URL никогда не обращается к серверу, второму клиенту даже не нужно уметь достучаться до хранилища.

В хранилище S3 у RE:NODE публичный адрес - это имя хоста, которое вы направили на proxy-слот тарифа; он отдаёт HTTPS с сертификатом, который выпускается и продлевается за вас. Подписывайте URL для https://s3.example.com - и браузер, сертификат и подпись будут согласованы.

Загрузки из браузера: весь процесс#

Схема состоит из четырёх шагов, и именно третий держит ваш бакет в чистоте.

1. запрос URL загрузки2. ключ + presigned PUT3. PUT файла напрямую4. подтверждение ключаHEAD для проверкизапись о файлеБаза данныхзаписи о загрузкахБраузервыбор файлаХранилище S3бакет mediaВаше приложениевыдаёт URL
Прямая загрузка из браузера через presigned PUT
  1. Браузер запрашивает у приложения URL для загрузки, передавая имя, размер и тип файла. Приложение проверяет, что пользователю разрешено загружать, сверяет заявленные размер и тип со своими ограничениями и само генерирует ключ - случайный ID с расширением, никогда не имя файла от пользователя.
  2. Приложение возвращает ключ и presigned URL для PUT по этому ключу, подписанный с заявленным ContentType.
  3. Браузер отправляет файл методом PUT на этот URL ровно с этим заголовком Content-Type.
  4. Браузер сообщает приложению, что загрузка завершена. Приложение отправляет запрос HEAD по этому ключу, чтобы убедиться, что объект существует, а его размер и тип совпадают с заявленными, и записывает его в базу данных. Всё, что так и не было подтверждено, можно потом вычистить: просмотреть префикс загрузок и удалить ключи старше суток, для которых нет строки в базе.

Сторона браузера - обычный fetch:

javascript
const { key, url } = await fetch("/api/uploads", {  method: "POST",  headers: { "Content-Type": "application/json" },  body: JSON.stringify({ name: file.name, size: file.size, type: file.type }),}).then((r) => r.json());const put = await fetch(url, { method: "PUT", headers: { "Content-Type": file.type }, body: file });if (!put.ok) throw new Error(`upload failed: ${put.status}`);await fetch("/api/uploads/confirm", {  method: "POST", headers: { "Content-Type": "application/json" },  body: JSON.stringify({ key }),});

Если приложение подписало ContentType: "image/jpeg", а браузер отправляет image/png или не отправляет ничего, подпись не сойдётся. Подписывайте ровно тот тип, который отправите, и отправляйте его.

fetch не сообщает о прогрессе загрузки. Для индикатора прогресса используйте XMLHttpRequest и его событие upload.onprogress; в остальном запрос такой же.

CORS: почему загрузка работает в curl, но не в браузере#

Страница на https://app.example.com, отправляющая PUT на https://s3.example.com, делает кросс-доменный запрос. Перед отправкой браузер выполняет предварительный запрос OPTIONS, и сервер хранилища должен ответить заголовками, разрешающими этот origin, этот метод и заголовок Content-Type. Если он этого не делает, браузер блокирует загрузку, а в консоли появляется ошибка CORS - при том что тот же URL прекрасно работает из curl, потому что curl CORS не проверяет.

В S3 ответ на это - конфигурация CORS на бакете:

cors.json
{  "CORSRules": [    {      "AllowedOrigins": ["https://app.example.com"],      "AllowedMethods": ["GET", "PUT", "HEAD"],      "AllowedHeaders": ["Content-Type"],      "ExposeHeaders": ["ETag"],      "MaxAgeSeconds": 3600    }  ]}
bash
$ aws s3api put-bucket-cors --bucket media --cors-configuration file://cors.json --profile store$ aws s3api get-bucket-cors --bucket media --profile store$ curl -i -X OPTIONS https://s3.example.com/media/test \    -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT"

S3-совместимые серверы по-разному реализуют API CORS для бакетов, поэтому проверьте, прежде чем строить на этом: примените правило, затем отправьте предварительный запрос через curl и поищите в ответе Access-Control-Allow-Origin. Если бакету нельзя задать правило CORS, запасной вариант - загрузка через ваше собственное приложение (приложение принимает файл и записывает его в хранилище через SDK): это стоит трафика и памяти приложения, но CORS не требует вовсе. Не хватайтесь за AllowedOrigins: ["*"] на бакете, в который идут загрузки; перечислите те origin, которые вы действительно обслуживаете.

Ограничения размера и POST policy#

Presigned PUT сам по себе не может ограничить максимальный размер. Приложение может отказаться выдавать URL для заявленного файла на 5 GB, но ничто не мешает браузеру заявить 5 MB и отправить 5 GB. Именно поэтому на шаге 4 реальный размер проверяется через HEAD, а всё, что превышает лимит, удаляется.

В API S3 есть второй механизм, созданный для браузерных форм, - presigned POST. Вместо URL сервер выдаёт документ policy со списком условий - ключ или префикс ключа, тип содержимого и content-length-range, - и браузер отправляет multipart-форму на URL бакета, передавая policy и её подпись как поля. Сервер отклоняет загрузки, нарушающие любое из условий.

python
post = s3.generate_presigned_post(    Bucket="media", Key="uploads/3f9c2a.jpg",    Fields={"Content-Type": "image/jpeg"},    Conditions=[{"Content-Type": "image/jpeg"},                ["content-length-range", 1, 10 * 1024 * 1024]],    ExpiresIn=300,)# post["url"] and post["fields"] go to the browser as a form

В Node аналог - createPresignedPost из @aws-sdk/s3-presigned-post. Поддержка POST policy у S3-совместимых серверов различается сильнее, чем поддержка PUT, так что правило то же: проверьте лимит, намеренно загрузив слишком большой файл и убедившись, что он отклонён. Если лимит не соблюдается, оставьте проверку через HEAD.

Для файлов больше нескольких сотен мегабайт одиночный PUT ненадёжен - один обрыв соединения начинает всё заново, а один PUT в любом случае ограничен 5 GB. Ответ S3 на это - presigned multipart upload: приложение вызывает CreateMultipartUpload, подписывает по одному URL UploadPart на каждый фрагмент, браузер загружает фрагменты (повторяя неудавшиеся), а приложение вызывает CompleteMultipartUpload с ETag частей. Браузерную половину реализуют библиотеки вроде S3-плагина Uppy. Движущихся частей тут больше, поэтому используйте это, только когда файлы действительно большие.

Чеклист безопасности#

  • Генерируйте ключи на сервере. Никогда не позволяйте браузеру выбирать ключ: он может перезаписать файл другого пользователя или записать что-то за пределами своего префикса.
  • Проверяйте права до подписи. Endpoint, который выдаёт URL для загрузки, - это и есть входная дверь. Ограничьте частоту запросов к нему для каждого пользователя, чтобы через него нельзя было забить ваше хранилище.
  • Короткие сроки для PUT. От пяти до пятнадцати минут.
  • Проверяйте после загрузки. Размер, тип и - для изображений - что файл действительно декодируется как изображение, прежде чем показывать его кому-то ещё.
  • Аккуратно отдавайте пользовательские файлы. Загруженный пользователем HTML- или SVG-файл, отданный inline с домена, который вы контролируете, может выполнить скрипт в браузерах посетителей. Отдавайте такие файлы с Content-Disposition: attachment или с отдельного от приложения имени хоста.
  • Держите секрет подальше от клиента. Браузер получает URL, но никогда не ключи. Секреты живут в окружении приложения - см. статью переменные окружения и секреты.

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

Flask: a private download behind a permission check
@app.get("/files/<int:file_id>")def download(file_id):    f = File.query.get_or_404(file_id)    if f.owner_id != current_user.id:        abort(403)    url = s3.generate_presigned_url(        "get_object",        Params={"Bucket": BUCKET, "Key": f.key,                "ResponseContentDisposition": f'attachment; filename="{f.safe_name}"'},        ExpiresIn=120,    )    return redirect(url, code=302)

Ссылка на вашей странице указывает на ваш собственный маршрут, который никогда не истекает и защищён вашим логином; presigned URL за редиректом живёт две минуты и никому не показывается. Браузеры проходят по редиректу незаметно для пользователя, а ссылка, скопированная из адресной строки, почти сразу перестаёт работать. Не кэшируйте ответ с этим редиректом, иначе CDN или proxy может отдать URL одного пользователя другому - отправляйте вместе с ним Cache-Control: no-store.

FAQ#

Можно ли использовать presigned URL больше одного раза?

Да. Он действителен для любого числа запросов до истечения срока. URL для PUT, использованный дважды, просто перезапишет объект второй загрузкой. Если нужна одноразовость, отслеживайте выданные ключи в базе данных и отказывайтесь записывать один и тот же ключ дважды.

Почему мой presigned URL возвращает SignatureDoesNotMatch?

Запрос отличается от того, что было подписано. Обычные причины - имя хоста отличается от того, на которое настроен клиент, заголовок Content-Type не совпадает с подписанным, или proxy переписывает заголовок Host. Генерируйте URL с публичным endpoint и отправляйте ровно подписанные заголовки.

Какой максимальный срок действия у presigned URL?

Семь дней (604 800 секунд) в Signature Version 4. Если нужно дольше, генерируйте свежий URL, когда пользователь запрашивает файл, вместо того чтобы хранить URL.

Работают ли presigned URL с path-style endpoint?

Да. В таком URL бакет просто стоит в пути - https://s3.example.com/bucket/key?.... При подписи клиент должен быть настроен на path-style, как объясняется в статье path-style и virtual-hosted URL.

Может, лучше сделать бакет публичным?

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


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000