ESPN'We just really have that pride': Inside Ballhalla, a basketball heavenPunchEFCC busts illegal lithium plants in Ogun, arrests 14 ChineseESPN DeportesBalón de Oro 2026: 20 expertos de ESPN predicen a los posibles ganadoresThe Jerusalem PostTwo men found dead near Tel Aviv senior living center in suspected murder-suicideInquirer EntertainmentTuldok, October 26, 2026Daily MaverickTRC Roulette : Apartheid chain of command: ‘I know everything,’ Eugene de Kock tells TRC inquiryХабрРуководство по сборке локального AI-стека. Часть 4. Запуск сервера генерации изображенийColliderIt’s Officially the End of an Era as Sony Discontinues Its Last 4K Blu-ray PlayerZDF heuteEntdecken Sie das ZDF-NachrichtenstudioSoompiWatch: A2O LITE Takes 5th Win For “NEON” On New Music Show “SHOW K-POP”RTL BoulevardHoogwater zorgt voor overlast in Breskens en op TexelSRF NewsUmbau beim Detailhändler – Migros-Führung will Genossenschaftsfusion
The Daily Newsstand · Free, Always
Monday, October 26, 2026

Иерархия контекстов в Spring. High CVE в Axelix

Translate

Меня зовут Михаил Поливаха, я Tech Lead проекта Axelix. Если Вы раньше про нас не слышали, то Axelix это платформа для управления и диагностики Spring Boot приложений. Ядро проекта доступно в Open Source, поэтому исходный код, история изменений и, как выяснилось, наши ошибки тоже открыты всем желающим 😁. Новости проекта, релизы и вот такие разборы мы публикуем в нашем Telegram-канале, заглядывайте.

Недавно внешний security researcher Андрей Котов (greatkaa) нашёл в Axelix уязвимость. Если в двух словах: при определённой конфигурации Spring Boot наши Actuator endpoints можно было вызвать без JWT-токена. Мы её уже исправили в версии 1.2.1 и описали в Security Advisory. Андрею, пользуясь случаем, большое спасибо.

Сразу скажу, что эта статья не про то, как этой уязвимостью можно было воспользоваться. Это, честно говоря, не так уж и интересно. Гораздо интереснее другое: почему она вообще появилась. Ведь мы не забыли поставить фильтр, не ошиблись в проверке подписи токена и не перепутали условие в if. Фильтр был, и он прекрасно работал. Просто не там, где мы думали.

А причина кроется в механизме Spring Framework, который существует уже лет двадцать, но о котором многие современные Spring Boot разработчики вообще не задумываются. Речь про иерархию ApplicationContext.

Поэтому план такой: сначала разберёмся, что такое иерархия контекстов и кто в ней кого видит, затем посмотрим, где её создаёт сам Spring Boot, и уже после этого вернёмся к Axelix и посмотрим, как всё это сложилось в одну неприятную историю.

Давайте разбираться.

С чего всё началось

Начнём с симптома, потому что он, на мой взгляд, очень показательный. Конфигурация была совершенно обычной:

server:
  port: 8080

management:
  server:
    port: 8081

То есть бизнес-API живёт на 8080, а Actuator вынесен на отдельный порт 8081. Так делают очень многие, и это хорошая практика: management-порт удобно закрыть от внешнего мира на уровне сети.

Axelix публикует свои endpoints через Actuator (они называются axelix-*), и все они закрыты JWT-фильтром. Когда Actuator живёт на основном порту, запрос без токена получает ожидаемый 401 Unauthorized. А вот на 8081 тот же самый запрос внезапно проходил.

Друзья, обратите внимание на важную деталь: приложение при этом нормально стартовало. Никаких NoSuchBeanDefinitionException, никаких warning-ов в логах, все endpoints на месте, зависимости заинжектились. Всё выглядит прекрасно. Именно такие ошибки и являются самыми неприятными. Все как будто бы нормально работает.

Почему простая смена номера порта так сильно меняет поведение? Чтобы ответить, придётся сделать шаг назад.

Один большой контекст. Или нет?

ApplicationContext это центральный контейнер Spring. Он хранит определения бинов, создаёт их, разрешает зависимости, прогоняет через post-processor-ы, публикует события и управляет lifecycle-ом.

