golang

Безопасный file upload в Go: 7 атак на загрузку файлов и как мы их закрывали

  • пятница, 27 марта 2026 г. в 00:00:09
https://habr.com/ru/articles/1015082/

«Сделай форму загрузки PDF» – звучит как задача на полчаса. Claude/GPT напишет handler, мы добавим accept=".pdf" на фронте, multer на бэке – и вот у нас работающий upload. Можно деплоить.

Проблема в том, что работающий upload и безопасный upload – это разные вещи. Разница между ними – несколько уязвимостей, каждая из которых может превратить ваш сервер в точку входа для атакующего.

С распространением LLM-инструментов порог входа в разработку снизился радикально. Это прекрасно – больше людей могут создавать продукты. Но вместе с порогом входа снизился и порог входа для уязвимостей. Когда LLM генерирует код загрузки файла, она решает функциональную задачу: принять файл, сохранить, обработать. Безопасность? «Добавлю потом». А «потом» обычно наступает после инцидента.

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

Дисклеймер. Это не универсальный гайд по безопасности и не претензия на тему «я знаю, как надо». Это инженерный кейс: как я проектировал защиту загрузки файлов в конкретном сервисе и какие компромиссы принимал. В качестве примера – браузерное расширение для конвертации PDF в Markdown. Сам по себе PDF-конвертер, возможно, и не требует такого уровня защиты. Но подходы универсальны и применимы к системам, где ставки выше: медицинские документы, финансовые отчёты, юридические сканы, UGC-платформы. Показываю принципы на реальном коде – а вы решаете, какие из них нужны в вашем случае.

TL;DR – в статье разбираю 7 атак на file upload и как я их закрывал в Go-бэкенде

  • Подмена типа файла → magic bytes %PDF, не доверяем расширению

  • Переполнение дискаMaxBytesReader + лимит слотов per-device

  • Path Traversal → фиксированное имя {UUID}/input.pdf, без пользовательского ввода в путях

  • SSRF → DNS-резолв до запроса, блокировка редиректов, denylist приватных IP

  • Replay-атака → nonce + timestamp + ECDSA-подпись каждого запроса

  • Подмена устройства → криптографическая идентификация через WebCrypto (ECDSA P-256)

  • Application-level abuse → rate limit + подпись + слоты

Сводная таблица со статусами – в конце статьи.

Архитектура загрузки: что происходит, когда вы отправляете файл

Прежде чем говорить об атаках, покажем путь файла через систему:

Два канала загрузки:

  • Прямая загрузка – пользователь выбирает файл или перетаскивает его (drag & drop)

  • Загрузка по URL – пользователь вводит ссылку на PDF

Каждый канал несёт свой набор угроз, и для каждого нужна своя защита.

Но прежде чем разбирать конкретные атаки, нужно понять фундамент, на котором стоит вся наша модель безопасности.

Все фрагменты кода ниже – упрощённые excerpts из реального проекта, отражающие ключевые проверки. Полный код может отличаться обработкой ошибок и дополнительными edge cases.

Фундамент: анонимная идентификация устройства

Зачем так сложно? Если у вас бэкенд с обычной авторизацией (JWT, сессии, OAuth) – этот раздел можно пропустить, ваш auth-слой уже решает задачу идентификации. Мы описываем этот подход для специфического случая: продукт без логинов и аккаунтов, где тем не менее нужно контролировать нагрузку per-device. Если ваш проект предполагает аутентификацию – используйте её, это проще и надёжнее.

В большинстве приложений загрузка файлов защищена логином – у вас есть аккаунт, сервер знает, кто вы. У нас браузерное расширение без регистрации и аккаунтов. Как тогда отличить легитимного пользователя от атакующего? Как ограничить количество запросов, если нет логина?

Мы решили эту задачу через криптографическую идентификацию устройства – по сути, каждый профиль браузера становится анонимным, но верифицируемым «аккаунтом».

Важно: это не трекинг пользователей и не скрытая идентификация личности. device_id привязан к ключевой паре в IndexedDB конкретного профиля браузера. Мы не знаем, кто этот человек – мы знаем только, что этот же профиль браузера уже делал запросы ранее. Инкогнито-окно = новое устройство. Удалил расширение = ключи потеряны навсегда. На уровне auth-модели device_id не даёт криптографической склейки между устройствами. При этом у оператора сервиса остаются косвенные сигналы (IP, ASN), которые теоретически позволяют строить гипотезы о связи устройств – но это не часть auth-протокола и не используется для идентификации.

