JavaRush /Курсы /Spring Security /Security headers и reverse proxy

Security headers и reverse proxy

Spring Security
28 уровень , 1 лекция
Открыта

1. Транспорт и заголовки: реальность продакшена

Если до этого момента Spring Security казался вам чем-то вроде “умного турникета на входе в контроллеры”, то сегодня мы добавим второй слой реальности: турникет может быть идеальным, но двери рядом всё равно должны закрываться нормально. Любая аутентификация и авторизация в нашем проекте так или иначе ездит по HTTP: Authorization для Basic, cookie для session, Authorization: Bearer ... для JWT. Значит, транспорт и ответные заголовки — это не декоративные украшения, а условия, при которых ваша security-модель вообще имеет смысл.

Представьте, что вы сделали всё правильно: роли USER/EDITOR/ADMIN, @PreAuthorize, owner checks, JSON ошибки. В тестах всё зелёное. И тут проект выкатывают за reverse proxy, который терминирует TLS. Приложение внезапно “думает”, что запросы приходят по обычному HTTP. В результате могут всплыть странности: где-то редирект идёт на http://..., cookie внезапно не становится Secure, HSTS не работает, а некоторые клиенты начинают вести себя так, будто вы “сломали логин”. И да — выглядеть это будет так, будто Spring Security “капризничает”, хотя виноват контекст запроса.

В этой лекции мы будем держать в голове простую мысль: Spring Security защищает приложения в условиях HTTP-реальности, а HTTP-реальность в проде почти всегда включает HTTPS и reverse proxy. Поэтому нам нужны три кусочка знания, которые дают эксплуатационный baseline: что такое security headers (и почему их нельзя “вырубать пачкой”), зачем HTTPS даже для API, и как Spring Boot/Spring Security узнают, что “снаружи было HTTPS”, если внутри до приложения дошёл HTTP.

Даже если секреты и TTL уже вынесены из кода, security всё ещё зависит от того, как запрос реально доезжает до приложения. Поэтому дальше смотрим на внешний HTTP-контур: какие заголовки мы отдаём, когда приложение считает запрос secure и что меняется, когда между клиентом и приложением появляется proxy.

2. Security headers: базовый режим Spring Security

Когда вы впервые видите в конфиге http.headers(...), рука новичка иногда тянется к героическому решению: “а давайте отключим, вдруг мешает”. Это примерно как чинить скрип двери тем, что выносите дверь из квартиры. Работает, но потом удивляетесь сквознякам. Security headers — это набор HTTP response headers, которые подсказывают клиенту (особенно браузеру), как безопаснее себя вести: что кэшировать, можно ли встраивать страницу в iframe, можно ли “угадывать” тип контента и так далее.

У Spring Security здесь очень взрослый подход: безопасное поведение включается по умолчанию. И это не “чтобы усложнить жизнь”, а чтобы ваш проект не зависел от дисциплины “мы потом обязательно включим”. В большинстве курсов по security (и в реальной жизни) беда приходит не от того, что кто-то сделал сложную уязвимость, а от того, что кто-то оставил дырку “по умолчанию”. Заголовки — ровно такая история: вы их редко “ощущаете”, пока не случается проблема.

В нашем проекте Secure Content Platform API эти заголовки особенно важны в тех местах, где клиентом потенциально является браузер. Даже если мы сейчас тестируем через Postman, реальный мир часто включает Swagger UI, возможную интеграцию со SPA, админские инструменты, страницу логина в stateful-ветке. Поэтому базовый принцип такой: сначала сохраняем дефолты, а точечные изменения делаем только когда понимаем причину.

Минимальный, правильный baseline прямо в SecurityFilterChain выглядит так:

package com.example.securecontent.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // Оставляем дефолтные security headers Spring Security включёнными
        // (это безопасный baseline, который потом можно тюнить точечно).
        http.headers(Customizer.withDefaults());

        // Важно: возвращаем собранную цепочку фильтров, иначе конфиг "не применится".
        return http.build();
    }
}