Если Вы пишете на Spring Boot, то, скорее всего, у Вас в голове живёт примерно такая модель:

В моём приложении есть один большой Spring context, и все бины живут внутри него.

И это нормально, потому что в подавляющем большинстве случаев так и есть. SpringApplication.run(...) создаёт ServletWebServerApplicationContext, тот сам поднимает embedded Tomcat, сам находит servlet-ы и filter-ы и сам их регистрирует (Javadoc ServletWebServerApplicationContext). Одного контекста хватает за глаза.

Но вот в чём дело: контекст в Spring не обязан быть единственным. У ApplicationContext есть метод getParent(), а связь задаётся через ConfigurableApplicationContext#setParent. То есть контексты могут образовывать дерево.

Немного истории. Root и DispatcherServlet

Те, кто застал Spring MVC до Spring Boot, с иерархией контекстов наверняка сталкивались, даже если специально об этом не думали. Классическая схема выглядела так:

root WebApplicationContext
  services
  repositories
  shared infrastructure
          |
          v
DispatcherServlet WebApplicationContext
  controllers
  handler mappings
  web-specific beans

Root context создавал ContextLoaderListener из web.xml, а каждый DispatcherServlet поднимал свой собственный (!) дочерний WebApplicationContext. Сервисы и репозитории жили наверху и были общими, а контроллеры и вся MVC-инфраструктура конкретного servlet-а жили внизу. Причём у одного root могло быть несколько независимых детей (документация Spring MVC).

Потом пришёл Spring Boot, всё схлопнулось в один контекст, и про иерархию большинство благополучно забыло. Но механизм никуда не делся. Второй контекст и сегодня может появиться:

  • из явного вызова parent(...), child(...) или sibling(...) у SpringApplicationBuilder;

  • из теста с @ContextHierarchy;

  • из Spring Cloud, который поднимает свои дочерние контексты, например, для каждого Feign-клиента (сюрприз, дорогие друзья!);

  • и, что для нас самое важное, из Spring Boot Actuator при отдельном management-порте.

К последнему пункту мы ещё вернёмся. А сейчас давайте поймём главное: как контексты в иерархии видят друг друга.

Кто кого видит

Правила тут довольно простые, но несимметричные:

  • child видит бины parent-а;

  • parent не видит (!) бины child-а;

  • sibling-контексты, то есть дети одного родителя, про друг-друга не знают;

  • если child объявит бин с тем же именем, что и у parent-а, локальное (в child) определение перекроет родительское.

Это прямо зафиксировано в Javadoc ApplicationContext. Давайте посмотрим на это в коде, так нагляднее:

@Configuration
class ParentConfig {

    @Bean
    SharedService sharedService() {
        return new SharedService();
    }
}

@Configuration
class ChildConfig {

    @Bean
    ChildOnlyService childOnlyService(SharedService sharedService) {
        return new ChildOnlyService(sharedService);
    }
}
var parent = new AnnotationConfigApplicationContext(ParentConfig.class);

var child = new AnnotationConfigApplicationContext();
child.setParent(parent);
child.register(ChildConfig.class);
child.refresh();

child.getBean(SharedService.class);          // OK, найден в parent
child.containsBean("sharedService");         // true
child.containsLocalBean("sharedService");    // false, локально его нет

parent.getBean(ChildOnlyService.class);      // NoSuchBeanDefinitionException

Обратите внимание на разницу между containsBean и containsLocalBean. Первый учитывает parent-а, второй смотрит только на текущий уровень. Это различие нам ещё очень пригодится.

То есть, грубо говоря, child знает, куда обратиться наверху, если чего-то не нашёл у себя. А вот parent о существовании конкретного child-а может вообще не подозревать. Слово «parent» создаёт приятное ощущение, будто старший знает обо всех младших. В Spring всё ровно наоборот.

Видеть бин не значит им владеть

А теперь самая главная мысль этой статьи. Я её выделю:

То, что child может получить бин из parent-а, не означает, что child этот бин «использует» как часть своей инфраструктуры.

Каждый контекст в иерархии остаётся полноценным отдельным контейнером. У него свои bean definitions, свои post-processor-ы, свой lifecycle, свой event multicaster. Иерархия даёт лишь одну вещь: lookup зависимостей наверх. Всё остальное каждый контекст делает сам и только со своими бинами.

