[ISSUE-149] Fix actuator security to allow unauthenticated health/info on management port #161

Merged
hitanshu merged 1 commits from Dave/CFTunnels:ISSUE-149 into main 2026-08-02 17:34:55 +00:00
Member

Summary

Fix Spring Boot Actuator health endpoint returning OIDC authentication redirect instead of health status JSON.

The health endpoint at http://192.168.0.100:5004/actuator/health was returning an authentication redirect because the catch-all SecurityFilterChain in SecuirtyConfig.java was intercepting actuator requests on the separate management port (8081) before the ActuatorSecurityConfig filter chain could apply.

Root Cause

  • SecuirtyConfig has no securityMatcher, making it a catch-all that matches ALL requests
  • With management.server.port=8081, Spring Boot creates a separate management server
  • The ActuatorSecurityConfig had @Order(1) on the @Bean method, which may not be sufficient to override auto-configured management security filter chains
  • The string-based securityMatcher("/actuator/**") may not work correctly across separate servlet contexts

Changes

  1. ActuatorSecurityConfig.java: Upgraded to @Order(Ordered.HIGHEST_PRECEDENCE) at class level to guarantee it's evaluated first
  2. ActuatorSecurityConfig.java: Replaced string-based securityMatcher with EndpointRequest.toAnyEndpoint() for proper Spring Boot actuator endpoint matching
  3. SecuirtyConfig.java: Added belt-and-suspenders permitAll() for /actuator/health and /actuator/info before the catch-all anyRequest().authenticated()

Issues

Testing

  • Verify http://192.168.0.100:5004/actuator/health returns JSON health status without authentication
  • Verify http://192.168.0.100:5004/actuator/info returns info without authentication
  • Verify other actuator endpoints still require authentication
  • Verify main application endpoints still require OIDC authentication
## Summary Fix Spring Boot Actuator health endpoint returning OIDC authentication redirect instead of health status JSON. The health endpoint at `http://192.168.0.100:5004/actuator/health` was returning an authentication redirect because the catch-all `SecurityFilterChain` in `SecuirtyConfig.java` was intercepting actuator requests on the separate management port (8081) before the `ActuatorSecurityConfig` filter chain could apply. ## Root Cause - `SecuirtyConfig` has no `securityMatcher`, making it a catch-all that matches ALL requests - With `management.server.port=8081`, Spring Boot creates a separate management server - The `ActuatorSecurityConfig` had `@Order(1)` on the `@Bean` method, which may not be sufficient to override auto-configured management security filter chains - The string-based `securityMatcher("/actuator/**")` may not work correctly across separate servlet contexts ## Changes 1. **ActuatorSecurityConfig.java**: Upgraded to `@Order(Ordered.HIGHEST_PRECEDENCE)` at class level to guarantee it's evaluated first 2. **ActuatorSecurityConfig.java**: Replaced string-based `securityMatcher` with `EndpointRequest.toAnyEndpoint()` for proper Spring Boot actuator endpoint matching 3. **SecuirtyConfig.java**: Added belt-and-suspenders `permitAll()` for `/actuator/health` and `/actuator/info` before the catch-all `anyRequest().authenticated()` ## Issues - Hithomelabs/HomeLabDocker#149 ## Testing - [ ] Verify `http://192.168.0.100:5004/actuator/health` returns JSON health status without authentication - [ ] Verify `http://192.168.0.100:5004/actuator/info` returns info without authentication - [ ] Verify other actuator endpoints still require authentication - [ ] Verify main application endpoints still require OIDC authentication
Dave added 1 commit 2026-07-28 06:17:00 +00:00
Hithomelabs/CFTunnels#149: Fix actuator security to allow unauthenticated health/info on management port
All checks were successful
Daily cloudflare API integration test / cloudflare-api-test (push) Successful in 1m46s
sample gradle build and test / build (pull_request) Successful in 1m58s
Build & Push Portainer Automation / version (push) Successful in 6s
Build & Push Portainer Automation / build-and-push (push) Successful in 2m11s
Build & Push CFTunnels / version (push) Successful in 6s
Build & Push CFTunnels / build-and-push (push) Successful in 2m45s
b9c39ad990
- Upgrade ActuatorSecurityConfig to @Order(Ordered.HIGHEST_PRECEDENCE) at class level
  to ensure filter chain is evaluated before auto-configured management security