Как это работает

При первом запуске расширение генерирует ключевую пару ECDSA P-256 через WebCrypto API:

const keyPair = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  false,  // extractable = false – ключ нельзя экспортировать!
  ['sign', 'verify']
);

Критически важный момент: extractable: false. Приватный ключ хранится в IndexedDB браузера, но его невозможно извлечь – ни через JavaScript, ни через DevTools, ни через расширения. Можно только попросить браузер подписать данные этим ключом.

Затем расширение отправляет публичный ключ на сервер (POST /register) и получает в ответ device_id и device_token. С этого момента каждый запрос к API подписывается приватным ключом:

Заголовок

Назначение

Authorization: Bearer <token>

Идентификация устройства

X-Timestamp

Время отправки (окно ±5 мин)

X-Nonce

Уникальный ID запроса (anti-replay)

X-Body-SHA256

Хеш тела – целостность данных

X-Signature

ECDSA-подпись всего вышеперечисленного

Подпись покрывает: METHOD + PATH + TIMESTAMP + NONCE + BODY_HASH. Подделать невозможно без приватного ключа.

Подпись ≠ шифрование. ECDSA-подпись обеспечивает целостность и аутентичность запроса, но не конфиденциальность. Содержимое файла, токен и метаданные передаются открытым текстом, если нет TLS. Подпись запросов – дополнение к HTTPS, а не замена.

Сервер проверяет подпись, используя публичный ключ, привязанный к device_id. Если подпись не сходится – запрос отклоняется, неважно, валидный ли токен.

Почему это важно для загрузки файлов

Эта модель даёт нам то, чего обычно нет без аккаунтов:

  • Контроль per-device – мы можем ограничить количество одновременных задач, файлов в день, запросов в минуту для каждого устройства (читай – профиля браузера)

  • Стоимость создания «нового аккаунта» – злоумышленник не может просто менять cookie или токен. Нужно генерировать новую ключевую пару и проходить регистрацию, которая ограничена 5 попытками на IP в час

  • Защита от кражи токена – даже если device_token утечёт, без приватного ключа (который нельзя экспортировать) он бесполезен

  • Целостность запроса – тело запроса (включая файл) покрыто подписью через SHA256-хеш. Подменить файл в transit невозможно

По сути, каждый профиль браузера получает свой неподделываемый «паспорт». И все лимиты, о которых мы будем говорить дальше – слоты, rate limits, ограничения размера – работают именно потому, что мы можем надёжно идентифицировать устройство.

Является ли эта защита абсолютной? Нет. Технически мотивированный злоумышленник может написать эмулятор, который воспроизведёт всю цепочку: генерацию ключей, регистрацию, подпись запросов – без браузера вовсе. Но в этом и суть подхода: мы значительно поднимаем порог входа. Без криптографической идентификации атаковать API можно одной строкой в curl или парой кликов в Postman – отправляй запрос за запросом, меняя заголовки. С нашей моделью атакующему нужно реализовать ECDSA P-256, корректно формировать canonical string, подписывать каждый запрос, управлять nonce и timestamp – и всё это ради лимита в 3 активных слота и 5 регистраций в час на IP. Стоимость атаки растёт на порядки, а выгода остаётся той же.

Атака 1: Подмена типа файла (Malicious File Upload)

Суть атаки

Злоумышленник переименовывает вредоносный файл (скрипт, исполняемый файл, HTML с XSS) в report.pdf и загружает его. Если сервер доверяет расширению – он сохранит файл, а при определённых условиях может его выполнить или отдать другим пользователям.

Как мы защитились

Три уровня проверки типа файла:

Уровень 1 – Фронтенд (расширение):

const ALLOWED_TYPES = ['application/pdf'];
const ALLOWED_EXTENSIONS = ['.pdf'];

function validateFile(file) {
  const isPdf = ALLOWED_TYPES.includes(file.type) ||
                ALLOWED_EXTENSIONS.some(ext =>
                  file.name.toLowerCase().endsWith(ext));

  if (!isPdf) return { valid: false, error: 'PDF only' };
  // ...
}

