
«Сделай форму загрузки 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-devicePath 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 подписывается приватным ключом:
Заголовок | Назначение |
|---|---|
| Идентификация устройства |
| Время отправки (окно ±5 мин) |
| Уникальный ID запроса (anti-replay) |
| Хеш тела – целостность данных |
| 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-атаку бессмысленной.
Проверка на сервере:
Временное окно – запрос старше 5 минут отклоняется
Уникальность nonce – каждый nonce сохраняется в Redis с TTL 5 минут. Повторный nonce →
409 Replay DetectedЦелостность тела – SHA256-хеш тела сравнивается с заявленным. Подмена тела при сохранении подписи невозможна
Криптографическая подпись – подпись охватывает метод, путь, 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 на | 5 в час на IP |
Запросы | Подпись каждого запроса | Без ключа – нельзя |
Параллельность | Слоты на устройство | 3 активных слота |
Размер | MaxBytesReader | 11 MB на запрос |
Тело запроса | Размер multipart-части | 10 MB на файл |
Чтобы массово abuse'ить загрузку файлов, атакующему нужно:
Создать множество устройств (rate limit на регистрацию)
Для каждого – сгенерировать ключевую пару и подписывать запросы
Каждое устройство ограничено 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 | Критическая | Защищены | Фиксированное имя |
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 %PDF – image.DecodeConfig() в Go (фактически те же magic bytes через стандартную библиотеку), вместо контейнерной изоляции от PDF-бомб – лимит пикселей на входе (отклоняем до декодирования в память), а вместо фиксированного input.pdf – серверные UUID на каждом уровне пути. Дополнительно работает перекодирование: любое загруженное изображение декодируется в пиксельный буфер и кодируется заново – EXIF, GPS, встроенные скрипты уничтожаются, polyglot-файлы нейтрализуются. Меняется специфика (PDF vs. изображения, расширение vs. веб-приложение), но фундамент один и тот же.
Принципы, которые стоит забрать с собой
Безопасность – не «потом».
Если вы используете LLM для генерации кода загрузки файлов – проверяйте результат по чеклисту из этой статьи. «Добавлю валидацию позже» – это технический долг, который выстрелит первым.Никогда не доверяйте фронтенду.
Любая клиентская валидация – это UX, а не защита.accept=".pdf"на<input>фильтрует случайные ошибки, но не целенаправленные атаки. Вся реальная защита – на сервере.Проверяйте содержимое, а не метаданные.
Расширение файла и Content-Type – это «как файл себя называет». Magic bytes – это «чем файл является на самом деле».Не используйте пользовательские имена файлов в путях хранения.
UUID + фиксированное имя закрывают storage-path аспект path traversal. Но имя файла как metadata (БД, UI,Content-Disposition) всё равно нужно валидировать и экранировать отдельно.При загрузке по URL проверяйте IP до запроса.
SSRF – это атака, которую нельзя отфильтровать после того, как запрос уже ушёл. Резолвите DNS, проверяйте диапазоны, блокируйте редиректы.Делайте атаку экономически невыгодной.
Абсолютной защиты не существует – мотивированный атакующий найдёт путь. Но вы можете сделать так, чтобы стоимость атаки многократно превышала потенциальную выгоду.Будьте честны о пробелах.
Безопасность – это процесс, а не состояние. Знать свои слабые места и планировать их закрытие – лучше, чем считать себя неуязвимым.
