From b8eb1559e0fefb1a43a67fc8c194c9998dad4914 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 3 Jul 2026 09:50:53 +0300 Subject: [PATCH] fix(security): plain 401 for programmatic requests - no browser login dialog over background polls An expired session answered EVERY request with 401 + WWW-Authenticate: Basic, so the BROWSER popped its native login dialog before any script saw the response. The generated Harmonia apps poll the inbox every 30 seconds, so an idle tab surfaced the dialog "out of nowhere" once the 8h session lapsed (or the server restarted). BasicSecurityConfig now registers an additional authentication entry point for programmatic requests - Sec-Fetch-Mode present and != navigate (every modern browser stamps fetch/XHR), with X-Requested-With: XMLHttpRequest as the legacy fallback - returning a plain 401 without the Basic challenge. Browser navigations keep the normal Basic/form login flow. The shared fetch client (application-core api.js) now sends X-Requested-With so the fallback also matches, and its error catalog already maps 401 to "Your session has ended. Please sign in again." - which the shell now actually gets to display. Verified live: 401 without WWW-Authenticate for Sec-Fetch-Mode:cors and X-Requested-With requests; challenge kept for navigations and plain curl; authenticated calls unaffected. Co-Authored-By: Claude Fable 5 --- .../tenants/security/BasicSecurityConfig.java | 30 ++++++++++++++++++- .../application-core/shell/js/services/api.js | 5 +++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/components/core/core-tenants/src/main/java/org/eclipse/dirigible/components/tenants/security/BasicSecurityConfig.java b/components/core/core-tenants/src/main/java/org/eclipse/dirigible/components/tenants/security/BasicSecurityConfig.java index 6fe784f1835..7bfbc46954a 100644 --- a/components/core/core-tenants/src/main/java/org/eclipse/dirigible/components/tenants/security/BasicSecurityConfig.java +++ b/components/core/core-tenants/src/main/java/org/eclipse/dirigible/components/tenants/security/BasicSecurityConfig.java @@ -9,17 +9,21 @@ */ package org.eclipse.dirigible.components.tenants.security; +import jakarta.servlet.http.HttpServletRequest; + import org.eclipse.dirigible.components.base.http.access.CorsConfigurationSourceProvider; import org.eclipse.dirigible.components.base.http.access.HttpSecurityURIConfigurator; import org.eclipse.dirigible.components.tenants.tenant.TenantContextInitFilter; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfigurationSource; @@ -49,13 +53,37 @@ SecurityFilterChain filterChain(HttpSecurity http, TenantContextInitFilter tenan .addFilterBefore(tenantContextInitFilter, UsernamePasswordAuthenticationFilter.class) .formLogin(Customizer.withDefaults()) .logout(logout -> logout.deleteCookies("JSESSIONID")) - .headers(headers -> headers.frameOptions(frameOpts -> frameOpts.disable())); + .headers(headers -> headers.frameOptions(frameOpts -> frameOpts.disable())) + // A programmatic (fetch/XHR) request whose session expired must get a PLAIN 401 - the + // default Basic entry point's `WWW-Authenticate: Basic` challenge makes the BROWSER pop + // its native login dialog before any script sees the response (the generated apps poll + // the inbox every 30s, so an idle tab surfaced the dialog "out of nowhere"). Browser + // navigations don't match and keep the normal Basic/form login flow. + .exceptionHandling(handling -> handling.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), + BasicSecurityConfig::isProgrammaticRequest)); httpSecurityURIConfigurator.configure(http); return http.build(); } + /** + * Whether the request comes from script (fetch / XMLHttpRequest) rather than a browser navigation: + * every modern browser stamps programmatic requests with a {@code Sec-Fetch-Mode} other than + * {@code navigate}; the {@code X-Requested-With} header is the legacy client-sent marker kept as a + * fallback. + * + * @param request the inbound request + * @return true for a programmatic request + */ + private static boolean isProgrammaticRequest(HttpServletRequest request) { + String secFetchMode = request.getHeader("Sec-Fetch-Mode"); + if (secFetchMode != null) { + return !"navigate".equalsIgnoreCase(secFetchMode); + } + return "XMLHttpRequest".equalsIgnoreCase(request.getHeader("X-Requested-With")); + } + /** * Cors configuration source. * diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/api.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/api.js index 3f99a2d7d67..02b0cde512a 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/api.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/api.js @@ -89,7 +89,10 @@ App.services.api = { async request(method, url, body, opts = {}) { const baseUrl = this.resolveBaseUrl(opts); - const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; + // X-Requested-With marks the call as programmatic for browsers without Sec-Fetch-Mode: the + // server then answers an expired session with a PLAIN 401 (no Basic challenge), so the + // browser's native login dialog never pops over a background poll. + const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }; const language = this.language(); if (language) headers['Accept-Language'] = language;