Вот несколько примеров того, как это проявляется:

  • BeanPostProcessor, объявленный в parent-е, не обрабатывает бины, которые создаются в child-е;

  • getBeansOfType() перечисляет только локальные бины. Чтобы пройтись по предкам, нужен BeanFactoryUtils.beansOfTypeIncludingAncestors(...);

  • события, опубликованные в child-е, поднимаются к parent-у. А вот события parent-а вниз к детям сами по себе не идут;

  • у parent-а нет универсального API, чтобы обойти всех своих детей.

Про события есть ещё один забавный нюанс. Если @EventListener объявлен в parent-е, он получит и ContextRefreshedEvent от child-а. Так что если Вы что-то делаете «при старте приложения» по этому событию, то при наличии дочернего контекста Ваш код может отработать дважды. Поэтому полезно проверять, от какого именно контекста пришло событие.

Ну и, наконец, пример, ради которого мы всё это обсуждаем. Embedded web server (тот же Tomcat) поднимается конкретным ServletWebServerApplicationContext, и при старте этот контекст регистрирует в сервере те Servlet-ы, Filter-ы и ServletContextInitializer-ы, которые определены в нём самом. Если Ваш FilterRegistrationBean лежит в parent-е, а сервер поднимает child, то фильтр в этот сервер не попадёт. Child его, конечно, «видит», но это никак не влияет на то, какую filter chain он соберёт для своего сервера.

Запомните эту мысль, мы к ней сейчас вернёмся.

Отдельный порт Actuator, это отдельный контекст

Итак, что происходит, когда Вы указываете management.server.port?

Spring Boot различает две ситуации. Если management.server.port не задан или совпадает с server.port, Actuator просто живёт в основном контексте. А вот если порты различаются, Boot создаёт отдельный дочерний management context, для которого основной контекст является parent-ом (ManagementContextAutoConfiguration).

Схематично это выглядит так:

application                          parent
  server.port=8080                     |
  web server :8080                     |
  controllers, services                |
                                       v
application:management               child
  management.server.port=8081
  web server :8081
  Actuator handler mappings
  своя servlet filter chain

То есть management.server.port меняет далеко, далеко не только не номер сокета! Появляется второй ApplicationContext, второй embedded server, собственные handler mapping-и и собственная цепочка servlet-фильтров. Документация Spring Boot об этом, кстати, честно предупреждает: management context на другом порту не использует ту же web-инфраструктуру, что основное приложение (Spring Boot Actuator 2.7 reference). Но мы же ее читаем, а вайбкодим, как известно.

То есть, по сути, с точки зрения web-слоя, у Вас теперь два разынх приложения.

Как библиотеке попасть в management context

Логичный вопрос: а если библиотеке (например, starter-у вроде нашего) нужно что-то положить именно в management context? Для этого в Spring Boot есть специальная аннотация @ManagementContextConfiguration. У неё есть параметр ManagementContextType:

  • SAME означает, что конфигурация применяется, когда Actuator живёт в основном контексте;

  • CHILD означает, что конфигурация применяется в отдельном дочернем management context;

  • ANY означает, что конфигурацию нужно применить в обоих случаях.

Одной аннотации при этом недостаточно. Класс нужно ещё и зарегистрировать в специальном файле:

META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports

Это очень похоже на то, как регистрируются обычные auto-configuration-ы через AutoConfiguration.imports, только этот файл Boot читает при создании management context-а. (В Spring Boot 2.7 по старой памяти поддерживался ещё и spring.factories, так что если копируете пример из старой документации, проверьте версию. Selector в Boot 2.7, selector в Boot 3.5.)

Что случилось в Axelix

Ну и теперь, я думаю, Вы уже и сами догадываетесь, что произошло.

Немного контекста про то, как устроен Axelix. В приложение подключается наш starter (axelix-spring-boot-2-starter, -3-starter или -4-starter, в зависимости от версии Boot). Starter публикует набор Actuator endpoints, через которые Axelix собирает информацию о приложении: бины, конфигурацию, логгеры, кэши и так далее. Все эти endpoints защищены JWT, и проверкой токена занимается наш JwtAuthorizationFilter.

