Bug2Lab · Web Security · CSRF в чате поддержки

CSRF: отправка сообщения от имени пользователя без его ведома

Веб-чат (виджет поддержки) принимает POST-запросы на отправку сообщения и не проверяет, что запрос реально пришёл из нашего фронтенда. В результате злоумышленник может заставить браузер жертвы отправить сообщение в чат от её имени — просто заставив её открыть страницу. Это позволяет подделывать коммуникацию и эскалировать конфликты.

1
Как выглядит атака
Жертва (авторизованный пользователь чата) просто открывает ссылку/страницу, которую контролирует злоумышленник. Страница автоматически делает POST в чат. Для сервера это выглядит как будто жертва сама что-то написала оператору.
Малicious page (внешний сайт злоумышленника)
<form id="csrf" action="https://chat.example.internal/api/v1/widget/messages" method="POST"> <input type="hidden" name="message[content]" value="мне всё равно, отмените заказ немедленно" /> </form> <script> document.getElementById('csrf').submit(); </script>
2
Почему это стало возможным
Сервер доверяет любому POST, если у браузера есть сессионные куки. Он не проверяет:
• CSRF-токен,
• заголовок Origin/Referer,
• атрибуты безопасности cookie.
Проблемный обработчик (упрощённо)
10@PostMapping("/api/v1/widget/messages")
11public ResponseEntity<?> sendMessage(@RequestParam("message[content]") String text,
12 @CookieValue("session") String sessionId){
13 // ❌ здесь считается, что раз есть кука sessionId — то это "наш пользователь"
14 chatService.postMessage(sessionId, text);
15 return ResponseEntity.status(HttpStatus.CREATED).build();
16}
3
Как это чинится правильно
1) Ввести CSRF-токен: фронтенд получает уникальный токен и отправляет его в каждом POST.
2) Поставить cookie с атрибутом SameSite=Lax или лучше SameSite=Strict, чтобы браузер не пересылал куку в кросс-доменных запросах формой.
3) Проверять Origin/Referer: запрос на отправку сообщения должен приходить только с нашего домена.
Исправленный паттерн
10@PostMapping("/api/v1/widget/messages")
11public ResponseEntity<?> sendMessage(
12 @RequestParam("message[content]") String text,
13 @RequestHeader("X-CSRF-Token") String csrf,
14 @RequestHeader("Origin") String origin,
15 @CookieValue("session") String sessionId){
16 if(!csrfService.isValid(sessionId, csrf)) {
17 return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); // ✅ нет токена → нет запроса
18 }
19 if(!origin.equals("https://chat.example.internal")){
20 return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); // ✅ не наш фронтенд
21 }
22 chatService.postMessage(sessionId, text);
23 return ResponseEntity.status(HttpStatus.CREATED).build();
24}

bug2regress Регрессионный тест (стоп-кран)

Мы автоматизируем защиту: пайплайн сам пытается отправить сообщение в чат, ИМЕЯ только куку сессии (как браузер жертвы), но без валидного CSRF-токена и с поддельным Origin. Если сервер всё равно принял сообщение → билд стопаем.

#!/bin/bash
# csrf-chat-check.sh

# имитируем: злоумышленник заставил браузер жертвы отправить POST без CSRF
STATUS=$(curl -s -o /tmp/resp.txt -w "%{http_code}" \
  -X POST "https://staging.example.internal/api/v1/widget/messages" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Cookie: session=VICTIM_SESSION_COOKIE" \
  -H "Origin: https://evil.attacker.site" \
  --data "message[content]=test_csrf_message_from_pipeline")

if [ "$STATUS" = "201" ] || [ "$STATUS" = "200" ]; then
  echo "[BLOCK] Чат принял сообщение без CSRF и с чужим Origin ($STATUS)"
  exit 1
fi

echo "[OK] Чат отклонил запрос без валидного CSRF ($STATUS)"
exit 0

Важно: мы не надеемся на «ребята не забудут включить защиту». Мы делаем так, что билд физически не может уехать в прод, если CSRF снова сломали.

Проверка понимания Что здесь самое опасное?

Стандарт: любой state-changing POST в вебе должен проверять, что его инициировал наш фронтенд, а не случайно открытая вкладка.

Теория (по желанию) Почему CSRF — это не просто "писулька в чат", а юрриск