Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 — Анализ и тестирование безопасности веб-приложения

## Цель
Expand Down
389 changes: 389 additions & 0 deletions REPORT.md

Large diffs are not rendered by default.

1,747 changes: 1,747 additions & 0 deletions semgrep-report.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
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-209 — Information Disclosure via Error Messages
* =============================================================================
*
* Компонент: POST /recordSession, GET /exportReport
* 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
*
* ОПИСАНИЕ:
* Эндпоинты возвращают e.getMessage() в HTTP-ответе при ошибках.
* Это раскрывает внутренние детали реализации: имена классов,
* формат данных, пути файловой системы.
*
* ВЕКТОР АТАКИ:
* 1. POST /recordSession?userId=user&loginTime=INVALID&logoutTime=INVALID
* → ответ содержит "Text 'INVALID' could not be parsed at index 0"
*
* ВЛИЯНИЕ:
* - Раскрытие технологического стека (Java, используемые библиотеки)
* - Помощь атакующему в планировании дальнейших атак
*
* МЕРЫ ЗАЩИТЫ:
* - Возвращать обобщённые сообщения клиенту
* - Логировать детали на стороне сервера
* =============================================================================
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class InfoDisclosurePentestTest {

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();
try {
send("POST", "/register?userId=info_user&userName=Test");
} catch (Exception e) {
throw new RuntimeException(e);
}
}

@AfterAll
static void stopServer() {
app.stop();
}

// -------------------------------------------------------------------------
// EXPLOIT: утечка информации через /recordSession
// -------------------------------------------------------------------------

@Test
@Order(1)
@DisplayName("[EXPLOIT] Некорректный loginTime раскрывает детали парсинга")
void invalidLoginTimeLeaksParsingDetails() throws Exception {
HttpResponse<String> response = send("POST",
"/recordSession?userId=info_user&loginTime=INVALID&logoutTime=2025-01-01T12:00");

assertEquals(400, response.statusCode());

String body = response.body();
// УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА: сообщение об ошибке содержит
// детали реализации парсинга (java.time.format)
assertTrue(body.contains("Invalid data:"),
"Ответ содержит префикс ошибки");
assertTrue(body.contains("could not be parsed") || body.contains("Text"),
"FAIL (ожидаемое поведение): ответ НЕ должен содержать детали парсинга. " +
"Должно быть общее сообщение типа 'Invalid date format'");
}

@Test
@Order(2)
@DisplayName("[EXPLOIT] Сообщение об ошибке содержит имя входного значения")
void errorMessageContainsInputValue() throws Exception {
String maliciousInput = "PROBE_VALUE_12345";
HttpResponse<String> response = send("POST",
"/recordSession?userId=info_user&loginTime=" + maliciousInput + "&logoutTime=2025-01-01T12:00");

String body = response.body();
// Сообщение об ошибке отражает пользовательский ввод —
// это может быть использовано для fingerprinting
assertTrue(body.contains(maliciousInput),
"Ответ отражает пользовательский ввод в сообщении об ошибке");
}

@Test
@Order(3)
@DisplayName("[EXPLOIT] Специальные символы в loginTime для извлечения информации")
void specialCharsInLoginTimeForInfoExtraction() throws Exception {
HttpResponse<String> response = send("POST",
"/recordSession?userId=info_user&loginTime=" +
enc("2025-01-01T00:00:00.000000000+99:99") +
"&logoutTime=2025-01-01T12:00");

assertEquals(400, response.statusCode());
String body = response.body();
// Проверяем, раскрывает ли ответ внутреннюю структуру парсера
assertNotNull(body, "Ответ не должен быть пустым");
}

// -------------------------------------------------------------------------
// EXPLOIT: утечка информации через /monthlyActivity
// -------------------------------------------------------------------------