До версии 1.2.1 этот фильтр регистрировался в обычной auto-configuration, то есть через FilterRegistrationBean в основном контексте (код Axelix 1.2.0).

Когда Actuator живёт на основном порту, всё прекрасно:

один context
  web server :8080
  Actuator mappings
  JwtAuthorizationFilter

Запрос идёт через фильтр, нет токена, получаем 401.

А вот при разных портах картина такая:

main context
  web server :8080
  JwtAuthorizationFilter       <- фильтр здесь
          |
          v
management child
  web server :8081
  Actuator mappings            <- а endpoints здесь
  JwtAuthorizationFilter нет

Management child прекрасно видит все нужные ему бины из parent-а, поэтому приложение стартует без единой ошибки. Endpoints работают. Но сервер на 8081 собирает свою filter chain, и нашего фильтра в ней нет. Ровно то, о чём мы говорили в разделе «Видеть бин не значит им владеть».

Root cause одним предложением: мы зарегистрировали JWT-фильтр в родительском контексте, а Actuator на отдельном порту обслуживался другим сервером из дочернего контекста.

Самое смешное, что сама проверка JWT работала безупречно. До неё запрос просто не доходил.

Был и ещё один симптом, который хорошо иллюстрирует происходящее. Часть endpoints на 8081 отвечала не 200, а 500 с сообщением Security Context is expected to be present at this point. Эти endpoints ожидали, что security context уже заполнен, а заполнял его как раз отсутствующий фильтр. Причём даже с валидным токеном было то же самое: обрабатывать токен было просто некому.

Честно скажу, оглядываясь назад, ошибка выглядит очевидной. Но в момент написания кода у нас, как и у многих, в голове была та самая модель «один большой контекст». И тесты у нас ходили только на основной порт, так что ничего не ловили. Хорошо, что есть люди вроде Андрея, которые смотрят на код под другим углом.

Как мы это починили

Решение напрашивается само собой: фильтр нужно зарегистрировать там, где поднимается сервер с Actuator-ом. В 1.2.1 для всех трёх starter-ов появился отдельный JwtAuthManagementContextConfiguration (исправление):

@ManagementContextConfiguration(
        value = ManagementContextType.CHILD,
        proxyBeanMethods = false
)
@ConditionalOnBean(WebIdentityAccessManager.class)
public class JwtAuthManagementContextConfiguration {

    @Bean
    public FilterRegistrationBean<JwtAuthorizationFilter>
            jwtAuthorizationFilterRegistration(
                    WebIdentityAccessManager webIdentityAccessManager,
                    SecurityContextExecutor securityContextExecutor,
                    WebEndpointProperties webEndpointProperties) {

        var filter = new JwtAuthorizationFilter(
                webIdentityAccessManager,
                securityContextExecutor,
                webEndpointProperties.getBasePath()
        );
        var registration = new FilterRegistrationBean<>(filter);
        registration.setName("jwtAuthorizationFilter");
        return registration;
    }
}

И, конечно, класс прописан в ManagementContextConfiguration.imports каждого starter-а.

Посмотрите, как здесь работает всё, что мы обсудили выше. WebIdentityAccessManager и SecurityContextExecutor по-прежнему живут в parent-е, и child их спокойно получает через lookup. А вот FilterRegistrationBean теперь создаётся локально в management context, поэтому именно management server включает фильтр в свою цепочку. Иерархия используется ровно так, как она задумана: общие сервисы наверху, web-инфраструктура конкретного сервера внизу.

Если сформулировать то, что мы восстановили, в виде правила, то получится так: сервер, который публикует Axelix endpoints, сам должен иметь JWT-фильтр в своей filter chain.

Ну и, разумеется, тест. Важно, что он проверяет именно топологию, а не наличие бина «где-то в контейнере»:

@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        properties = "management.server.port=0"
)
class JwtAuthorizationFilterSeparateManagementPortTest {

    @LocalManagementPort
    private int managementPort;

    @Test
    void shouldReturnUnauthorized_TokenIsMissing() {
        var response = restTemplate.getForEntity(
                "http://localhost:" + managementPort
                        + "/actuator/axelix-env",
                String.class
        );

        assertThat(response.getStatusCode())
                .isEqualTo(HttpStatus.UNAUTHORIZED);
    }
}

