diff --git a/PentestReport.md b/PentestReport.md new file mode 100644 index 0000000..bd76d0b --- /dev/null +++ b/PentestReport.md @@ -0,0 +1,394 @@ +# 🛡️ Этап 5 — Pentest Report + +## 📊 Общее резюме + +В результате выявлены **критические уязвимости уровня HIGH и CRITICAL**, позволяющие: + +- выполнять произвольный JavaScript в браузере пользователя +- получать доступ к внутренней инфраструктуре +- записывать файлы на сервере вне разрешённой директории +- перегружать систему (DoS) +- получать доступ к данным без аутентификации + +⚠️ Приложение **не готово к production-использованию**. + +--- + +## 🎯 Scope + +Тестирование охватывало следующие компоненты: + +- REST API endpoints: + - `/register` + - `/recordSession` + - `/totalActivity` + - `/inactiveUsers` + - `/monthlyActivity` + - `/userProfile` + - `/exportReport` + - `/notify` +- Внутренние сервисы: + - `UserAnalyticsService` + - `UserStatusService` + +--- + +## 🧱 Asset Inventory + +| Актив | Тип | Ценность | Причина | +|------|-----|----------|--------| +| Пользовательские данные | Данные | HIGH | Персональные данные | +| Сессии активности | Данные | HIGH | Аналитика и поведение | +| Файловая система | Инфраструктура | CRITICAL | Возможен доступ/перезапись | +| Внутренняя сеть | Инфраструктура | CRITICAL | SSRF атаки | + +--- + +## ⚠️ Threat Modeling (STRIDE) + +| Категория | Применимость | Пример | +|----------|-------------|-------| +| Spoofing | ✅ | Нет аутентификации | +| Tampering | ✅ | Изменение данных | +| Repudiation | ⚠️ | Нет логирования | +| Information Disclosure | ✅ | Ошибки и SSRF | +| DoS | ✅ | Нет ограничений | +| Elevation of Privilege | ⚠️ | Через SSRF | + +--- + +# 🔴 Finding #1 — Reflected XSS + +| Поле | Значение | +|------|----------| +| Компонент | `/userProfile` | +| CWE | CWE-79 | +| CVSS | 6.1 MEDIUM | +| Статус | Confirmed | + +**Описание:** +Параметр `userName` напрямую вставляется в HTML без экранирования. + +**PoC:** + +POST /register?userId=evil&userName= +GET /userProfile?userId=evil + + +**Impact:** +- выполнение JS +- кража сессии + +**Fix:** +- HTML escaping +- CSP header + +Шаги воспроизведения: + +1. Отправить запрос на регистрацию пользователя с вредоносным payload: + POST /register?userId=evil&userName= + +2. Запросить профиль пользователя: + GET /userProfile?userId=evil + +3. Открыть ответ в браузере + +Ожидаемый результат: +HTML должен быть экранирован, скрипт не выполняется + +Фактический результат: +JavaScript выполняется (alert) + +--- + +# 🔴 Finding #2 — Path Traversal + +| Поле | Значение | +|------|----------| +| Компонент | `/exportReport` | +| CWE | CWE-22 | +| CVSS | 8.6 HIGH | + +**Описание:** +Путь к файлу формируется из пользовательского ввода. + +**PoC:** + +../../../../etc/passwd + + +**Impact:** +- запись файлов +- возможный RCE + +**Fix:** +- whitelist файлов +- normalize path + + +Шаги воспроизведения: + +1. Отправить запрос: + GET /exportReport?userId=test&filename=../../../../tmp/hacked.txt + +2. Проверить статус ответа + +Ожидаемый результат: +Сервер возвращает ошибку (400/403), запись запрещена + +Фактический результат: +Сервер возвращает 200 OK и записывает файл вне разрешённой директории + +--- + +# 🔴 Finding #3 — SSRF + +| Поле | Значение | +|------|----------| +| Компонент | `/notify` | +| CWE | CWE-918 | +| CVSS | 9.1 CRITICAL | + +**Описание:** +Сервер выполняет HTTP-запрос на произвольный URL. + +**PoC:** + +http://169.254.169.254 + + +**Impact:** +- доступ к metadata +- обход firewall + +**Fix:** +- блок private IP +- DNS allowlist + +Вариант 1 — localhost +1. Отправить запрос: + POST /notify?userId=test&callbackUrl=http://localhost:7000 + +2. Наблюдать ответ сервера + +Ожидаемый результат: +Запрос блокируется (400/403) + +Фактический результат: +Сервер выполняет HTTP-запрос к localhost + +Вариант 2 — metadata endpoint +1. Отправить запрос: + POST /notify?userId=test&callbackUrl=http://169.254.169.254 + +2. Проверить ответ + +Ожидаемый результат: +Запрос блокируется + +Фактический результат: +Сервер пытается обратиться к внутреннему IP +--- + +# 🔴 Finding #4 — DoS + +| Поле | Значение | +|------|----------| +| CWE | CWE-400 | +| CVSS | 7.5 HIGH | + +**Описание:** +- нет rate limiting +- возможны тяжёлые вычисления (CPU hotspot) + +**Impact:** +- перегрузка CPU +- зависание сервера + +Вариант 1 — большой input +1. Сформировать userId длиной 10000 символов +2. Отправить запрос: + GET /totalActivity?userId=<очень длинная строка> + +Ожидаемый результат: +Сервер возвращает 400 Bad Request + +Фактический результат: +Сервер обрабатывает запрос без ограничений +Вариант 2 — flood запросов +1. Выполнить 50+ запросов подряд: + GET /totalActivity?userId=test + +2. Проверить ответы + +Ожидаемый результат: +Появляется ограничение (429 Too Many Requests) + +Фактический результат: +Все запросы обрабатываются без ограничений + +--- + +# 🔴 Finding #5 — Broken Validation + +| Поле | Значение | +|------|----------| +| CWE | CWE-20 | + +**Описание:** +Нет проверки временных интервалов. + +**Impact:** +- некорректная аналитика + +Шаги воспроизведения: + +1. Отправить запрос: + POST /recordSession?userId=test + &loginTime=2025-01-01T10:00:00 + &logoutTime=2025-01-01T09:00:00 + +2. Проверить статус ответа + +Ожидаемый результат: +Ошибка валидации (400) + +Фактический результат: +Сервер принимает некорректную сессию + +--- + +# 🔴 Finding #6 — Null Handling + +| Поле | Значение | +|------|----------| +| CWE | CWE-476 | + +**Описание:** + +getUserSessions(userId).getLast() + + +→ может вызвать NPE + +Шаги воспроизведения: + +1. Не создавать сессии для пользователя +2. Выполнить запрос: + GET /monthlyActivity?userId=test&month=2025-01 + +Ожидаемый результат: +Корректный ответ (пустой результат или 400) + +Фактический результат: +500 Internal Server Error (возможен NullPointerException) + +--- + +# 🔴 Finding #7 — Missing Authentication + +| Поле | Значение | +|------|----------| +| CWE | CWE-306 | +| CVSS | 9.8 CRITICAL | + +**Описание:** +Все endpoints доступны без auth. + +Шаги воспроизведения: + +1. Выполнить запрос без авторизации: + GET /totalActivity?userId=test + +2. Проверить статус ответа + +Ожидаемый результат: +401 Unauthorized + +Фактический результат: +200 OK — доступ разрешён без проверки + +--- + +# 🔴 Finding #8 — Information Disclosure + +| Поле | Значение | +|------|----------| +| CWE | CWE-209 | + +**Описание:** +Возвращаются внутренние ошибки. + +Шаги воспроизведения: + +1. Отправить некорректный запрос: + POST /recordSession?userId=test&loginTime=invalid&logoutTime=invalid + +2. Проверить тело ответа + +Ожидаемый результат: +Общее сообщение об ошибке (без деталей) + +Фактический результат: +Ответ содержит внутренние детали или текст исключения + +--- + +# 🧪 Security Testing Strategy + +Реализованы автоматические тесты: + + +src/test/java/.../pentest/ + + +### Подход: +- тесты проверяют **безопасное поведение** +- при наличии уязвимости → тест падает +- после фикса → тест проходит + +Шаги воспроизведения: + +1. Отправить запрос: + POST /register?userId=' OR 1=1 --&userName=test + +2. Проверить статус ответа + +Ожидаемый результат: +400 Bad Request (некорректный ввод) + +Фактический результат: +Сервер принимает вредоносный input +--- + +# 🛠️ Рекомендации (приоритет) + +## 🔴 Critical +- Добавить authentication +- Исправить SSRF +- Исправить Path Traversal + +## 🟠 High +- Валидация input +- Rate limiting + +## 🟡 Medium +- Обработка ошибок +- Null safety + +--- + +# 📌 Заключение + +Приложение демонстрирует типичные уязвимости: + +- отсутствие валидации +- доверие к пользовательскому вводу +- отсутствие базовых security-практик + +📉 Уровень безопасности: **низкий** + +📈 Требуется внедрение: +- secure coding practices +- input validation +- authentication & authorization 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 — Анализ и тестирование безопасности веб-приложения ## Цель diff --git a/pentest.sh b/pentest.sh new file mode 100644 index 0000000..cf67086 --- /dev/null +++ b/pentest.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +BASE="http://localhost:7778" + +ok() { echo "✅ $1"; } +fail() { echo "❌ $1"; } + +register() { + curl -s -X POST "$BASE/register?userId=test&userName=TestUser" > /dev/null +} + +echo "=== SETUP ===" +register + +echo "=== PATH TRAVERSAL ===" +code=$(curl -s -o /dev/null -w "%{http_code}" \ +"$BASE/exportReport?userId=test&filename=../../../../tmp/hacked.txt") + +[[ "$code" != "200" ]] && ok "Path traversal blocked ($code)" || fail "Path traversal NOT blocked" + +echo "=== SSRF localhost ===" +code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ +"$BASE/notify?userId=test&callbackUrl=http://localhost:7000") + +[[ "$code" -ge 400 ]] && ok "SSRF localhost blocked ($code)" || fail "SSRF NOT blocked" + +echo "=== SSRF metadata ===" +code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ +"$BASE/notify?userId=test&callbackUrl=http://169.254.169.254") + +[[ "$code" -ge 400 ]] && ok "SSRF metadata blocked ($code)" || fail "SSRF NOT blocked" + +echo "=== LARGE INPUT ===" +LONG=$(python3 -c "print('A'*10000)") +code=$(curl -s -o /dev/null -w "%{http_code}" \ +"$BASE/totalActivity?userId=$LONG") + +[[ "$code" == "400" ]] && ok "Large input rejected" || fail "No input limit ($code)" + +echo "=== RATE LIMIT ===" +rate_limited=0 + +for i in {1..50}; do + code=$(curl -s -o /dev/null -w "%{http_code}" \ + "$BASE/totalActivity?userId=test") + + if [[ "$code" == "429" ]]; then + rate_limited=1 + break + fi +done + +[[ "$rate_limited" == "1" ]] && ok "Rate limit works" || fail "NO rate limit" + +echo "=== INVALID SESSION ===" +code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ +"$BASE/recordSession?userId=test&loginTime=2025-01-01T10:00:00&logoutTime=2025-01-01T09:00:00") + +[[ "$code" != "200" ]] && ok "Invalid session rejected" || fail "Bad session accepted" + +echo "=== NO AUTH ===" +code=$(curl -s -o /dev/null -w "%{http_code}" \ +"$BASE/totalActivity?userId=test") + +[[ "$code" == "401" ]] && ok "Auth required" || fail "Auth missing" + +echo "=== SQLi TEST ===" +code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ +"$BASE/register?userId=%27%20OR%201%3D1%20--&userName=test") + +[[ "$code" == "400" ]] && ok "Input filtered" || fail "SQLi not blocked" \ No newline at end of file diff --git a/src/test/java/ru/itmo/testing/lab4/pentest/SecurityPentestTest.java b/src/test/java/ru/itmo/testing/lab4/pentest/SecurityPentestTest.java new file mode 100644 index 0000000..96529f8 --- /dev/null +++ b/src/test/java/ru/itmo/testing/lab4/pentest/SecurityPentestTest.java @@ -0,0 +1,214 @@ +package ru.itmo.testing.lab4.pentest; + +import io.javalin.Javalin; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class SecurityPentestTest { + + 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(); + } + + // ========================================================================= + // HELPER + // ========================================================================= + + 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()); + } + + @BeforeEach + void setupUser() throws Exception { + send("POST", "/register?userId=test&userName=TestUser"); + } + + // ========================================================================= + // PATH TRAVERSAL — /exportReport + // ========================================================================= + + @Test + @DisplayName("[SECURITY] Path Traversal должен быть заблокирован") + void pathTraversalShouldBeBlocked() throws Exception { + HttpResponse response = send("GET", + "/exportReport?userId=test&filename=../../../../tmp/hacked.txt"); + + assertNotEquals(200, response.statusCode(), + "Path Traversal не заблокирован!"); + } + + + // ========================================================================= + // SSRF — /notify + // ========================================================================= + + @Test + @DisplayName("[SECURITY] SSRF на localhost должен блокироваться") + void ssrfLocalhostShouldBeBlocked() throws Exception { + HttpResponse response = send("POST", + "/notify?userId=test&callbackUrl=http://localhost:7000"); + + assertTrue(response.statusCode() >= 400, + "SSRF на localhost разрешён!"); + } + + @Test + @DisplayName("[SECURITY] SSRF на metadata IP должен блокироваться") + void ssrfMetadataShouldBeBlocked() throws Exception { + HttpResponse response = send("POST", + "/notify?userId=test&callbackUrl=http://169.254.169.254"); + + assertTrue(response.statusCode() >= 400, + "SSRF на внутренние IP разрешён!"); + } + + // ========================================================================= + // DoS — рекурсия / heavy input + // ========================================================================= + + @Test + @DisplayName("[SECURITY] Слишком длинный input должен отклоняться") + void largeInputShouldBeRejected() throws Exception { + String largeUserId = "A".repeat(10000); + + HttpResponse response = send("GET", + "/totalActivity?userId=" + largeUserId); + + assertEquals(400, response.statusCode(), + "Нет ограничения на размер входных данных!"); + } + + @Test + @DisplayName("[SECURITY] Flood запросов должен ограничиваться") + void requestFloodShouldBeLimited() throws Exception { + boolean rateLimited = false; + + for (int i = 0; i < 50; i++) { + HttpResponse response = send("GET", + "/totalActivity?userId=test"); + + if (response.statusCode() == 429) { + rateLimited = true; + break; + } + } + + assertTrue(rateLimited, + "Отсутствует rate limiting!"); + } + + // ========================================================================= + // BUSINESS LOGIC — invalid session + // ========================================================================= + + @Test + @DisplayName("[SECURITY] logoutTime < loginTime должно отклоняться") + void invalidSessionTimeShouldFail() throws Exception { + HttpResponse response = send("POST", + "/recordSession?userId=test" + + "&loginTime=2025-01-01T10:00:00" + + "&logoutTime=2025-01-01T09:00:00"); + + assertNotEquals(200, response.statusCode(), + "Некорректная сессия принимается!"); + } + + // ========================================================================= + // NULL POINTER / ERROR HANDLING + // ========================================================================= + + @Test + @DisplayName("[SECURITY] Отсутствие сессий не должно ломать сервер") + void nullPointerShouldBeHandled() throws Exception { + HttpResponse response = send("GET", + "/monthlyActivity?userId=test&month=2025-01"); + + assertNotEquals(500, response.statusCode(), + "NullPointerException приводит к 500!"); + } + + // ========================================================================= + // INFORMATION DISCLOSURE + // ========================================================================= + + @Test + @DisplayName("[SECURITY] Ошибки не должны раскрывать детали") + void errorMessagesShouldBeSanitized() throws Exception { + HttpResponse response = send("POST", + "/recordSession?userId=test&loginTime=invalid&logoutTime=invalid"); + + assertFalse(response.body().contains("Exception"), + "Раскрываются внутренние детали!"); + } + + // ========================================================================= + // MISSING AUTH + // ========================================================================= + + @Test + @DisplayName("[SECURITY] Доступ без аутентификации должен быть запрещён") + void noAuthShouldBeRejected() throws Exception { + HttpResponse response = send("GET", + "/totalActivity?userId=test"); + + assertEquals(401, response.statusCode(), + "Нет проверки аутентификации!"); + } + + // ========================================================================= + // INPUT VALIDATION + // ========================================================================= + + @Test + @DisplayName("[SECURITY] Спецсимволы должны фильтроваться") + void specialCharsShouldBeRejected() throws Exception { + String payload = "' OR 1=1 --"; + + HttpResponse response = send("POST", + "/register?userId=" + enc(payload) + "&userName=test"); + + assertEquals(400, response.statusCode(), + "Нет валидации входных данных!"); + } +}