Безопасный file upload в Go: 7 атак на загрузку файлов и как мы их закрывали
- пятница, 27 марта 2026 г. в 00:00:09

«Сделай форму загрузки PDF» – звучит как задача на полчаса. Claude/GPT напишет handler, мы добавим accept=".pdf" на фронте, multer на бэке – и вот у нас работающий upload. Можно деплоить.
Проблема в том, что работающий upload и безопасный upload – это разные вещи. Разница между ними – несколько уязвимостей, каждая из которых может превратить ваш сервер в точку входа для атакующего.
С распространением LLM-инструментов порог входа в разработку снизился радикально. Это прекрасно – больше людей могут создавать продукты. Но вместе с порогом входа снизился и порог входа для уязвимостей. Когда LLM генерирует код загрузки файла, она решает функциональную задачу: принять файл, сохранить, обработать. Безопасность? «Добавлю потом». А «потом» обычно наступает после инцидента.
Я решил не ждать инцидента и разобрался заранее: какие атаки существуют при загрузке файлов, что бывает, когда про них забывают, и как мы от них защитились в конкретном проекте.
Дисклеймер. Это не универсальный гайд по безопасности и не претензия на тему «я знаю, как надо». Это инженерный кейс: как я проектировал защиту загрузки файлов в конкретном сервисе и какие компромиссы принимал. В качестве примера – браузерное расширение для конвертации PDF в Markdown. Сам по себе PDF-конвертер, возможно, и не требует такого уровня защиты. Но подходы универсальны и применимы к системам, где ставки выше: медицинские документы, финансовые отчёты, юридические сканы, UGC-платформы. Показываю принципы на реальном коде – а вы решаете, какие из них нужны в вашем случае.
Подмена типа файла → 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 подписывается приватным ключом:
Заголовок | Назначение |
|---|---|
| Идентификация устройства |
| Время отправки (окно ±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. Стоимость атаки растёт на порядки, а выгода остаётся той же.
Злоумышленник переименовывает вредоносный файл (скрипт, исполняемый файл, 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, и чем-то вредоносным – такие полиглоты существуют, об этом ниже).
Злоумышленник загружает гигабайтный файл (или тысячи файлов подряд), чтобы исчерпать дисковое пространство или память сервера.
Три уровня ограничения размера:

Ключевой элемент – 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 в час).
Злоумышленник отправляет файл с именем ../../../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. В нашем случае санитизация на фронте (удаление спецсимволов, ограничение длины) – первый барьер, а основная ответственность лежит на корректном экранировании при выдаче результата.
При загрузке по 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-клиента. В нашем случае этот вектор частично снижается за счёт блокировки редиректов и одноразового запроса.
Злоумышленник перехватывает легитимный запрос на загрузку файла и отправляет его повторно – многократно. Результат: десятки одинаковых задач, исчерпание квот, нагрузка на сервер.
Здесь работает та самая система подписей, которую мы описали в разделе «Фундамент». Каждый запрос уже содержит 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 устарел).
Злоумышленник пытается представиться другим устройством, чтобы использовать его квоты, получить его результаты или просто обойти rate-limit.
Как мы описали в разделе «Фундамент», идентификация устройства основана на асимметричной криптографии, а не на простых токенах. Это делает подмену принципиально невозможной:
Приватный ключ нельзя украсть – extractable: false в WebCrypto API означает, что даже вредоносное расширение не может прочитать ключ из IndexedDB. Его можно только использовать для подписи
Токен без ключа бесполезен – device_token даёт право отправить запрос, но без ECDSA-подписи приватным ключом сервер его отклонит
Создание «нового устройства» дорого – нужен новый профиль браузера + регистрация, ограниченная 5 попытками на IP в час
Ключ привязан к профилю браузера – смена вкладки, перезагрузка, обновление расширения – ключ остаётся. Только удаление профиля или расширения приводит к потере ключа
Массовая отправка запросов на загрузку с целью перегрузить сервер – забить сеть, диск или 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 может содержать сжатые потоки, которые при распаковке разрастаются до гигабайтов. Файл в 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 – это полноценный контейнер, который может содержать:
Встроенный 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 – это файл, который одновременно является валидным PDF и, например, валидным ZIP или HTML. Наша проверка magic bytes пройдёт (%PDF в начале), но другое ПО может интерпретировать файл иначе.
Текущий статус: проверяются только первые 4 байта. Более глубокий анализ структуры PDF не проводится.
Решение: валидация структуры PDF (xref-таблица, trailer) или использование специализированных библиотек для глубокой валидации.
Как упоминалось в разделе про 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, проверяйте диапазоны, блокируйте редиректы.
Делайте атаку экономически невыгодной.
Абсолютной защиты не существует – мотивированный атакующий найдёт путь. Но вы можете сделать так, чтобы стоимость атаки многократно превышала потенциальную выгоду.
Будьте честны о пробелах.
Безопасность – это процесс, а не состояние. Знать свои слабые места и планировать их закрытие – лучше, чем считать себя неуязвимым.