@Test
@Order(4)
@DisplayName("[EXPLOIT] Некорректный month раскрывает детали парсинга YearMonth")
void invalidMonthLeaksYearMonthDetails() throws Exception {
HttpResponse<String> response = send("GET",
"/monthlyActivity?userId=info_user&month=INVALID");

assertEquals(400, response.statusCode());
String body = response.body();
assertTrue(body.contains("Invalid data:"),
"Ответ раскрывает, что используется Java-парсинг для YearMonth");
}

// -------------------------------------------------------------------------
// BOUNDARY: корректные данные работают правильно
// -------------------------------------------------------------------------

@Test
@Order(5)
@DisplayName("[BOUNDARY] Корректные данные обрабатываются без ошибок")
void validDataProcessedCorrectly() throws Exception {
HttpResponse<String> response = send("POST",
"/recordSession?userId=info_user&loginTime=2025-01-01T10:00&logoutTime=2025-01-01T11:00");

assertEquals(200, response.statusCode());
assertTrue(response.body().contains("Session recorded"));
}

@Test
@Order(6)
@DisplayName("[BOUNDARY] Отсутствие параметров возвращает 400 без утечки")
void missingParamsReturns400WithoutLeak() throws Exception {
HttpResponse<String> response = send("POST", "/recordSession");

assertEquals(400, response.statusCode());
assertEquals("Missing parameters", response.body(),
"Сообщение об отсутствующих параметрах не должно раскрывать внутренности");
}

// -------------------------------------------------------------------------
// Вспомогательные методы
// -------------------------------------------------------------------------

private static String enc(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}

private static HttpResponse<String> 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());
}
}
205 changes: 205 additions & 0 deletions src/test/java/ru/itmo/testing/lab4/pentest/MissingAuthPentestTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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-306 — Missing Authentication for Critical Function
* =============================================================================
*
* Компонент: Все эндпоинты
* 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. GET /totalActivity?userId=admin — просмотр данных без авторизации
* 2. POST /recordSession?userId=victim&... — запись данных от чужого имени
*
* ВЛИЯНИЕ:
* - Просмотр данных любого пользователя
* - Модификация данных от имени других пользователей
* - Полное отсутствие разграничения доступа
*
* МЕРЫ ЗАЩИТЫ:
* - Добавить аутентификацию (JWT, API-ключи)
* - Реализовать авторизацию на уровне ресурсов
* =============================================================================
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class MissingAuthPentestTest {

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();
}

// -------------------------------------------------------------------------
// EXPLOIT: доступ к данным без аутентификации
// -------------------------------------------------------------------------

@Test
@Order(1)
@DisplayName("[EXPLOIT] Регистрация пользователя без аутентификации")
void registerWithoutAuth() throws Exception {
HttpResponse<String> response = send("POST",
"/register?userId=auth_victim&userName=VictimUser");

// УЯЗВИМОСТЬ: любой может зарегистрировать пользователя
assertEquals(200, response.statusCode(),
"FAIL (ожидаемое поведение): регистрация без аутентификации НЕ должна быть разрешена");
assertTrue(response.body().contains("User registered: true"));
}

@Test
@Order(2)
@DisplayName("[EXPLOIT] Просмотр профиля другого пользователя без аутентификации")
void viewOtherUserProfileWithoutAuth() throws Exception {
HttpResponse<String> response = send("GET",
"/userProfile?userId=auth_victim");

// УЯЗВИМОСТЬ: доступ к профилю любого пользователя без авторизации
assertEquals(200, response.statusCode(),
"FAIL (ожидаемое поведение): доступ к чужому профилю без авторизации НЕ должен быть разрешён");
assertTrue(response.body().contains("VictimUser"));
}

@Test
@Order(3)
@DisplayName("[EXPLOIT] Запись сессии от имени другого пользователя")
void recordSessionForOtherUser() throws Exception {
HttpResponse<String> response = send("POST",
"/recordSession?userId=auth_victim&loginTime=2025-01-01T00:00&logoutTime=2025-01-01T23:59");

// УЯЗВИМОСТЬ: можно записать произвольную сессию любому пользователю
assertEquals(200, response.statusCode(),
"FAIL (ожидаемое поведение): запись сессии для чужого userId НЕ должна быть разрешена");
assertTrue(response.body().contains("Session recorded"));
}