Смысл этого фрагмента не в том, что “мы что-то включили”. Смысл в том, что мы явно фиксируем: заголовки — часть модели безопасности, и мы осознанно оставляем безопасный baseline включённым.

3. Ключевые security headers: практичный обзор

Очень легко утонуть в заголовках, потому что их много, и каждый звучит как заклинание из древнего манускрипта: X-Content-Type-Options, Strict-Transport-Security, Content-Security-Policy. Но нам, как backend-разработчикам, нужен практичный уровень: понимать, какие заголовки “про что”, почему Spring Security их добавляет, и почему их массовое отключение почти всегда ухудшает ситуацию, даже если “ничего не сломалось прямо сейчас”.

Важно также помнить, что заголовки бывают разных типов по смыслу. Часть заголовков защищает от конкретных браузерных атак (например, clickjacking), часть — от “странного поведения клиента” (например, sniffing типа контента), часть — дисциплинирует кэширование. API-клиенты вроде curl в большинстве случаев на это реагируют нейтрально, но браузер — нет. А браузер в мире Java backend встречается чаще, чем кажется: даже если у вас “чистый REST”, Swagger UI или простая админка уже превращают ваш сервис в web-приложение.

Ниже — компактная таблица для ориентировки (не как список “выучить”, а как карта местности):

Заголовок Идея простыми словами Чем может закончиться отсутствие
X-Content-Type-Options: nosniff “Не угадывай тип контента сам, доверяй Content-Type Браузер может попытаться интерпретировать ответ “не тем способом”, что иногда превращается в XSS-like проблемы
X-Frame-Options или CSP frame-ancestors “Не встраивай страницу в чужой iframe” Clickjacking: пользователь кликает “по кнопке”, не понимая, что она подложена
Cache-Control, Pragma, Expires “Не кэшируй чувствительные ответы” Приватные данные могут оказаться в кэше браузера/прокси “на потом”
Strict-Transport-Security (HSTS) “Если ты браузер, не ходи сюда по HTTP вообще” Пользователь может случайно попасть на HTTP-версию и словить downgrade-атаку; HSTS снижает риск
Content-Security-Policy (CSP) “Какие скрипты/стили/ресурсы вообще разрешены” Уменьшает последствия XSS, но требует аккуратности; чаще важен для UI-страниц, чем для JSON

Обратите внимание на слово “браузер” во многих описаниях. Это нормально. Backend-разработчику важно не путать: CORS — это тоже браузерная история, CSRF — тоже. Security headers попадают ровно в эту же категорию: “клиентский” слой безопасности, но управляется сервером.

И ещё одна практическая мысль: заголовки сами по себе не заменяют @PreAuthorize, роли, authorities и owner checks. Они не решают задачу “кто может публиковать статью”, но они помогают сделать так, чтобы клиент не стал вашим непредсказуемым соучастником в атаке.

4. HTTPS для JSON API: что он даёт

Про HTTPS новичок часто думает так: “ну у нас же токены и пароли не в URL, значит всё ок”. Это опасная упрощёнка. HTTPS (TLS) — это не про то, где лежит токен, а про то, может ли кто-то по пути прочитать или подменить ваш HTTP-трафик. Если вы передаёте Authorization: Basic ..., то без HTTPS вы буквально отправляете логин и пароль в формате, который легко восстановить. Если вы передаёте cookie с JSESSIONID, то без HTTPS вы отдаёте идентификатор сессии. Если вы передаёте Authorization: Bearer ..., то без HTTPS вы отдаёте “ключ от квартиры” в открытом виде.

Даже в случае JWT, где “внутри подпись и всё такое”, bearer token остаётся bearer: кто держит токен, тот и пользователь. Никакая подпись не спасает, если токен утащили из сети. Поэтому транспортная безопасность — это фундамент для всех трёх моделей, которые мы сравнивали по курсу: session, basic, JWT.

В приложении можно добавить дополнительную защиту: явно сказать, что мы ожидаем только HTTPS. Для учебного baseline это полезно хотя бы как “страховка от случайного запуска в неправильном окружении”. В Spring Security это выглядит так:

import org.springframework.security.config.annotation.web.builders.HttpSecurity;

// Требуем HTTPS для всех запросов.
// Важно: за reverse proxy это будет работать корректно только при настроенных forwarded headers.
http.requiresChannel(channel -> channel
        .anyRequest().requiresSecure()
);

Смысл этого куска кода простой: если запрос пришёл по HTTP, приложение будет пытаться перевести его на HTTPS (обычно через redirect). И тут появляется важное “но”, из-за которого этот фрагмент в реальных проектах всегда рассматривают вместе с reverse proxy: если TLS заканчивается на прокси, а до приложения доходит HTTP, то для приложения все запросы выглядят как HTTP, и вы можете случайно получить вечный редирект, странное поведение или ситуацию “у нас всё стало недоступно”.

Поэтому правило такое: requiresSecure() — хороший инструмент, но он работает корректно только если приложение правильно понимает, что внешний запрос был HTTPS. И именно тут мы подходим к reverse proxy и forwarded headers.

5. Reverse proxy в проде: роль и эффекты

Если вы до сих пор запускали приложение как “прямо на 8080 порту” и ходили к нему напрямую, то прод обычно выглядит иначе. Перед вашим Spring Boot приложением стоит reverse proxy: Nginx, Envoy, HAProxy, ingress-контроллер в Kubernetes или даже managed балансировщик в облаке. Он принимает внешние запросы, и уже затем прокидывает их внутрь, к вашему приложению.

На человеческом уровне reverse proxy делает несколько вещей сразу. Он часто заканчивает TLS (то есть клиент подключается по HTTPS к прокси, а прокси ходит к приложению по HTTP внутри сети), умеет балансировать нагрузку между несколькими экземплярами сервиса, может сжимать ответы, ограничивать доступ по IP, выставлять rate limits. И да — иногда он “подкручивает” заголовки так, что ваше приложение начинает видеть другой контекст запроса.

Вот типичная схема, на которой обычно и начинается “HTTPS awareness”:

flowchart LR
    %% Клиент всегда общается с внешним контуром (прокси), а приложение видит уже "внутренний" запрос.
    Client["Клиент (браузер/SPA/Postman)"]
    Proxy["Reverse proxy / LB
TLS terminate"] App["Spring Boot приложение
Secure Content Platform API"] Client -- "HTTPS" --> Proxy Proxy -- "HTTP внутри сети + Forwarded headers" --> App

Для вашего приложения здесь ключевой момент: оно видит входящий запрос как HTTP. Но снаружи клиент общался по HTTPS. Если приложение не умеет восстановить “наружную реальность”, появляются эффектные баги: редиректы на http://, неправильные ссылки в выдаче, невозможность правильно выставить Secure cookie, HSTS не включается, а requiresSecure() превращается в “сам себя зациклил”.

И нет, это не “чисто DevOps”. Это та точка, где backend-разработчик должен хотя бы понимать термины, иначе вы будете чинить симптомы в коде контроллеров вместо настройки контекста запроса.

6. Forwarded headers: HTTPS awareness

Самая частая причина “странного поведения за прокси” — приложение не понимает исходную схему запроса. Для этого reverse proxy добавляет специальные заголовки. В учебной реальности вы чаще встретите X-Forwarded-Proto: https, X-Forwarded-Host, X-Forwarded-Port, а иногда стандартный Forwarded: proto=https;host=... Эти заголовки несут одну идею: “внешний запрос был таким-то, просто я, прокси, дошёл до тебя по-другому”.

Spring Boot умеет учитывать эти заголовки, но это нужно включить и важно включить правильно. В рамках нашего baseline самый простой и понятный вариант — настроить стратегию обработки forwarded headers на уровне сервера приложения:

server:
  # Говорим Spring, что нужно учитывать заголовки Forwarded / X-Forwarded-* от reverse proxy.
  # Это нужно, чтобы корректно восстанавливались scheme/host/port (например, HTTPS "снаружи").
  forward-headers-strategy: framework

Эта настройка говорит примерно следующее: “используй возможности Spring Framework, чтобы обработать forwarded headers и правильно восстановить схему/хост/порт запроса”. После этого многие вещи начинают работать “как ожидается”: request.isSecure() становится true там, где внешний запрос действительно был HTTPS, правильнее формируются редиректы, корректнее работают решения, которые завязаны на схему.

Здесь есть важная security-оговорка, которую стоит проговорить именно на fundamentals-уровне. Forwarded headers — это заголовки, а заголовки может подделать клиент. Если ваше приложение бездумно доверяет forwarded headers от кого угодно, злоумышленник может попробовать “сыграть прокси” и подсунуть X-Forwarded-Proto: https, чтобы приложение считало запрос secure. Поэтому в реальных системах доверие forwarded headers обычно ограничивают на уровне инфраструктуры: прокси должен перезаписывать такие заголовки сам, а прямые запросы к приложению извне — быть закрыты. Мы не уходим в DevOps-настройку, но вы должны понимать, почему это важно: “HTTPS awareness” — это не вера в заголовки, а корректно выстроенная цепочка “клиент → прокси → приложение”.

Если после включения forwarded headers вы используете requiresSecure(), то логика становится адекватной: приложение видит secure-запрос и не пытается бесконечно редиректить само себя. Плюс вы получаете более корректное поведение заголовков, завязанных на HTTPS, например HSTS (он имеет смысл только когда браузер уже общается с вами по HTTPS).

7. Типичные ошибки при работе с security headers, HTTPS и reverse proxy

В этой теме ошибки особенно коварны тем, что выглядят как “Spring Security опять чудит”, хотя на деле это просто нестыковка контекста запроса и ваших ожиданий. И чем ближе вы к продакшену, тем чаще такие ошибки появляются “внезапно”, потому что локально вы работали без прокси и без HTTPS.

Ошибка №1: глобальное headers().disable() “чтобы не мешало”.
Это классика. Заголовки редко мешают вашему API-клиенту, но часто защищают браузерного клиента от неприятных сценариев. Отключая их, вы обычно не решаете проблему, а делаете проект менее защищённым и усложняете будущий аудит. Если что-то “мешает”, лучше точечно настроить конкретный header, а не вырубать всё.

Ошибка №2: думать, что “у нас JWT, значит HTTPS не обязательно”.
JWT — не бронежилет, а формат токена. Если bearer token ушёл по HTTP, он ушёл в открытом виде. Это тот случай, когда криптография внутри токена не спасает от утечки по пути. HTTPS нужен и session, и basic, и JWT — просто по разным причинам, но итог один: без транспорта вся модель трещит.

Ошибка №3: включить requiresSecure(), но забыть про reverse proxy и forwarded headers.
На локалхосте это может выглядеть “нормально”, а в проде за прокси начинается редирект-луп или полный отказ доступа. Причина почти всегда одна: приложение видит входящий запрос как HTTP и честно пытается “перевести его на HTTPS”, не понимая, что HTTPS уже был снаружи.

Ошибка №4: доверять forwarded headers от кого угодно.
Если приложение доступно напрямую из интернета, клиент может подделать X-Forwarded-Proto и другие заголовки. Поэтому “HTTPS awareness” — это не “верить заголовкам”, а “быть за прокси, который эти заголовки контролирует”. На уровне курса достаточно запомнить: forwarded headers — вещь полезная, но доверие к ним должно быть осознанным.

Ошибка №5: пытаться чинить транспортные проблемы в контроллерах.
Иногда видишь код, где люди вручную переписывают ссылки, добавляют странные Location заголовки или “подклеивают https://” строкой. Это обычно признак того, что приложение не понимает исходную схему запроса. Такие вещи должны решаться на уровне настройки forwarded headers и security-конфигурации, а не внутри бизнес-логики.

1
Задача
Spring Security, 28 уровень, 1 лекция
Недоступна
Базовые security headers на публичном endpoint
Базовые security headers на публичном endpoint
1
Задача
Spring Security, 28 уровень, 1 лекция
Недоступна
Учет forwarded headers от reverse proxy
Учет forwarded headers от reverse proxy
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