- Replace string-based securityMatcher with EndpointRequest.toAnyEndpoint() for
  proper Spring Boot actuator endpoint matching
- Add belt-and-suspenders permitAll() for /actuator/health and /actuator/info in
  SecuirtyConfig so health endpoints are accessible even if filter chain ordering fails
- Root cause: SecuirtyConfig catch-all (no securityMatcher) was intercepting actuator
  requests on the separate management port (management.server.port=8081) and
  redirecting to OIDC login before ActuatorSecurityConfig filter chain could apply
Linus approved these changes 2026-07-28 06:20:54 +00:00
Linus left a comment
Member

PR Review: Fix Actuator Security for Unauthenticated Health/Info on Management Port

Strengths

  1. Correct root cause analysis: The PR description clearly identifies the issue — SecuirtyConfig has no securityMatcher, making it a catch-all that intercepts ALL requests including actuator endpoints on the separate management port (8081). Well-documented.

  2. EndpointRequest.toAnyEndpoint() is the right choice: Replacing the string-based "/actuator/**" with EndpointRequest.toAnyEndpoint() is the idiomatic Spring Boot approach. It properly handles the management server servlet context and the actuator base path configuration.

  3. Excellent Javadoc: The updated class-level documentation is thorough — it explains the ordering rationale, references the specific auto-configuration being overridden, and describes the failure scenario. This is the kind of documentation that saves future developers hours of debugging.

  4. Belt-and-suspenders defense: Adding .requestMatchers("/actuator/health", "/actuator/info").permitAll() in SecuirtyConfig is a smart defensive measure. With management.server.port=8081, actuator requests won't normally reach SecuirtyConfig, but this ensures resilience if the management port configuration changes in the future.

  5. Least-privilege principle preserved: Only /actuator/health and /actuator/info are permitted without auth. All other actuator endpoints remain authenticated via .anyRequest().authenticated() in both filter chains.

  6. show-details=when-authorized in application.properties is correctly configured — unauthenticated requests get minimal health status, authenticated requests get full component details.

Concerns

  1. Ordered.HIGHEST_PRECEDENCE may be overly aggressive (minor): This evaluates to Integer.MIN_VALUE. For this codebase with only 2 security configs it's fine, but if other security filter chains are added later (e.g., for API key auth, basic auth), they could conflict. Consider using @Order(0) instead — it's the conventional "go first" order and leaves room for truly highest-precedence chains.

  2. CSRF disabled on actuator chain: This is standard for REST actuator endpoints, but worth noting in a comment for future auditors.

  3. No integration tests: Security configuration is one of the highest-risk areas. Consider adding a test that verifies:

    • GET /actuator/health on management port returns 200 without auth
    • GET /actuator/info on management port returns 200 without auth
    • GET /actuator/beans on management port returns 401/403 without auth
    • GET /actuator/health on main port returns 200 without auth (belt-and-suspenders)
  4. Testing checklist not verified: The PR body has all testing items unchecked. These should be verified before merge — especially the port-specific tests.

Recommendations

  1. Consider @Order(0) over HIGHEST_PRECEDENCE: More conventional, leaves headroom, and achieves the same effect for this codebase. Not blocking.

  2. Add a brief comment above the SecuirtyConfig permitAll explaining it's a fallback for non-separated-port configurations:

    // Fallback: permit actuator health/info if management server port is not separated
    .requestMatchers("/actuator/health", "/actuator/info").permitAll()
    
  3. Verify the fix in production: After merge, confirm with Uptime Kuma that health checks pass without authentication on port 5004 (mapped to 8081).

  4. Future improvement: Consider adding a @SuppressWarnings or renaming the typo in SecuirtyConfigSecurityConfig (pre-existing, not blocking this PR).

Effort Estimate

  • Estimated: XS (1-2 days)
  • Confidence: High
  • Key assumptions: The management port separation is correctly configured in Docker Compose and the fix addresses the full scope of the issue.

Approval Status

APPROVED — This is a clean, well-documented fix that correctly addresses the root cause. The changes are minimal, focused, and use the idiomatic Spring Boot approach. Minor recommendations above are non-blocking. Recommend merging after verifying the testing checklist items.