management.server.port=0 заставляет Boot поднять management context на случайном порту, а @LocalManagementPort даёт нам этот порт. Второй тест в том же классе отправляет валидный токен и ожидает 200 OK. Такие же тесты есть для Boot 2, 3 и 4 (тест в 1.2.1).

Небольшой Off-Top про follow-up

После релиза Андрей обратил наше внимание ещё на одну вещь. Первый патч оставил регистрацию фильтра и в основном контексте, и в child-е. Безопасность от этого не страдала, но при раздельных портах фильтр на основном сервере был, по сути, лишним: Actuator-а там уже нет.

В PR #1818 мы убрали регистрацию из обычной auto-configuration и поменяли ManagementContextType.CHILD на ANY. Теперь одна management-конфигурация отвечает за фильтр в обоих случаях: и когда порт общий, и когда отдельный. На момент написания статьи это изменение уже в main, но в релиз ещё не попало. Это архитектурная чистка, а не дополнительный security fix, так что 1.2.1 закрывает проблему полностью.

Как посмотреть на иерархию в своём приложении

Если Вы до сих пор всегда предполагали, что контекст у Вас один, то первый полезный шаг очень простой: перестать гадать и посмотреть.

@Component
final class ContextTopologyLogger {

    @EventListener
    void onRefreshed(ContextRefreshedEvent event) {
        ApplicationContext context = event.getApplicationContext();
        ApplicationContext parent = context.getParent();

        System.out.printf(
                "context=%s class=%s parent=%s beans=%d%n",
                context.getId(),
                context.getClass().getName(),
                parent == null ? "<root>" : parent.getId(),
                context.getBeanDefinitionCount()
        );
    }
}

Помните, что события поднимаются из child-а к parent-у? Как раз поэтому этот listener, объявленный в основном контексте, увидит refresh и management child-а тоже. И как раз поэтому контекст здесь важно брать из самого события, а не из заинжекченного ApplicationContext. Запустите приложение с management.server.port, и Вы увидите в логах две строчки вместо одной.

Ещё несколько полезных инструментов:

  • context.getParent() возвращает непосредственного parent-а;

  • containsLocalBean(name) отвечает, определён ли бин на текущем уровне;

  • containsBean(name) учитывает ещё и parent-ов;

  • BeanFactoryUtils.beanNamesForTypeIncludingAncestors(...) явно ищет по всем предкам.

Стандартный Actuator endpoint /actuator/beans тоже возвращает объект contexts, где у контекста может быть parentId (Actuator beans API). Но тут есть нюанс: endpoint описывает тот контекст, в котором он живёт, и его предков. Своих потомков он искать не обязан. Так что результат зависит от того, на какой порт Вы пришли.

И совет для тех, кто пишет свои starter-ы или просто вешает что-то на Actuator: если Ваш код должен работать при отдельном management-порте, то тестируйте его именно на отдельном management-порте. Тест, который ходит только на @LocalServerPort, management server вообще не проверяет. Мы в этом убедились на своём опыте.

Итоги

Давайте соберём главное:

  1. ApplicationContext в Spring не обязан быть один. Контексты образуют дерево, и Spring Boot сам создаёт такое дерево чаще, чем кажется.

  2. Видимость в иерархии работает снизу вверх: child видит parent-а, parent не видит child-а.

  3. Видимость бина не делает его частью инфраструктуры другого контекста. Post-processor-ы, события, filter chain и web server у каждого контекста свои.

  4. management.server.port, отличный от server.port, означает отдельный дочерний контекст и отдельный web server. Относитесь к нему как ко второму web-приложению.

  5. Если библиотеке нужно что-то положить в management context, для этого есть @ManagementContextConfiguration.

Для нас эта история стала хорошим напоминанием, что даже очень знакомый фреймворк может удивить, если Вы опираетесь на упрощённую ментальную модель. Причём упрощённая модель работает годами, ровно до тех пор, пока не перестаёт.

Ещё раз спасибо Андрею за находку и за внимательный review патча. А если Вам интересно, как устроен Axelix внутри, то добро пожаловать на GitHub, мы открыты. А чтобы не пропустить следующие релизы и разборы, подписывайтесь на наш Telegram-канал. Там же можно задать вопрос напрямую команде.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.