@Test
@Order(4)
@DisplayName("[EXPLOIT] Просмотр активности другого пользователя без аутентификации")
void viewOtherUserActivityWithoutAuth() throws Exception {
HttpResponse<String> response = send("GET",
"/totalActivity?userId=auth_victim");

// УЯЗВИМОСТЬ: доступ к данным активности без авторизации
assertEquals(200, response.statusCode(),
"FAIL (ожидаемое поведение): доступ к чужой активности без авторизации НЕ должен быть разрешён");
assertTrue(response.body().contains("Total activity"));
}

@Test
@Order(5)
@DisplayName("[EXPLOIT] Экспорт отчёта другого пользователя без аутентификации")
void exportOtherUserReportWithoutAuth() throws Exception {
HttpResponse<String> response = send("GET",
"/exportReport?userId=auth_victim&filename=stolen.txt");

// УЯЗВИМОСТЬ: экспорт данных любого пользователя без авторизации
assertEquals(200, response.statusCode(),
"FAIL (ожидаемое поведение): экспорт чужих данных без авторизации НЕ должен быть разрешён");
}

// -------------------------------------------------------------------------
// EXPLOIT: перебор userId (enumeration)
// -------------------------------------------------------------------------

@Test
@Order(6)
@DisplayName("[EXPLOIT] Перебор userId — различные ответы для существующих и несуществующих")
void userIdEnumeration() throws Exception {
// Сначала регистрируем пользователя
send("POST", "/register?userId=enum_user&userName=Test");

HttpResponse<String> existingUser = send("GET", "/userProfile?userId=enum_user");
HttpResponse<String> nonExistingUser = send("GET", "/userProfile?userId=nonexistent_12345");

// УЯЗВИМОСТЬ: разные статус-коды позволяют перебирать userId
assertNotEquals(existingUser.statusCode(), nonExistingUser.statusCode(),
"Разные статус-коды для существующих и несуществующих пользователей " +
"позволяют перебирать userId");
}

// -------------------------------------------------------------------------
// BOUNDARY
// -------------------------------------------------------------------------

@Test
@Order(7)
@DisplayName("[EXPLOIT] Эндпоинт /inactiveUsers доступен без аутентификации")
void inactiveUsersEndpointAccessibleWithoutAuth() throws Exception {
HttpResponse<String> response = send("GET", "/inactiveUsers?days=1");

// УЯЗВИМОСТЬ: сервер не требует аутентификацию — не возвращает 401/403
// (возвращает 500 из-за отсутствия Jackson для ctx.json(), но это отдельный баг)
assertNotEquals(401, response.statusCode(),
"Сервер не возвращает 401 — аутентификация отсутствует");
assertNotEquals(403, response.statusCode(),
"Сервер не возвращает 403 — авторизация отсутствует");
}

@Test
@Order(8)
@DisplayName("[EXPLOIT] /monthlyActivity доступен для чужого userId без авторизации")
void monthlyActivityAccessibleWithoutAuth() throws Exception {
send("POST", "/register?userId=monthly_victim&userName=MonthlyVictim");
send("POST", "/recordSession?userId=monthly_victim&loginTime=2025-01-15T10:00&logoutTime=2025-01-15T11:00");

HttpResponse<String> response = send("GET",
"/monthlyActivity?userId=monthly_victim&month=2025-01");

// УЯЗВИМОСТЬ: сервер не требует аутентификацию для доступа к чужим данным
// (возвращает 400 из-за бага в сервисе, но не 401/403)
assertNotEquals(401, response.statusCode(),
"Сервер не возвращает 401 — аутентификация отсутствует");
assertNotEquals(403, response.statusCode(),
"Сервер не возвращает 403 — авторизация отсутствует");
}

// -------------------------------------------------------------------------
// Вспомогательные методы
// -------------------------------------------------------------------------

private static String enc(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}

private static HttpResponse<String> 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());
}
}
Loading