## PR Review: Fix Actuator Security for Unauthenticated Health/Info on Management Port ### Strengths 1. **Correct root cause analysis**: The PR description clearly identifies the issue — `SecuirtyConfig` has no `securityMatcher`, making it a catch-all that intercepts ALL requests including actuator endpoints on the separate management port (8081). Well-documented. 2. **`EndpointRequest.toAnyEndpoint()` is the right choice**: Replacing the string-based `"/actuator/**"` with `EndpointRequest.toAnyEndpoint()` is the idiomatic Spring Boot approach. It properly handles the management server servlet context and the actuator base path configuration. 3. **Excellent Javadoc**: The updated class-level documentation is thorough — it explains the ordering rationale, references the specific auto-configuration being overridden, and describes the failure scenario. This is the kind of documentation that saves future developers hours of debugging. 4. **Belt-and-suspenders defense**: Adding `.requestMatchers("/actuator/health", "/actuator/info").permitAll()` in `SecuirtyConfig` is a smart defensive measure. With `management.server.port=8081`, actuator requests won't normally reach `SecuirtyConfig`, but this ensures resilience if the management port configuration changes in the future. 5. **Least-privilege principle preserved**: Only `/actuator/health` and `/actuator/info` are permitted without auth. All other actuator endpoints remain authenticated via `.anyRequest().authenticated()` in both filter chains. 6. **`show-details=when-authorized`** in `application.properties` is correctly configured — unauthenticated requests get minimal health status, authenticated requests get full component details. ### Concerns 1. **`Ordered.HIGHEST_PRECEDENCE` may be overly aggressive** (minor): This evaluates to `Integer.MIN_VALUE`. For this codebase with only 2 security configs it's fine, but if other security filter chains are added later (e.g., for API key auth, basic auth), they could conflict. Consider using `@Order(0)` instead — it's the conventional "go first" order and leaves room for truly highest-precedence chains. 2. **CSRF disabled on actuator chain**: This is standard for REST actuator endpoints, but worth noting in a comment for future auditors. 3. **No integration tests**: Security configuration is one of the highest-risk areas. Consider adding a test that verifies: - `GET /actuator/health` on management port returns 200 without auth - `GET /actuator/info` on management port returns 200 without auth - `GET /actuator/beans` on management port returns 401/403 without auth - `GET /actuator/health` on main port returns 200 without auth (belt-and-suspenders) 4. **Testing checklist not verified**: The PR body has all testing items unchecked. These should be verified before merge — especially the port-specific tests. ### Recommendations 1. **Consider `@Order(0)` over `HIGHEST_PRECEDENCE`**: More conventional, leaves headroom, and achieves the same effect for this codebase. Not blocking. 2. **Add a brief comment above the `SecuirtyConfig` permitAll** explaining it's a fallback for non-separated-port configurations: ```java // Fallback: permit actuator health/info if management server port is not separated .requestMatchers("/actuator/health", "/actuator/info").permitAll() ``` 3. **Verify the fix in production**: After merge, confirm with Uptime Kuma that health checks pass without authentication on port 5004 (mapped to 8081). 4. **Future improvement**: Consider adding a `@SuppressWarnings` or renaming the typo in `SecuirtyConfig` → `SecurityConfig` (pre-existing, not blocking this PR). ### Effort Estimate - **Estimated**: XS (1-2 days) - **Confidence**: High - **Key assumptions**: The management port separation is correctly configured in Docker Compose and the fix addresses the full scope of the issue. ### Approval Status **APPROVED** — This is a clean, well-documented fix that correctly addresses the root cause. The changes are minimal, focused, and use the idiomatic Spring Boot approach. Minor recommendations above are non-blocking. Recommend merging after verifying the testing checklist items.
@ -15,0 +18,4 @@
* Uses @Order(Ordered.HIGHEST_PRECEDENCE) to ensure this filter chain is evaluated BEFORE
* any auto-configured management security filter chains (e.g., ManagementWebSecurityAutoConfiguration).
* Without this, the catch-all SecurityFilterChain in SecuirtyConfig can intercept actuator
* requests on the separate management port (management.server.port=8081) and redirect to OIDC login.
Member

Using Ordered.HIGHEST_PRECEDENCE (Integer.MIN_VALUE) is correct but aggressive. Consider @Order(0) instead — it's the conventional 'go first' order and leaves headroom if other security filter chains are added later. Not blocking for this PR, but a consideration for future-proofing.

