
Введение
В феврале прошлого года у интерфейса веб-серверов Rack, лежащего в основе практически каждого приложения Ruby on Rails, была обнаружена CVE-2024-26141. Уязвимость была простой: достаточно отправить запрос файла с сотней байтовых диапазонов, и Rack генерировал неожиданно большой ответ. Серверы продакшена можно было атаковать одиночными HTTP-запросами, пока у них не закончится ресурс памяти или канала.
Усугубляло ситуацию то, что баг затронул широкий диапазон версий: от 1.3.0 и выше; это означало, что уязвимыми оказались приложения, которые писали с 2011 года. Многие разработчики тратили все свои выходные на установку патчей.
Это пример того, как простой неправильно обрабатываемый пограничный случай HTTP может нанести существенный ущерб. И не потому, что мы плохие разработчики, а потому, что HTTP сложен. В идеальном случае всё работает замечательно. Но потом наступает продакшен.
Проблема заголовка диапазона
Заголовки диапазонов нужны для продолжения скачивания, поиска в видео, скачивания фрагментов файлов и так далее. Они есть в спецификации HTTP/1.1 и определены в RFC 7233. Большинство из нас не задумывается о них, пока что-нибудь не сломается.
Уязвимость Rack возникла потому, что специально подготовленные заголовки диапазонов могут вызывать большой объём вычислений при парсинге и сборке ответа интерфейсом Rack: некорректные или чрезвычайно большие диапазоны заставляют сервер распределять или вычислять больше данных, чем ожидалось. При достаточно большом количестве запрошенных диапазонов затраты на обработку начинают превышать сам контент. Атака выглядела так:
GET /report.pdf HTTP/1.1
Range: bytes=0-999,1000000-2000000,999999999-...
При дальнейшем добавлении диапазонов оверхед парсинга накапливается. PDF размером 1 МБ со специально настроенными диапазонами может генерировать многомегабайтные ответы или приводить к затратным вычислениям диапазонов. Один запрос катастрофы не вызовет, но если сгенерировать сотни конкурентных запросов, то существенно увеличится потребление ресурсов памяти и CPU, снижая таким образом доступность сервисов.
Серверы статических файлов наподобие nginx и Apache обрабатывают такие ситуации корректно. S3 и CloudFront тоже справляются с ними. Проблема возникает, когда мы пишем собственные конечные точки скачивания файлов для управления доступом, создания водяных знаков или динамической генерации.
Но для решения проблемы недостаточно просто ограничить количество диапазонов. Даже пять диапазонов в случае достаточно большого файла могут привести к генерации проблемных ответов. Необходимо валидировать и количество диапазонов, и общий проецируемый размер ответа. ResourceHttpRequestHandler Spring Boot делает это для статических ресурсов, но пользовательские контроллеры не наследуют эту защиту:
@GetMapping("/api/files/{filename}")
public ResponseEntity<?> downloadFile(
@PathVariable String filename,
@RequestHeader(value = "Range", required = false) String rangeHeader) throws IOException {
Resource file = fileService.loadAsResource(filename);
long size = file.contentLength();
if (rangeHeader == null || rangeHeader.isBlank()) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(file);
}
List<HttpRange> ranges;
try {
ranges = HttpRange.parseRanges(rangeHeader); // это выдаст ошибку плохого синтакса или >100 диапазонов
} catch (IllegalArgumentException ex) {
// RFC 7233: 416 for unsatisfiable/malformed; include Content-Range with '*'
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
.header(HttpHeaders.CONTENT_RANGE, "bytes */" + size)
.build();
}
// Ограничиваем диапазоны и общее количество байтов для защиты ресурсов
final int MAX_RANGES = 5;
final long MAX_BYTES = Math.min(size, 8L * 1024 * 1024);
if (ranges.size() > MAX_RANGES) {
// Вариант А: игнорировать диапазоны -> возвращать контент целиком (200)
// Вариант Б: 416, чтобы заставить клиента выполнить безопасную повторную попытку, выбрать согласованную политику
return ResponseEntity.ok().body(file);
}
long totalBytes = 0L;
for (HttpRange r : ranges) {
ResourceRegion rr = r.toResourceRegion(file);
totalBytes += rr.getCount();
if (totalBytes > MAX_BYTES) {
return ResponseEntity.ok().body(file); // игнорируем диапазоны, если они слишком велики
}
}
// Позволяем Spring автоматически записывать однострочный или многосоставной 206
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(HttpRange.toResourceRegions(ranges, file));
}
Аналогичный пример для Express.js:
import rangeParser from 'range-parser';
import fs from 'fs';
import path from 'path';
app.get('/files/:filename', (req, res) => {
const filename = req.params.filename;
const filePath = path.join(__dirname, 'files', filename);
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const rangeHeader = req.headers.range;
if (rangeHeader) {
// Парсим при помощи range-parser (то же самое, что и express.static)
const ranges = rangeParser(fileSize, rangeHeader, { combine: true });
if (ranges === -1) {
// Невыполнимо -> 416 по RFC
return res.status(416)
.set('Content-Range', `bytes */${fileSize}`)
.end();
}
if (ranges === -2) {
// Неправильно сформированный -> игнорируем диапазон, передаём контент полностью
return res.sendFile(filePath);
}
// Ограничиваем диапазоны и совокупный размер
const MAX_RANGES = 5;
const MAX_BYTES = 8 * 1024 * 1024;
if (ranges.length > MAX_RANGES) {
// Слишком много -> игнорируем диапазон, передаём контент полностью
return res.sendFile(filePath);
}
let totalBytes = 0;
for (const r of ranges) {
totalBytes += (r.end - r.start + 1);
if (totalBytes > MAX_BYTES) {
// Слишком тяжело -> игнорируем диапазон
return res.sendFile(filePath);
}
}
// Если один диапазон, то передаём однострочный 206
if (ranges.length === 1) {
const { start, end } = ranges[0];
res.status(206)
.set('Content-Range', `bytes ${start}-${end}/${fileSize}`)
.set('Accept-Ranges', 'bytes')
.set('Content-Length', end - start + 1);
return fs.createReadStream(filePath, { start, end }).pipe(res);
}
// Если диапазонов несколько, необходимо сконструировать многосоставной ответ/диапазоны байтов.
// Node/Express не делает этого автоматически; можно отклонить запрос или передать контент целиком
return res.sendFile(filePath);
}
// Нет диапазона -> передать контент целиком
res.sendFile(filePath);
});
Устранить проблему несложно, но о ней легко забыть, если мы сосредоточены на логике контроля доступа и запросов к базам данных. Кажется, что заголовками диапазонов должна заниматься инфраструктура, но внезапно они оказываются нашей проблемой.
Принудительное указание Content-Type предотвращает странное поведение парсера
Согласно RFC 8259 Section 8.1, JSON должен кодироваться в UTF-8. Не существует «параметра charset», потому что кодировка UTF-8 обязательна. Любой JSON с Content-Type: application/json; charset=iso-8859-1, строго говоря, имеет неверную структуру.
Но есть и более тонкая проблема. Рассмотрим следующий запрос:
POST /api/users HTTP/1.1
Content-Type: text/plain
Content-Length: 87
{"name": "Robert'; DROP TABLE users;--", "email": "bobby@tables.com"}
Некоторые фреймворки видят напоминающий JSON текст и всё равно его парсят. Другие корректно его отклоняют. Поведение бывает разным, и из-за такого рассогласования возникают бреши в безопасности. Нападающий может генерировать полезные нагрузки, которые парсятся только в определённых версиях или конфигурациях фреймворков.
Если явно указать, что вы хотите принимать, Spring Boot задаёт Content-Type принудительно:
@PostMapping(value = "/api/users", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
// Если Content-Type не совпадает, возвращает 415 Unsupported Media Type
return ResponseEntity.ok(userService.create(user));
}
Express требует явной конфигурации middleware. По умолчанию он вообще не парсит тела. При наличии явной конфигурации можно принудительно задавать строгие типы медиа:
// Принимает только application/json, отклоняет всё остальное
app.use(express.json({ type: 'application/json' }));
app.post('/api/users', (req, res) => {
// req.body заполняется только для корректного Content-Type
// В случае неправильного Content-Type req.body остаётся неопределённым, что предотвращает «беззвучные» сбои
});
Django REST Framework по умолчанию валидирует Content-Type, и это одна из причин, по которой в Django сложно «выстрелить себе в ногу»:
from rest_framework.decorators import api_view
from rest_framework.parsers import JSONParser
@api_view(['POST'])
@parser_classes([JSONParser]) # Принимает только application/json
def create_user(request):
# Возвращает 415 для некорректного Content-Type
# Никакой неоднозначности, никакого тихого парсинга
pass
Всегда объявляйте допустимые Content-Type. API, которые допускают всё, создают поверхность атаки. Если вы обрабатываете только JSON, отклоняйте всё, для чего явным образом не указано application/json. Не пытайтесь быть полезным, пытаясь принимать «достаточно похожие» типы контента.
Согласование заголовков Accept быстро становится странным
Заголовки Accept позволяют клиентам указывать предпочтительные форматы со значениями качества:
Accept: application/json;q=1.0, application/xml;q=0.5, */*;q=0.1
Эта строка сообщает: «Я предпочитаю JSON, подойдёт и XML, но при необходимости я приму всё». Качество имеет значения в интервале от 0.0 до 1.0, чем они выше, тем предпочтительнее формат. Большинство API полностью это игнорирует и возвращает тот формат, который первый соответствует их логике маршрутизации.
Баги согласования контента возникают, когда логика маршрутизации не учитывает значения качества. Если API добавляет поддержку XML для обработки одного клиента, но логика маршрутизации проверяет наличие подстроки «xml» до парсинга значений качества, то клиенты, предпочитающие JSON, но указавшие XML, как приемлемый формат (Accept: application/json;q=1.0, application/xml;q=0.5), могут неожиданно получить XML. Парсер JSON получает XML, происходит сбой парсинга и интеграция ломается.
Spring Boot обрабатывает согласование контента автоматически и правильно парсит значения качества:
@GetMapping(value = "/api/data", produces = MediaType.APPLICATION_JSON_VALUE)
public Data getData() {
return dataService.fetch();
}
// Несколько форматов - Spring выполняет согласование на основании значений качества
@GetMapping(value = "/api/data/{id}",
produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
public Data getDataById(@PathVariable Long id) {
return dataService.findById(id);
}
Если клиент запрашивает что-то неподдерживаемое, то Spring без каких-либо двусмысленностей возвращает 406 Not Acceptable.
Express требует ручной обработки, при этом нужно правильно реализовать парсинг:
app.get('/api/data', (req, res) => {
// req.accepts() правильно парсит значения качества
const accepts = req.accepts(['json', 'xml']);
if (accepts === 'json') {
res.json(data);
} else if (accepts === 'xml') {
res.type('application/xml').send(toXml(data));
} else {
res.status(406).send('Not Acceptable');
}
});
Выбирайте поддерживаемые форматы осознанно. Не пытайтесь поддерживать всё. Если в 99% конечных точек вы обрабатываете JSON, не добавляйте в одну конечную точку поддержку XML только потому, что его запрашивает легаси-система. Именно так вы создадите описанный выше инцидент в продакшене.
Method Not Allowed должен сообщать, что подходит
Ответы HTTP 405 должны включать заголовок Allow со списком валидных методов. Это превращает ответы с ошибками в документацию. Когда разработчики получают 405 вместо 200, они сразу будут понимать, какие методы поддерживаются. Без качественных заголовков Allow 404 и 405 становятся неразличимыми, что заставляет разработчиков гадать о причинах: конечные точки не существуют или они используют неправильный метод?
Для методов контроллеров Spring Boot обрабатывает это автоматически:
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
@GetMapping("/{id}")
public Payment getPayment(@PathVariable String id) {
return paymentService.findById(id);
}
@PostMapping
public Payment createPayment(@Valid @RequestBody PaymentRequest request) {
return paymentService.create(request);
}
}
Если отправить DELETE на /api/payments/123, то получится следующее:
HTTP/1.1 405 Method Not Allowed
Allow: GET, POST, HEAD, OPTIONS
Express требует явной обработки:
app.route('/api/payments/:id')
.get(getPayment)
.post(createPayment)
.all((req, res) => {
res.set('Allow', 'GET, POST')
.status(405)
.send('Method Not Allowed');
});
Это небольшая деталь, но она отличает замечательный API от хорошего.
Конфигурация сжатия никогда не находится там, где вы предполагаете
Кажется, со сжатием всё просто: включаем gzip, экономим ширину канала, выигрывают все. А потом обнаруживается, что ваша конфигурация сжатия Spring Boot ничего не делает в продакшене, потому что nginx (или другой прокси) прекращает соединение ещё до того, как оно достигнет вашего приложения.
Сжатие Spring Boot влияет только на встроенный контейнер сервлета:
server.compression.enabled=true
server.compression.mime-types=application/json,application/xml,text/plain
server.compression.min-response-size=1024
В продакшене обработкой TLS-соединения обычно занимается nginx, HAProxy или API Gateway. Они видят запрос, обрабатывают его и проксируют к бэкенду. Если для nginx не сконфигурировано сжатие, то вне зависимости от настроек Spring Boot ответ исходит в несжатом виде. Если для nginx сконфигурировано сжатие, то он сжимает ответ, вне зависимости от того, сконфигурирован ли Spring Boot.
Конфигурировать сжатие нужно там, где оно происходит на самом деле — на границе. Также следует отключать сжатие на уровне приложений или вырезать Accept-Encoding в апстриме, чтобы избежать двойного сжатия:
nginx:
gzip on;
gzip_types application/json application/xml text/plain text/css application/javascript;
gzip_min_length 1024;
gzip_vary on; # Сообщает, что сжатие кэшей варьируется в зависимости от Accept-Encoding
gzip_proxied any; # Включаем gzip для прокси-ответов
gzip_comp_level 6; # Балансируем нагрузку на CPU и коэффициент сжатия
Apache:
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/json application/xml text/plain text/css application/javascript
DeflateCompressionLevel 6
Header append Vary "Accept-Encoding"
</IfModule>
AWS API Gateway:
# Включаем сжатие в настройках API Gateway
minimumCompressionSize: 1024
Не сжимайте всё без разбору: в случае маленьких ответов размером меньше 1 КБ оверхед от сжатия превышает экономию на трафике. Такты CPU и задержки не стоят экономии двухсот байт. Также подумайте над отключением сжатия на уязвимых конечных точках (например, на страницах, отражающих секреты), чтобы снизить риски атак типа BREACH.
Кодирование символов незаметно повреждает базу данных
Несоответствие кодировок необратимо уничтожает данные. Форма регистрации принимает пользовательские данные. Сервер предполагает, что они имеют формат UTF-8. Клиент отправляет ISO-8859-1. Имена повреждаются. Повреждение необратимо, потому что невозможно обнаружить проблемы кодировок после того, как всё уже случилось. Текст может быть повреждённым UTF-8 или же правильно закодированным в другой кодировке.
Эта проблема возникает с данными форм:
POST /api/registration HTTP/1.1
Content-Type: application/x-www-form-urlencoded; charset=iso-8859-1
name=Jos%E9+Garc%EDa&city=S%E3o+Paulo
Если сервер предполагает, что используется UTF-8, то он декодирует закодированные процентами байты как UTF-8 и сохраняет их. Повреждение необратимо.
Используйте UTF-8 везде и включайте его принудительно:
Spring Boot:
server.tomcat.uri-encoding=UTF-8
spring.http.encoding.charset=UTF-8
spring.http.encoding.force=true
Express:
app.use(express.json({ defaultCharset: 'utf-8' }));
app.use(express.urlencoded({ defaultCharset: 'utf-8', extended: true }));
Django использует UTF-8 по умолчанию, но укажите его явно:
DEFAULT_CHARSET = 'utf-8'
nginx:
charset utf-8;
Согласно RFC 8259, для конечных точек JSON формат UTF-8 обязателен. Отклоняйте все запросы, утверждающие, что в них используется не UTF-8. Согласно спецификации, они повреждены, и их обработка повредит данные.
Обход пути позволяет нападающим читать произвольные файлы
CVE-2019-19781 показал, что уязвимости обхода пути компрометируют сети целиком. Уязвимость Citrix ADC позволяет нападающим читать произвольные файлы благодаря манипуляциям с URL:
GET /vpn/../vpns/cfg/smb.conf HTTP/1.1
GET /vpn/%2e%2e%2fvpns%2fcfg%2fsmb.conf HTTP/1.1
Можно подняться вверх по дереву папок, считывать файлы конфигурации с учётными данными, перемещаться по сети горизонтально. Из-за неправильной валидации путей компрометируются корпоративные сети. Citrix выпустила аварийные патчи, но эксплойты уже существуют.
Все конечные точки, сопоставляющие пользовательский ввод с путями файловой системы, требуют валидации. Паттерн един: нормализуем путь, ресолвим его в абсолютный путь, проверяем, остаётся ли он внутри допустимой папки!
Вот некоторые примеры того, как с этим работать:
Java:
@GetMapping("/api/files/**")
public ResponseEntity<Resource> serveFile(HttpServletRequest request) throws IOException {
String requestPath = request.getRequestURI().substring("/api/files/".length());
Path baseDir = Paths.get("/var/app/uploads").toAbsolutePath().normalize();
Path requestedFile = baseDir.resolve(requestPath).normalize();
// Критично: проверяем, что после ресолвинга путь остаётся внутри базовой папки
if (!requestedFile.startsWith(baseDir)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (!Files.exists(requestedFile) || !Files.isReadable(requestedFile)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok().body(new FileSystemResource(requestedFile));
}
Node.js:
const path = require('path');
app.get('/files/*', (req, res) => {
const baseDir = path.resolve('/var/app/uploads');
const requestedPath = path.join(baseDir, req.params[0]);
const resolved = path.resolve(requestedPath);
if (!resolved.startsWith(baseDir)) {
return res.status(403).send('Forbidden');
}
res.sendFile(resolved);
});
Python:
from pathlib import Path
def serve_file(filename):
base_dir = Path('/var/app/uploads').resolve()
requested = (base_dir / filename).resolve()
# Проверяем, что после ресолвинга путь остаётся в базовой папке
if not requested.is_relative_to(base_dir):
return 403, "Forbidden"
return send_file(requested)
Никогда не доверяйте пользовательскому вводу в операциях с файловой системой. Всегда выполняйте нормализацию, ресолвинг и валидацию.
Ограничение размера запросов предотвращает переполнение памяти
Неограниченные размеры запросов — это простейшая возможность реализации DoS-атаки. Отправляем POST-запросы по 100 МБ, пока не исчерпается память сервера. Не требуется никаких сложных действий или распределённых атак. Один нападающий, один скрипт, один плохо сконфигурированный сервер.
Везде задавайте консервативные ограничения:
Spring Boot:
server.tomcat.max-http-request-header-size=8KB
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
Express:
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ limit: '1mb', extended: true }));
Django:
DATA_UPLOAD_MAX_MEMORY_SIZE = 1048576 # 1 МБ
FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10 МБ
nginx:
client_max_body_size 10M;
client_body_buffer_size 128K;
Apache:
LimitRequestBody 10485760
Большинству API не требуется на запрос больше 1-10 МБ. Вам нужно загружать больше? Используйте chunked-загрузки, протоколы с механизмами возобновления или direct-to-S3 с предварительно подписанными URL. Не буферизируйте загрузки целиком в памяти.
Уязвимость диапазонов в Rack в сочетании с неограниченными размерами ответов могла бы привести к катастрофе. Важен каждый слой валидации. Устанавливайте ограничения, следите за их исполнением, тестируйте их.
Transfer-Encoding позволяет использовать контрабанду запросов
Контрабанда запросов (request smuggling) пользуется рассогласованием между прокси и серверами приложений относительно того, где заканчивается один запрос и начинается другой. Согласно RFC 7230 Section 3.3.3, если присутствует Transfer-Encoding, то Content-Length следует игнорировать.
Атака отправляет конфликтующие заголовки:
POST /api/submit HTTP/1.1
Content-Length: 44
Transfer-Encoding: chunked
0
POST /api/admin/delete HTTP/1.1
Прокси обрабатывает Content-Length и видит один запрос. Сервер приложения обрабатывает кодировку chunked и видит два запроса. Второй «контрабандный» запрос обходит аутентификацию, потому что прокси уже валидировала первый запрос.
Современные HTTP-серверы корректно реализуют RFC 7230, но в определённых конфигурациях баги всё равно возникают. CVE-2020-7238 затронул Netty 4.1.43, потому что он ошибочно обрабатывал заголовки Transfer-Encoding с идущим впереди пробелом. CVE-2021-43797 показал, что управляющий символ в именах заголовков в определённых конфигурациях прокси может обеспечить возможность контрабанды.
Не нужно реализовывать защиту от контрабанды запросов в коде приложения. Требуется глубокая защита — множественные слои, каждый из которых валидирует соответствие HTTP:
nginx автоматически нормализует запросы и отклоняет неправильные. Никакой специальной конфигурации не требуется, всё вшито в парсер HTTP.
HAProxy по умолчанию строго валидирует соответствие HTTP и отклоняет запросы с заголовками
Content-LengthиTransfer-Encoding.Springboot и Tomcat валидируют это на уровне коннекторов. Контейнер сервлета отклоняет попытки контрабанды до выполнения кода приложения.
Уязвимость появляется, когда у прокси и серверов приложений возникают разногласия со спецификацией. Ваша защита гарантирует, что каждый слой правильно реализует RFC 7230. Не пытайтесь добавить собственную защиту от контрабанды запросов. Вы реализуете её неправильно и добавите сложность, никак не повышающую безопасность.
Тестирование пограничных случаев раскрывает истинное поведение
В идеале все эти случаи должны обрабатываться автоматизированными тестами. Сначала используйте curl, чтобы понять, как ваш стек обрабатывает пограничные случаи, а затем автоматизируйте успешные тесты:
# Тестируем злоумышленное использование заголовка диапазона
curl -v -H "Range: bytes=0-100,200-300,400-500,600-700,800-900,1000-1100" \
http://localhost:8080/api/files/test.pdf
# Тестируем принудительное использование Content-Type
curl -X POST -H "Content-Type: text/plain" \
-d '{"valid": "json"}' http://localhost:8080/api/users
# Тестируем обход пути
curl "http://localhost:8080/api/files/../../../etc/passwd"
curl "http://localhost:8080/api/files/%2e%2e%2f%2e%2e%2fetc%2fpasswd"
# Тестируем ограничения размеров - генерируем 11 МБ, чтобы превзойти ограничение в 10 МБ
dd if=/dev/zero bs=1M count=11 2>/dev/null | \
curl -X POST -H "Content-Type: application/octet-stream" \
--data-binary @- http://localhost:8080/api/upload
# Тестируем валидацию методов
curl -X DELETE -v http://localhost:8080/api/payments/123
# Тестируем конфликты кодировок
curl -X POST -H "Content-Length: 10" -H "Transfer-Encoding: chunked" \
-d "test" http://localhost:8080/api/submit
Эти тесты дадут вам понимание того, как ломается ваш API. Что следует проверять:
Сообщения об ошибках полезны или они создают утечку подробностей реализации?
Целостно ли работает валидация?
Какая информация отображается в логах, когда происходит атака?
HTTP/2 и HTTP/3 меняют поверхность атаки
Выше мы говорили о пограничных случаях с упором на HTTP/1.1, однако HTTP/2 и HTTP/3 привносят другие уязвимости. Двоичное формирование фреймов и сжатие заголовков (HPACK) протокола HTTP/2 открывают возможности для новых атак наподобие бомб сжатия (больших, специально созданных таблиц заголовков) и злонамеренного использования потоков. Транспорт QUIC протокола HTTP/3 добавляет такие аспекты, как миграцию соединений и возобновление 0-RTT, которые в случае неправильной обработки могут открыть возможность для атак повторным воспроизведением.
Большинство фреймворков абстрагирует различия протоколов, но некоторые пограничные случаи остаются связанными с конкретными протоколами:
Заголовки диапазонов: работают одинаково в HTTP/1.1, HTTP/2 и HTTP/3.
Контрабанда запросов: распространена в HTTP/1.1 из-за неоднозначностей с chunked-кодированием, но неприменима к строгому двоичному формированию фреймов в HTTP/2.
Атаки Rapid reset: уникальны для мультиплексирования HTTP/2, при котором нападающие могут открыть множество потоков и немедленно их отменить, тратя ресурсы сервера.
Эксплойты сжатия заголовков: злоумышленники могут использовать HPACK (HTTP/2) и QPACK (HTTP/3) для атак давления на память, если реализации не задействуют строгие правила.
Риски транспорта QUIC: HTTP/3 наследует такие проблемы, как повторное воспроизведение при помощи 0-RTT и потенциальное использование нападающими миграции соединений.
Абстракции фреймворков обрабатывают большинство различий протоколов, но при проектировании защитных мер важно понимать, какие уязвимости относятся к какой версии протокола. Обратный прокси, транслирующий HTTP/2 в HTTP/1.1 (или HTTP/3 в HTTP/2) добавляет ещё один слой, в котором рассогласования о границах запросов или парсинге заголовков могут снова внедрять старые классы багов.
Где заканчиваются фреймворки и начинается ваша ответственность
Большинство современных фреймворков обрабатывает HTTP корректно. Нам редко приходится писать собственный код валидации и зачастую он привносит больше багов, чем устраняет. Все описанные выше уязвимости — CVE-2024-26141 (заголовки диапазонов Rack), CVE-2019-19781 (обход пути Citrix), CVE-2020-7238 (контрабанда в Netty) — используют несоответствия между спецификацией и реализацией.
Обычно фреймворки обрабатывают следующее:
Валидацию Content-Type
Согласование заголовков Accept
Валидацию методов HTTP
Валидацию Transfer-Encoding
Базовую нормализацию путей
Сжатие (если конфигурирование выполнено на нужном уровне)
Используемые по умолчанию кодировки символов
Вам нужно реализовывать следующее:
Валидацию заголовков диапазонов для файловых конечных точек
Предотвращение обхода пути для передачи динамических файлов
Ограничение размеров запросов (через конфигурацию)
Правильные ответы на ошибки с заголовками Allow
Обработка на уровне инфраструктуры:
Ограничение частоты
Защита от DDoS
Завершение TLS
Нормализация запросов
Географическая маршрутизация
Ваша защита — это не увеличение объёма кода, а глубокое понимание HTTP, знание о том, что обрабатывает фреймворк, использование слоёв инфраструктуры для обеспечения избыточности и написание своей валидации только тогда, когда она действительно необходима. Большинство уязвимостей безопасности возникает из-за ненужного собственного кода, который заново реализует (неправильно) то, что фреймворк уже делает правильно.
Комментарии (2)

vasyakolobok77
19.10.2025 21:14Понимаю, что это перевод, но все же.
Content-Type: application/x-www-form-urlencoded; charset=iso-8859-1Если сервер предполагает, что используется UTF-8, то он декодирует закодированные процентами байты, как UTF-8 и сохраняет их. Повреждение необратимо.
Если в запросе передана кодировка charset, то парсеры запроса будут использовать ее при URLDecode. Это касается как Tomcat / Jetty / Netty, так и оберток на уровне Spring. Иное поведение, где не учитывается кодировка из заголовка Content-Type и используется кодировка по умолчанию, - это просто баг.
Dr_Faksov
Картинка на заставке зачётная, вот только это абсолютно нормальное состояние наборных регулирующих плотин.