Проверяем и MIME-тип, и расширение. Но фронтенд-валидация – это лишь UX-фильтр, а не защита. Любой может отправить запрос напрямую, минуя расширение.

Уровень 2 – Бэкенд, Content-Type запроса:

Сервер маршрутизирует запрос по Content-Type: multipart/form-data для файлов, application/json для URL. Это не валидация содержимого, но первый серверный барьер.

Уровень 3 – Бэкенд, magic bytes (сигнатура файла):

pdfMagicBytes = "%PDF"

header4 := make([]byte, 4)
_, err = file.Read(header4)
if string(header4) != pdfMagicBytes {
    respondError(w, http.StatusBadRequest,
        models.ErrCodeValidationError,
        "File is not a valid PDF")
}
file.Seek(0, 0) // Сбрасываем позицию для дальнейшей обработки

Это ключевая проверка. Каждый формат файла начинается с определённой последовательности байтов – «магических байтов». PDF всегда начинается с %PDF. Даже если злоумышленник переименует .exe в .pdf, первые байты его выдадут.

Важно: мы проверяем именно содержимое файла, а не то, что браузер написал в заголовке. Заголовки легко подделать, байты – нет (если, конечно, злоумышленник не создал файл, который одновременно является и валидным PDF, и чем-то вредоносным – такие полиглоты существуют, об этом ниже).

Атака 2: Переполнение диска (File Size DoS)

Суть атаки

Злоумышленник загружает гигабайтный файл (или тысячи файлов подряд), чтобы исчерпать дисковое пространство или память сервера.

Как мы защитились

Три уровня ограничения размера:

Ключевой элемент – http.MaxBytesReader на уровне middleware:

r.Body = http.MaxBytesReader(w, r.Body, h.cfg.Storage.MaxFileSize + 1024*1024)

Эта функция оборачивает r.Body и прерывает чтение, как только превышен лимит. Сервер не будет читать 10 ГБ, чтобы потом сказать «файл слишком большой» – он остановится на 11-м мегабайте.

И здесь в полную силу работает наша модель идентификации устройства. Поскольку каждый профиль браузера криптографически привязан к device_id, мы можем ограничить нагрузку per-device:

const maxJobsPerDevice = 3

Три активных слота на устройство (в слот входят задачи в статусах queued, processing, ready и error – до автоочистки или ручного удаления). Обойти это сменой cookie или токена невозможно. Чтобы получить новые слоты, злоумышленнику нужно создать новый профиль браузера, сгенерировать ключи и пройти регистрацию (которая ограничена 5 попытками на IP в час).

Атака 3: Path Traversal (обход директорий)

Суть атаки

Злоумышленник отправляет файл с именем ../../../etc/passwd или ..\..\windows\system32\config.txt. Если сервер использует имя файла при сохранении без санитизации, файл окажется не в папке загрузок, а в произвольном месте файловой системы.

Как мы защитились

Фронтенд – санитизация имени файла:

function sanitizeFileName(name) {
  return name
    .replace(/[/\\?%*:|"<>]/g, '-')  // Удаляем опасные символы
    .replace(/^\.+/, '')              // Удаляем точки в начале
    .replace(/\.+$/, '')              // Удаляем точки в конце
    .substring(0, 255)                // Ограничиваем длину
    .trim();
}

Но главная защита – на бэкенде, где имя файла полностью игнорируется:

func (h *JobsHandler) saveFile(jobID uuid.UUID, file io.Reader) error {
    jobDir := filepath.Join(h.cfg.Storage.SharedDataPath, jobID.String())
    os.MkdirAll(jobDir, 0755)

    filePath := filepath.Join(jobDir, "input.pdf") // Фиксированное имя!
    // ...
}

Файл всегда сохраняется как {UUID}/input.pdf. Никакого пользовательского ввода в пути. UUID генерируется сервером – предсказать или подобрать его невозможно. Это надёжная защита от path traversal на уровне storage path.

Но filename на этом не заканчивается. Оригинальное имя файла продолжает жить как metadata: в БД, в UI, в заголовке Content-Disposition при скачивании результата. На уровне storage path проблема закрыта полностью (пользовательское имя не участвует). Но на уровне вывода – скачивание, отображение в интерфейсе – имя нужно корректно экранировать, чтобы избежать XSS или header injection. В нашем случае санитизация на фронте (удаление спецсимволов, ограничение длины) – первый барьер, а основная ответственность лежит на корректном экранировании при выдаче результата.

Атака 4: SSRF (Server-Side Request Forgery)

Суть атаки

При загрузке по URL злоумышленник передаёт не ссылку на PDF, а адрес внутреннего сервиса: http://169.254.169.254/latest/meta-data/ (endpoint метаданных – одинаковый у Yandex Cloud, VK Cloud, AWS), http://localhost:6379/ (Redis) или http://192.168.1.1/admin. Сервер, доверяя URL, делает запрос от своего имени – и злоумышленник получает доступ к внутренней инфраструктуре.

SSRF входит в OWASP Top 10 и является одной из самых распространённых уязвимостей в современных приложениях.

Как мы защитились

Шаг 1 – Валидация IP-адреса:

Идея простая: прежде чем делать HTTP-запрос по пользовательскому URL, мы резолвим DNS и проверяем, что все полученные IP – публичные:

func validatePublicURL(rawURL string) error {
    u, err := url.Parse(rawURL)
    // ... валидация схемы и хоста

    ips, err := net.LookupIP(u.Hostname())

    for _, ip := range ips {
        if isPrivateOrReservedIP(ip) {
            return fmt.Errorf("URL resolves to private/reserved IP")
        }
    }
    return nil
}

func isPrivateOrReservedIP(ip net.IP) bool {
    if ip.IsLoopback() { return true }        // 127.0.0.0/8, ::1
    if ip.IsLinkLocalUnicast() { return true } // 169.254.0.0/16 – метаданные облаков
    if ip.IsMulticast() { return true }

    if ip4 := ip.To4(); ip4 != nil {
        // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
        if ip4[0] == 10 { return true }
        if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 { return true }
        if ip4[0] == 192 && ip4[1] == 168 { return true }

        // TODO: 100.64.0.0/10 (Carrier-Grade NAT)
        // TODO: 198.18.0.0/15 (benchmark testing)
        // TODO: 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (documentation)
        // TODO: 198.51.100.0/24, 203.0.113.0/24 (documentation)
    }

    // IPv6 ULA (fc00::/7)
    if ip[0] == 0xfc || ip[0] == 0xfd { return true }
    // TODO: ::ffff:0:0/96 (IPv4-mapped), 2001:db8::/32 (documentation)
    // TODO: dial-time validation для защиты от DNS rebinding

    return false
}

Оговорка: это denylist-подход – мы перечисляем «что блокировать». OWASP рекомендует для SSRF positive allowlist (разрешать только известные безопасные адресаты). Denylist проще в реализации, но его легче обойти: если мы забыли диапазон (например, 100.64.0.0/10 – Carrier-Grade NAT или 198.18.0.0/15 – benchmark), запрос пройдёт. Для нашего кейса denylist покрывает основные риски, но для критичной инфраструктуры стоит переходить на allowlist или dial-time validation.

Шаг 2 – Блокировка редиректов:

httpClient = &http.Client{
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        return http.ErrUseLastResponse // Не следовать за редиректами
    },
}

Классический трюк: http://safe-looking-url.com → 302 → http://169.254.169.254/. Мы не следуем за редиректами вообще. Если URL возвращает 3xx-ответ, запрос отклоняется:

if resp.StatusCode >= 300 && resp.StatusCode < 400 {
    respondError(w, http.StatusBadRequest, ...,
        "URL redirects are not allowed for security reasons")
}

Примечание о DNS Rebinding: существует более изощрённая атака, при которой DNS-имя сначала резолвится в публичный IP (проходит валидацию), а затем – в приватный (при повторном запросе). Полная защита от DNS rebinding требует закрепления (pinning) резолва или использования специализированного HTTP-клиента. В нашем случае этот вектор частично снижается за счёт блокировки редиректов и одноразового запроса.

Атака 5: Replay-атака (повторное использование запроса)

Суть атаки

Злоумышленник перехватывает легитимный запрос на загрузку файла и отправляет его повторно – многократно. Результат: десятки одинаковых задач, исчерпание квот, нагрузка на сервер.

Как мы защитились

Здесь работает та самая система подписей, которую мы описали в разделе «Фундамент». Каждый запрос уже содержит timestamp, nonce и ECDSA-подпись – и именно они делают replay-атаку бессмысленной.

Проверка на сервере:

  1. Временное окно – запрос старше 5 минут отклоняется

  2. Уникальность nonce – каждый nonce сохраняется в Redis с TTL 5 минут. Повторный nonce → 409 Replay Detected

  3. Целостность тела – SHA256-хеш тела сравнивается с заявленным. Подмена тела при сохранении подписи невозможна

  4. Криптографическая подпись – подпись охватывает метод, путь, timestamp, nonce и хеш тела. Без приватного ключа невозможно создать валидную подпись

// Проверка целостности тела
actualHash := sha256.Sum256(bodyBytes)
if !strings.EqualFold(hex.EncodeToString(actualHash[:]), bodySHA256Header) {
    respondError(w, http.StatusUnauthorized, ..., "Body hash mismatch")
}

Это означает: даже если атакующий перехватит запрос, он не сможет его переиграть (nonce уже использован), изменить (подпись не сойдётся) или растянуть во времени (timestamp устарел).

Атака 6: Подмена устройства (Identity Spoofing)

Суть атаки

Злоумышленник пытается представиться другим устройством, чтобы использовать его квоты, получить его результаты или просто обойти rate-limit.

Как мы защитились

Как мы описали в разделе «Фундамент», идентификация устройства основана на асимметричной криптографии, а не на простых токенах. Это делает подмену принципиально невозможной:

  • Приватный ключ нельзя украсть – extractable: false в WebCrypto API означает, что даже вредоносное расширение не может прочитать ключ из IndexedDB. Его можно только использовать для подписи

  • Токен без ключа бесполезен – device_token даёт право отправить запрос, но без ECDSA-подписи приватным ключом сервер его отклонит

  • Создание «нового устройства» дорого – нужен новый профиль браузера + регистрация, ограниченная 5 попытками на IP в час

  • Ключ привязан к профилю браузера – смена вкладки, перезагрузка, обновление расширения – ключ остаётся. Только удаление профиля или расширения приводит к потере ключа

Атака 7: Массовый abuse через загрузку

Суть атаки

Массовая отправка запросов на загрузку с целью перегрузить сервер – забить сеть, диск или CPU (парсинг PDF – ресурсоёмкая операция). Это не network-level DDoS (от него защищают CDN, WAF и инфраструктурные решения), а application-level abuse – злоупотребление бизнес-логикой загрузки.

Как мы снижаем риск

Эшелонированная защита на уровне приложения:

Уровень

Механизм

Лимит

Регистрация

Rate limit на /register

5 в час на IP

Запросы

Подпись каждого запроса

Без ключа – нельзя

Параллельность

Слоты на устройство

3 активных слота

Размер

MaxBytesReader

11 MB на запрос

Тело запроса

Размер multipart-части

10 MB на файл

Чтобы массово abuse'ить загрузку файлов, атакующему нужно:

  1. Создать множество устройств (rate limit на регистрацию)

  2. Для каждого – сгенерировать ключевую пару и подписывать запросы

  3. Каждое устройство ограничено 3 активными слотами и 10 MB на файл

Это повышает стоимость массового злоупотребления на уровне приложения. Но это не замена network-level DDoS mitigation (Qrator, DDoS-Guard, Cloudflare и т.п.) – от SYN-flood или HTTP-flood на уровне инфраструктуры наша application-level логика не защитит.

Про rate limit по IP и CGNAT. Да, мы знаем: за одним IP мобильного оператора могут сидеть тысячи пользователей. Поэтому IP-лимит – это не основной, а вспомогательный сигнал, и только на endpoint регистрации новых устройств. Рабочие запросы лимитируются по device_id, а не по IP. Если с одного мобильного IP приходит нормальный живой трафик – он проходит. Лимит срабатывает только на аномальный burst регистраций, характерный для автоматизированного фарма.

Что ещё бывает: атаки, от которых защита неполная

Честность – лучшая политика. Вот векторы, которые мы осознаём и где наша защита ещё не идеальна:

PDF-бомба (Decompression Bomb)

PDF может содержать сжатые потоки, которые при распаковке разрастаются до гигабайтов. Файл в 5 MB может превратиться в 10 GB при парсинге.

Чем мы уже защищены: Docling работает в отдельном Docker-контейнере. В compose-конфигурации задан лимит памяти и таймаут:

Отдельный docling-service, таймаут 15 минут и memory limit в deploy-конфигурации снижают blast radius: даже если PDF-бомба начнёт разворачиваться, последствия локализованы в рамках одного контейнера и не затрагивают API, базу данных или другие воркеры. Конкретное поведение при OOM зависит от среды запуска (Docker Desktop, Swarm, Kubernetes применяют deploy.resources по-разному), но сам принцип изоляции работает в любом случае.

Таймаут – второй рубеж: даже если память не кончится, но распаковка затянется, воркер принудительно прервёт обработку и отметит задачу как неуспешную.

Что это не покрывает: у нас нет превентивной защиты – мы не анализируем степень сжатия до начала обработки. PDF-бомба всё равно начнёт разворачиваться, но в изолированном окружении. Для полноценной защиты можно добавить эвристику на входе: аномально высокий коэффициент сжатия (размер файла vs. количество потоков/страниц) – повод отклонить файл до обработки.

Вредоносное содержимое PDF

PDF – это полноценный контейнер, который может содержать:

  • Встроенный JavaScript

  • Формы с автоотправкой данных

  • Ссылки на внешние ресурсы

  • Встроенные файлы других форматов

Мы не выполняем и не рендерим PDF – мы только извлекаем текст и структуру, что существенно снижает риск. А контейнерная изоляция Docling означает, что даже если парсер уязвим к специально сформированному PDF, последствия ограничены рамками контейнера.

Что это не покрывает: нет антивирусной проверки (ClamAV) и нет CDR (Content Disarm & Reconstruct – пересборка PDF с удалением потенциально опасных элементов: JavaScript, форм, встроенных файлов). Для сценариев, где результат конвертации отдаётся третьим лицам, стоит проверять и входящий PDF, и выходной Markdown на наличие вредоносных ссылок/скриптов. Отдельный вопрос – своевременное обновление самого парсера (Docling и его зависимостей): PDF-парсер – это отдельная поверхность атаки, и CVE в нём могут появляться регулярно.

Хранение и доступ к загруженным файлам

Отдельно стоит отметить то, что OWASP File Upload Cheat Sheet выделяет как обязательные пункты, и что у нас уже реализовано, но не было проговорено:

  • Файлы хранятся вне webroot – загруженные PDF лежат в shared storage между API и worker, а не в публично доступной директории. Nginx не отдаёт их напрямую.

  • Исходный PDF не экспонируется наружу – загруженный файл не отдаётся ни через Nginx, ни через отдельный endpoint. Через API доступен только результат конвертации (Markdown), а исходник удаляется после обработки.

  • Приложение не исполняет загруженные файлы – даже если каким-то образом загрузить не-PDF, он не будет интерпретирован сервером. Upload storage не используется как публичная директория раздачи и не является точкой исполнения.

Polyglot-файлы

Polyglot – это файл, который одновременно является валидным PDF и, например, валидным ZIP или HTML. Наша проверка magic bytes пройдёт (%PDF в начале), но другое ПО может интерпретировать файл иначе.

Текущий статус: проверяются только первые 4 байта. Более глубокий анализ структуры PDF не проводится.

Решение: валидация структуры PDF (xref-таблица, trailer) или использование специализированных библиотек для глубокой валидации.

DNS Rebinding

Как упоминалось в разделе про SSRF – между проверкой IP и реальным HTTP-запросом есть временной зазор (TOCTOU). Теоретически атакующий может обойти validatePublicURL через управляемый DNS-сервер: при первом резолве вернуть публичный IP (проходит валидацию), а при втором (когда HTTP-клиент устанавливает соединение) – приватный.

Чем мы уже защищены: даже при успешном DNS rebinding атакующий получает только blind SSRF – сервер может «потрогать» внутренний адрес, но ответ ему не вернётся. Причина: ответ от внутреннего сервиса (JSON от metadata API облака, текст от Redis, HTML от админки) не пройдёт проверку magic bytes (%PDF), и запрос будет отклонён с генерическим сообщением "URL does not point to a valid PDF file". Никакие данные из ответа пользователю не возвращаются.

Что это не покрывает: blind SSRF всё ещё позволяет «дотянуться» до внутренних сервисов GET-запросом. Для критичной инфраструктуры это может быть нежелательно – например, metadata API облачных провайдеров (Yandex Cloud, VK Cloud и др.) могут отдавать IAM-токены по GET без авторизации. Полная защита – перенести валидацию IP на уровень TCP-соединения (dial-time validation), чтобы устранить TOCTOU-зазор между DNS-резолвом и подключением.

Сводная таблица: атаки и статус защиты

Атака

Опасность

Статус

Как защищены

Подмена типа файла

Высокая

Защищены

MIME + расширение + magic bytes

Переполнение диска

Высокая

Защищены

MaxBytesReader + лимит multipart + слоты

Path Traversal

Критическая

Защищены

Фиксированное имя input.pdf + UUID-директории

SSRF

Критическая

Снижен риск

Denylist IP + блокировка редиректов (не allowlist, нет dial-time validation)

Replay-атака

Средняя

Защищены

Nonce + timestamp + ECDSA-подпись

Подмена устройства

Высокая

Защищены

Асимметричная криптография (P-256)

Application-level abuse

Высокая

Снижен риск

Rate limit + подпись + слоты (не замена network DDoS mitigation)

PDF-бомба

Средняя

Частично

Контейнерная изоляция + OOM + таймаут 15 мин, нет превентивного анализа

Вредоносный PDF

Средняя

Частично

Не рендерим + контейнерная изоляция, нет антивируса

Polyglot-файлы

Низкая

Частично

Только magic bytes без глубокого анализа

DNS Rebinding

Низкая

Частично

Только blind SSRF – magic bytes блокируют утечку данных, нет dial-time validation

Подход масштабируется

Всё описанное выше я показал на примере конвертера PDF в Markdown, но тот же набор принципов мы применяли и в другом UGC-проекте с загрузкой изображений. Там вместо magic bytes %PDFimage.DecodeConfig() в Go (фактически те же magic bytes через стандартную библиотеку), вместо контейнерной изоляции от PDF-бомб – лимит пикселей на входе (отклоняем до декодирования в память), а вместо фиксированного input.pdf – серверные UUID на каждом уровне пути. Дополнительно работает перекодирование: любое загруженное изображение декодируется в пиксельный буфер и кодируется заново – EXIF, GPS, встроенные скрипты уничтожаются, polyglot-файлы нейтрализуются. Меняется специфика (PDF vs. изображения, расширение vs. веб-приложение), но фундамент один и тот же.

Принципы, которые стоит забрать с собой

  1. Безопасность – не «потом».
    Если вы используете LLM для генерации кода загрузки файлов – проверяйте результат по чеклисту из этой статьи. «Добавлю валидацию позже» – это технический долг, который выстрелит первым.

  2. Никогда не доверяйте фронтенду.
    Любая клиентская валидация – это UX, а не защита. accept=".pdf" на <input> фильтрует случайные ошибки, но не целенаправленные атаки. Вся реальная защита – на сервере.

  3. Проверяйте содержимое, а не метаданные.
    Расширение файла и Content-Type – это «как файл себя называет». Magic bytes – это «чем файл является на самом деле».

  4. Не используйте пользовательские имена файлов в путях хранения.
    UUID + фиксированное имя закрывают storage-path аспект path traversal. Но имя файла как metadata (БД, UI, Content-Disposition) всё равно нужно валидировать и экранировать отдельно.

  5. При загрузке по URL проверяйте IP до запроса.
    SSRF – это атака, которую нельзя отфильтровать после того, как запрос уже ушёл. Резолвите DNS, проверяйте диапазоны, блокируйте редиректы.

  6. Делайте атаку экономически невыгодной.
    Абсолютной защиты не существует – мотивированный атакующий найдёт путь. Но вы можете сделать так, чтобы стоимость атаки многократно превышала потенциальную выгоду.

  7. Будьте честны о пробелах.
    Безопасность – это процесс, а не состояние. Знать свои слабые места и планировать их закрытие – лучше, чем считать себя неуязвимым.