Using `Ordered.HIGHEST_PRECEDENCE` (Integer.MIN_VALUE) is correct but aggressive. Consider `@Order(0)` instead — it's the conventional 'go first' order and leaves headroom if other security filter chains are added later. Not blocking for this PR, but a consideration for future-proofing.
@ -30,3 +30,4 @@
.authorizeHttpRequests(auth -> auth
//.requestMatchers( "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html" ).permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.anyRequest().authenticated()
Member

Good belt-and-suspenders approach. With management.server.port=8081, actuator requests won't normally reach this filter chain, but this serves as a safety net. Consider adding a brief comment explaining the fallback intent:

// Fallback: permit actuator health/info for non-separated management port configs
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
Good belt-and-suspenders approach. With `management.server.port=8081`, actuator requests won't normally reach this filter chain, but this serves as a safety net. Consider adding a brief comment explaining the fallback intent: ```java // Fallback: permit actuator health/info for non-separated management port configs .requestMatchers("/actuator/health", "/actuator/info").permitAll() ```
Member

🏛️ Architect Review — PR #161: Actuator Security Fix

Posted by @Archie (architect). Note: architect token is scoped to write:issue (no write:repository), so this is posted as a thread comment rather than a formal review; @Linus's formal APPROVED review stands.

Verdict: APPROVED

Root Cause — Confirmed & Sharpened

The PR's diagnosis is correct, and I can pin the definitive mechanism: Spring Security 6.1+ no longer honors @Order on @Bean methods (Boot 3.4.5 ships Spring Security 6.4.x). The original @Order(1) on actuatorSecurityChain(...) was silently ignored, leaving both chains at default order (LOWEST_PRECEDENCE). With ties, FilterChainProxy falls back to bean registration order, and the catch-all SecuirtyConfig chain won for actuator requests on the management port → OIDC redirect. Moving @Order to the class level is the mandatory, idiomatic fix — not stylistic.

One Javadoc nuance to correct (non-blocking): ManagementWebSecurityAutoConfiguration backs off entirely when any SecurityFilterChain bean exists (@ConditionalOnMissingBean). So this is intra-app chain ordering inside the single FilterChainProxy, not "overriding auto-configured management chains". The separate management port (8081) shares the parent context's chains (child context's springSecurityFilterChain delegating filter resolves to the parent proxy) — which is exactly why ordering within that proxy is what fixes the health endpoint. Fix is correct; the Javadoc just slightly mischaracterizes the mechanism.

What's Right

  • EndpointRequest.toAnyEndpoint() is the idiomatic matcher — context-aware (main + management servlet contexts) and resilient to management.endpoints.web.base-path changes.
  • Least privilege preserved: only health/info permitted; .anyRequest().authenticated() future-proofs newly exposed actuator endpoints (better than the issue template's .anyRequest().permitAll()).
  • show-details=when-authorized — unauthenticated probes get {"status":"UP"} with no component/DB detail leakage. Correct security posture.
  • Exposure restricted to health,info — minimal attack surface on the management port.
  • The belt-and-suspenders permitAll in SecuirtyConfig is inert-but-safe: with management.server.port=8081 the main port serves no actuator endpoints, so behavior changes from "302 OIDC redirect" → "404" — friendlier for probes.

Concerns & Recommendations (non-blocking)

  1. Ordered.HIGHEST_PRECEDENCE is aggressive (Integer.MIN_VALUE). Fine with 2 chains, but @Order(0) — or even @Order(1) at class level — achieves identical behavior here while leaving headroom for future chains (API-key/basic-auth) that may legitimately need to run first. Both defensible.
  2. Port inconsistency: docker-compose.yaml maps ${MNGT_PORT:-5003}:8081 (default 5003), but the PR body says 5004. Verify stack.env/.env sets MNGT_PORT=5004 and Uptime Kuma points at the actual published port — otherwise 5003 is the effective default.
  3. No automated regression coverage — highest-value follow-up. The repo has integration-test infra (application-integration.properties). Add tests: unauthenticated 200 on /actuator/health + /actuator/info (management port), 401/403 on a non-permitted actuator endpoint, and preserved OIDC redirect on main app endpoints. Security config is where silent regressions happen.
  4. Doc correction (Javadoc nuance above) — optional, amend when convenient.
  5. Pre-existing: SecuirtyConfig typo → rename to SecurityConfig in a separate refactor PR (keeps this diff minimal).

Effort Estimate

  • XS (~half-day). Minimal scope (10+/2−), idiomatic, addresses the true root cause.
  • Confidence: High, contingent on MNGT_PORT verification + the unchecked testing checklist.

Recommendation

Merge after verifying the unchecked testing items — particularly the actual published management port (5003 vs 5004) and the Uptime Kuma target URL.

## 🏛️ Architect Review — PR #161: Actuator Security Fix *Posted by @Archie (architect). Note: architect token is scoped to `write:issue` (no `write:repository`), so this is posted as a thread comment rather than a formal review; @Linus's formal APPROVED review stands.* ### Verdict: **APPROVED** ✅ ### Root Cause — Confirmed & Sharpened The PR's diagnosis is correct, and I can pin the definitive mechanism: **Spring Security 6.1+ no longer honors `@Order` on `@Bean` methods** (Boot 3.4.5 ships Spring Security 6.4.x). The original `@Order(1)` on `actuatorSecurityChain(...)` was **silently ignored**, leaving both chains at default order (`LOWEST_PRECEDENCE`). With ties, `FilterChainProxy` falls back to bean registration order, and the catch-all `SecuirtyConfig` chain won for actuator requests on the management port → OIDC redirect. Moving `@Order` to the class level is the *mandatory*, idiomatic fix — not stylistic. **One Javadoc nuance to correct** (non-blocking): `ManagementWebSecurityAutoConfiguration` backs off entirely when *any* `SecurityFilterChain` bean exists (`@ConditionalOnMissingBean`). So this is **intra-app chain ordering inside the single `FilterChainProxy`**, not "overriding auto-configured management chains". The separate management port (8081) shares the parent context's chains (child context's `springSecurityFilterChain` delegating filter resolves to the parent proxy) — which is exactly why ordering within that proxy is what fixes the health endpoint. Fix is correct; the Javadoc just slightly mischaracterizes the mechanism. ### What's Right - `EndpointRequest.toAnyEndpoint()` is the idiomatic matcher — context-aware (main + management servlet contexts) and resilient to `management.endpoints.web.base-path` changes. - **Least privilege preserved**: only `health`/`info` permitted; `.anyRequest().authenticated()` future-proofs newly exposed actuator endpoints (better than the issue template's `.anyRequest().permitAll()`). - `show-details=when-authorized` — unauthenticated probes get `{"status":"UP"}` with **no** component/DB detail leakage. Correct security posture. - Exposure restricted to `health,info` — minimal attack surface on the management port. - The belt-and-suspenders `permitAll` in `SecuirtyConfig` is inert-but-safe: with `management.server.port=8081` the main port serves no actuator endpoints, so behavior changes from "302 OIDC redirect" → "404" — friendlier for probes. ### Concerns & Recommendations (non-blocking) 1. **`Ordered.HIGHEST_PRECEDENCE` is aggressive** (`Integer.MIN_VALUE`). Fine with 2 chains, but `@Order(0)` — or even `@Order(1)` at class level — achieves identical behavior here while leaving headroom for future chains (API-key/basic-auth) that may legitimately need to run first. Both defensible. 2. **Port inconsistency**: `docker-compose.yaml` maps `${MNGT_PORT:-5003}:8081` (default **5003**), but the PR body says **5004**. Verify `stack.env`/`.env` sets `MNGT_PORT=5004` and Uptime Kuma points at the actual published port — otherwise 5003 is the effective default. 3. **No automated regression coverage** — highest-value follow-up. The repo has integration-test infra (`application-integration.properties`). Add tests: unauthenticated 200 on `/actuator/health` + `/actuator/info` (management port), 401/403 on a non-permitted actuator endpoint, and preserved OIDC redirect on main app endpoints. Security config is where silent regressions happen. 4. **Doc correction** (Javadoc nuance above) — optional, amend when convenient. 5. **Pre-existing**: `SecuirtyConfig` typo → rename to `SecurityConfig` in a separate refactor PR (keeps this diff minimal). ### Effort Estimate - **XS** (~half-day). Minimal scope (10+/2−), idiomatic, addresses the true root cause. - **Confidence**: High, contingent on MNGT_PORT verification + the unchecked testing checklist. ### Recommendation **Merge** after verifying the unchecked testing items — particularly the actual published management port (5003 vs 5004) and the Uptime Kuma target URL.
hitanshu merged commit b9c39ad990 into main 2026-08-02 17:34:55 +00:00
Sign in to join this conversation.
No reviewers
No Milestone
No project
No Assignees
3 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: Hithomelabs/CFTunnels#161
No description provided.