diff --git a/.claude/docs/conventions.md b/.claude/docs/conventions.md index 20e261004cb..923321a2bd6 100644 --- a/.claude/docs/conventions.md +++ b/.claude/docs/conventions.md @@ -32,5 +32,6 @@ - **Sample-project tests live in `tests/ui/tests/sample/` and come in FAMILIES, not one class per sample.** `SampleProjectsIT` clones a *list* of `dirigiblelabs/*` repos through the IDE Git perspective, calls `Workbench.publishAll(true)` once, runs `forceProcessSynchronizers()` + `waitForStableSynchronization()`, and does all of that once under `@DirtiesContext(AFTER_CLASS)`; each subclass then verifies one sample per `@Test`. The clone-and-publish runs in a **`@BeforeEach` guarded by the family class**, deliberately not in a `@BeforeAll` on a `@TestInstance(PER_CLASS)` class — PER_CLASS autowires the test instance *before* `@BeforeAll`, so the platform boots before `IntegrationTest.cleanBeforeTestClassExecution()` deletes the Dirigible folder and the next sync pass reaps the platform's own registry (it surfaces as "`perspective-git` not found" on a blank page, nowhere near the cause). UI-based (Selenide → Chrome), slower than HTTP-only ITs — which is exactly why the boot, the browser and the publish cycle are shared. Three subclasses: `TypeScriptSampleProjectsIT` (the TS/JS decorator samples), `JavaSampleProjectsIT` (the client-Java samples) and `SampleLibraryLocalNativeAppIT` (alone, because it spawns a real OS process). **The family split is a runtime constraint, not taste:** `sample-entity-decorators` and `sample-java-entity-decorators` both own the `SAMPLE_COUNTRY` table and both CSVIM-seed it, so they cannot be published into the same instance. Before adding a sample to a family, check it against the family for a table/route/queue/websocket-endpoint/Java-FQN collision (the whole family shares one `javac` batch and one `ClientClassLoader`), and keep the verification order-independent — the methods run against one live instance. When adding a new sample, drop the project in its own `dirigiblelabs/*` repo first, then add its URL to a family's `getRepositoryUrls()`. Inventory of sample repos under `dirigiblelabs/*`: `sample-entity-decorators`, `sample-java-entity-decorators`, `sample-roles-decorator`, `sample-job-decorator`, `sample-listener-decorator`, `sample-extension-decorator`, `sample-component-decorator`, `sample-websocket-decorator`, `sample-store-api`, `sample-intent-model`. `IntentEditorLoadsIT` is not a `SampleProjectsIT` subclass (the intent generates in the workspace, not on publish) but uses the same clone-a-real-repo pattern: it clones `dirigiblelabs/sample-intent-model`, opens its `app.intent`, and drives the editor's Generate. - **The error-body `message` key is configured as `spring.web.error.include-message=always`** (`application-common.properties`), and a `ResponseStatusException`'s reason reaches the caller through it. Spring Boot 4.0 **renamed** that property from `server.error.include-message` and deprecated the old spelling at level `error`, i.e. it is not bound at all — the platform kept the old key from the Boot 3 era and so answered every one of its ~200 `ResponseStatusException` throw sites with a bare status phrase, silently (the property was still loaded and still visible on `/actuator/env`, it just had no effect). Fixed in #6994, pinned by `RestErrorMessageIT`; three module-scoped workarounds were written against the broken behaviour before the cause was found (`ControllerInvoker`, `TenantProvisioningExceptionHandler`, and commit `f13c8c219e`, which relaxed `JavaEngineIT` to assert `statusCode` only) — asserting a reason in the body is legitimate again. When adding a `server.*` property, check `META-INF/spring-configuration-metadata.json` for a `deprecation` entry: a `level: error` rename is accepted by the binder and does nothing. - **A STOMP session takes its handshake identity cross-origin only with `DIRIGIBLE_CORS_ALLOW_CREDENTIALS` (#7445).** The `/stomp` endpoint (`engine-websockets`, `WebsocketConfig`) accepts the configured origins that name a host, and a handshake - the raw WebSocket upgrade or the SockJS transport request that creates a session - cannot be kept from carrying the session cookie or HTTP authentication. So the identity is decided on the CONNECT, as for every other cross-origin request: `CrossOriginHandshakeInterceptor` marks a session opened cross-origin with its origin (a session attribute, written on both registrations BEFORE `withSockJS()`, which copies interceptors and patterns at creation; a request without `Origin` is same-origin, as for Spring's own check), and `BearerTokenStompInterceptor` refuses a CONNECT without a bearer token on a marked session unless `CorsConfigurationSourceProvider.stompCredentialsAllowed()` - read once at boot - is true; the refusal is the usual `Unauthorized` ERROR frame with the reason (origin, key, remedies) in the log. A bearer CONNECT overrides the handshake identity either way, and a same-origin session is never concerned. Behind a TLS-terminating proxy the platform's own pages read as cross-origin unless `server.forward-headers-strategy` is set - the same failure mode the CORS filter has. Guards: `ExternalFrontendIT` (credentials off, raw + `xhr_streaming` transport), `StompCrossOriginCredentialsIT` (credentials on, basic profile). +- **CSRF is refused on the browser's word, not with a token (#7644).** Every chain still runs `csrf.disable()` - no IDE or Harmonia client sends a token - and `BrowserSecurityConfigurator` (core-base, a `CustomSecurityConfigurator`, so it reaches all five chains) puts `CrossSiteRequestFilter` in the CSRF filter's slot instead: a POST/PUT/PATCH/DELETE carrying an ambient credential (a session id, or HTTP authentication that is not `Bearer`) is answered 403 when `Sec-Fetch-Site` is anything but `same-origin`/`none` (so `same-site` too - a sibling subdomain may be another tenant's), or, without fetch metadata, when `Origin` is `null` or names another host than `X-Forwarded-Host`/`Host`. No header at all = a server-side client = passes; an origin from `DIRIGIBLE_CORS_ALLOWED_ORIGINS` that names a host is trusted, the wildcard is not. The same configurator owns the response headers (`DIRIGIBLE_SECURITY_FRAME_OPTIONS` default `SAMEORIGIN`, `DIRIGIBLE_SECURITY_HSTS` `auto|always|off`, `DIRIGIBLE_SECURITY_REFERRER_POLICY`, opt-in `DIRIGIBLE_SECURITY_CONTENT_SECURITY_POLICY` report-only by default, `DIRIGIBLE_SECURITY_PERMISSIONS_POLICY`) - do not set `frameOptions` in a chain again. The session cookie is `SameSite=Lax; HttpOnly` from `application-common.properties` (`DIRIGIBLE_SESSION_COOKIE_SAMESITE`, `DIRIGIBLE_SESSION_COOKIE_SECURE`). A cookie write with a foreign `Origin` is now refused even where CORS let it through - the unconfigured basic chain grants every origin (without credentials), and a plain form post needs no CORS at all. Guards: `CrossSiteRequestFilterTest`, `BrowserSecurityConfiguratorTest`, `EndpointAuthorizationIT`. `DIRIGIBLE_SECURITY_CROSS_SITE_PROTECTION=false` is the escape hatch. - **The client-Java effort is split across three repositories.** The platform code (engine-java, data-store-java, IT) ships in this repo via PR [#5923](https://github.com/eclipse-dirigible/dirigible/pull/5923). The sample project that `JavaSampleProjectsIT` clones lives in [`dirigiblelabs/sample-java-entity-decorators`](https://github.com/dirigiblelabs/sample-java-entity-decorators) (initial content from PR [#1](https://github.com/dirigiblelabs/sample-java-entity-decorators/pull/1)). The announcement blog "Return of the Java – Decorators Awaken in Eclipse Dirigible" (sister piece to the December 2025 TS decorators post) goes through the docs portal in PR [`dirigible-io/dirigible-io.github.io#123`](https://github.com/dirigible-io/dirigible-io.github.io/pull/123). Follow-up Java-runtime features generally touch the same three places. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4bdc9b88e07..542b910792b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -504,6 +504,12 @@ jobs: - name: Run OWASP ZAP Full Scan uses: zaproxy/action-full-scan@v0.12.0 + env: + # scan as the default basic user, so ZAP reaches the REST surface behind the login too, not only + # the public pages (#7644); report-only until one release has shown zero High alerts + ZAP_AUTH_HEADER: Authorization + ZAP_AUTH_HEADER_VALUE: Basic YWRtaW46YWRtaW4= + ZAP_AUTH_HEADER_SITE: localhost with: target: 'http://localhost:8080' cmd_options: '-T 10' # https://www.zaproxy.org/docs/docker/full-scan/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e8eb2ad572..cd3861a2c5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -422,6 +422,12 @@ jobs: - name: Run OWASP ZAP Full Scan uses: zaproxy/action-full-scan@v0.12.0 + env: + # scan as the default basic user, so ZAP reaches the REST surface behind the login too, not only + # the public pages (#7644); report-only until one release has shown zero High alerts + ZAP_AUTH_HEADER: Authorization + ZAP_AUTH_HEADER_VALUE: Basic YWRtaW46YWRtaW4= + ZAP_AUTH_HEADER_SITE: localhost with: target: 'http://localhost:8080' cmd_options: '-T 10' # https://www.zaproxy.org/docs/docker/full-scan/ diff --git a/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/http/access/BrowserSecurityConfigurator.java b/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/http/access/BrowserSecurityConfigurator.java new file mode 100644 index 00000000000..6121d6c5c96 --- /dev/null +++ b/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/http/access/BrowserSecurityConfigurator.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.base.http.access; + +import java.util.Locale; + +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.eclipse.dirigible.commons.config.InvalidConfigException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * What a browser is told and refused on whichever security chain the deployment runs: the response + * headers that keep the platform's pages from being framed, sniffed or leaking their URLs, and the + * {@link CrossSiteRequestFilter} that refuses a state-changing request another site's page sends in + * a logged in user's name. + * + *

+ * Every chain applies the custom configurators before its URL matrix, so one bean reaches the + * basic, snowflake and OAuth2 login chains alike; the chains no longer decide their frame options + * themselves. Spring's other default headers ({@code X-Content-Type-Options: nosniff}, the cache + * control of authenticated responses) stay as they are. The filter takes the slot of the CSRF + * filter, which every chain disables, so it runs after CORS - a preflight is answered first - and + * before logout and every authentication. + */ +@Component +class BrowserSecurityConfigurator implements CustomSecurityConfigurator { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(BrowserSecurityConfigurator.class); + + /** + * Configure. + * + * @param http the http + * @throws Exception the exception + */ + @Override + public void configure(HttpSecurity http) throws Exception { + http.headers(this::configureHeaders); + if (DirigibleConfig.SECURITY_CROSS_SITE_PROTECTION.getBooleanValue()) { + http.addFilterAt(new CrossSiteRequestFilter(), CsrfFilter.class); + } else { + LOGGER.warn("Cross-site request protection is disabled by [{}]: a page of any site a logged in user visits may post to the" + + " platform in that user's name.", DirigibleConfig.SECURITY_CROSS_SITE_PROTECTION.getKey()); + } + } + + void configureHeaders(HeadersConfigurer headers) { + String frameOptions = DirigibleConfig.SECURITY_FRAME_OPTIONS.getStringValue(); + switch (normalized(frameOptions)) { + case "SAMEORIGIN" -> headers.frameOptions(options -> options.sameOrigin()); + case "DENY" -> headers.frameOptions(options -> options.deny()); + case "DISABLED" -> headers.frameOptions(options -> options.disable()); + default -> throw new InvalidConfigException( + "Unsupported frame options [" + frameOptions + "], supported are SAMEORIGIN, DENY" + " and DISABLED", + DirigibleConfig.SECURITY_FRAME_OPTIONS.getKey()); + } + + String hsts = DirigibleConfig.SECURITY_HSTS.getStringValue(); + switch (normalized(hsts)) { + // Spring's default: secure requests only, a year, subdomains included + case "AUTO" -> { + } + case "ALWAYS" -> headers.httpStrictTransportSecurity(options -> options.requestMatcher(AnyRequestMatcher.INSTANCE)); + case "OFF" -> headers.httpStrictTransportSecurity(options -> options.disable()); + default -> throw new InvalidConfigException("Unsupported HSTS mode [" + hsts + "], supported are auto, always and off", + DirigibleConfig.SECURITY_HSTS.getKey()); + } + + String referrerPolicy = DirigibleConfig.SECURITY_REFERRER_POLICY.getStringValue(); + if (StringUtils.hasText(referrerPolicy)) { + ReferrerPolicy policy = ReferrerPolicy.get(referrerPolicy.trim() + .toLowerCase(Locale.ROOT)); + if (policy == null) { + throw new InvalidConfigException("Unsupported referrer policy [" + referrerPolicy + "]", + DirigibleConfig.SECURITY_REFERRER_POLICY.getKey()); + } + headers.referrerPolicy(options -> options.policy(policy)); + } + + String contentSecurityPolicy = DirigibleConfig.SECURITY_CONTENT_SECURITY_POLICY.getStringValue(); + if (StringUtils.hasText(contentSecurityPolicy)) { + boolean reportOnly = DirigibleConfig.SECURITY_CONTENT_SECURITY_POLICY_REPORT_ONLY.getBooleanValue(); + headers.contentSecurityPolicy(options -> { + options.policyDirectives(contentSecurityPolicy.trim()); + if (reportOnly) { + options.reportOnly(); + } + }); + } + + String permissionsPolicy = DirigibleConfig.SECURITY_PERMISSIONS_POLICY.getStringValue(); + if (StringUtils.hasText(permissionsPolicy)) { + headers.permissionsPolicyHeader(options -> options.policy(permissionsPolicy.trim())); + } + } + + private static String normalized(String value) { + return value == null ? "" + : value.trim() + .toUpperCase(Locale.ROOT); + } +} diff --git a/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/http/access/CorsConfigurationSourceProvider.java b/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/http/access/CorsConfigurationSourceProvider.java index 8029be825d7..bbe6a3c5fde 100644 --- a/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/http/access/CorsConfigurationSourceProvider.java +++ b/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/http/access/CorsConfigurationSourceProvider.java @@ -36,8 +36,8 @@ * Unconfigured, the chains that always answered cross-origin requests (basic, snowflake) keep their * historical shape - every origin, the usual headers and methods - with one change: cookies are no * longer accepted cross-site. A wildcard origin combined with credentials lets any page a logged in - * user happens to visit call the platform in that user's name, and CSRF tokens are disabled on - * every chain, so nothing else would stop such a call. + * user happens to visit call the platform in that user's name and read the answer; a configured + * origin is also exempt from the {@link CrossSiteRequestFilter}, so it is trusted with the session. * *

* Configured, the listed origins get exactly what the configuration grants, and the OAuth2 login @@ -235,8 +235,9 @@ private static void refuseUnsafe(List origins, boolean allowCredentials, private static void warnAboutRisks(List origins, boolean allowCredentials, long maxAge) { if (allowCredentials) { - LOGGER.warn("Cross-origin requests from {} may carry the session cookie. CSRF tokens are disabled on every security chain, so" - + " these origins are trusted with the sessions of logged in users.", origins); + LOGGER.warn("Cross-origin requests from {} may carry the session cookie. No CSRF token is asked for and the" + + " cross-site request filter lets them through, so these origins are trusted with the sessions of logged in users.", + origins); } List insecureOrigins = origins.stream() .filter(origin -> !isTransportSecure(origin)) diff --git a/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/http/access/CrossSiteRequestFilter.java b/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/http/access/CrossSiteRequestFilter.java new file mode 100644 index 00000000000..a796e9ee582 --- /dev/null +++ b/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/http/access/CrossSiteRequestFilter.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.base.http.access; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Refuses a cross-site request forgery: a state-changing request a browser sends from another site + * with the user's ambient credentials. + * + *

+ * CSRF tokens are disabled on every security chain - the IDE and the generated applications send + * none - so a page a logged in user visits could post a form to a generated create endpoint in that + * user's name. Instead of a token this filter reads what the browser itself says about the request, + * which needs no change in any client: + *

+ * Only requests carrying an ambient credential are concerned - a session id, or HTTP authentication + * other than a bearer token, which a browser may replay on its own. A bearer token is never + * ambient: a page can only send it by script, and a script reaches another origin only through + * CORS. An origin configured in {@link DirigibleConfig#CORS_ALLOWED_ORIGINS} by host is the + * operator's explicit trust decision and passes; a pattern naming no host (the wildcard) does not, + * as it would trust every site. + */ +public class CrossSiteRequestFilter extends OncePerRequestFilter { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(CrossSiteRequestFilter.class); + + static final String SEC_FETCH_SITE = "Sec-Fetch-Site"; + static final String X_FORWARDED_HOST = "X-Forwarded-Host"; + + private static final Set SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE"); + private static final Set SAME_ORIGIN_FETCH_SITES = Set.of("same-origin", "none"); + private static final String BEARER_PREFIX = "bearer "; + private static final String NULL_ORIGIN = "null"; + + /** The configured origins that name a host, null when none is configured. */ + private final CorsConfiguration trustedOrigins; + + public CrossSiteRequestFilter() { + List patterns = CorsConfigurationSourceProvider.allowedOriginPatterns() + .stream() + .filter(CorsConfigurationSourceProvider::namesAHost) + .toList(); + if (patterns.isEmpty()) { + this.trustedOrigins = null; + } else { + this.trustedOrigins = new CorsConfiguration(); + this.trustedOrigins.setAllowedOriginPatterns(patterns); + } + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (SAFE_METHODS.contains(request.getMethod() + .toUpperCase(Locale.ROOT)) + || !carriesAmbientCredentials(request) || !isCrossSite(request) || isTrusted(request.getHeader(HttpHeaders.ORIGIN))) { + filterChain.doFilter(request, response); + return; + } + LOGGER.warn( + "Refused a cross-site [{}] on [{}] from origin [{}] ({} [{}]) carrying the user's session or browser credentials." + + " A page of another site cannot act in a logged in user's name; a trusted front end belongs in [{}], a" + + " server-side client authenticates without a browser.", + forLog(request.getMethod()), forLog(request.getRequestURI()), forLog(request.getHeader(HttpHeaders.ORIGIN)), SEC_FETCH_SITE, + forLog(request.getHeader(SEC_FETCH_SITE)), DirigibleConfig.CORS_ALLOWED_ORIGINS.getKey()); + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.TEXT_PLAIN_VALUE); + response.getWriter() + .write("Cross-site request refused"); + } + + /** + * Whether the request carries a credential the browser attaches on its own: a session id, or HTTP + * authentication that is not a bearer token. + */ + static boolean carriesAmbientCredentials(HttpServletRequest request) { + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authorization != null && authorization.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { + return false; + } + return request.getRequestedSessionId() != null || authorization != null; + } + + /** Whether the browser says the request was sent by a page of another origin. */ + static boolean isCrossSite(HttpServletRequest request) { + String fetchSite = request.getHeader(SEC_FETCH_SITE); + if (fetchSite != null) { + return !SAME_ORIGIN_FETCH_SITES.contains(fetchSite.trim() + .toLowerCase(Locale.ROOT)); + } + String origin = request.getHeader(HttpHeaders.ORIGIN); + if (origin == null) { + return false; + } + String originHost = hostOf(origin); + String requestHost = requestHost(request); + return originHost == null || requestHost == null || !originHost.equalsIgnoreCase(requestHost); + } + + private boolean isTrusted(String origin) { + return trustedOrigins != null && origin != null && !NULL_ORIGIN.equalsIgnoreCase(origin) + && trustedOrigins.checkOrigin(origin) != null; + } + + /** + * The host the request was addressed to, before any proxy: {@code X-Forwarded-Host}, else + * {@code Host}. + */ + private static String requestHost(HttpServletRequest request) { + String forwarded = request.getHeader(X_FORWARDED_HOST); + String host = forwarded != null && !forwarded.isBlank() ? forwarded.split(",")[0] : request.getHeader(HttpHeaders.HOST); + if (host == null || host.isBlank()) { + host = request.getServerName(); + } + return hostOf("http://" + host.trim()); + } + + /** + * A request value as it may appear in the log: an anonymous client chooses these, so control + * characters, which could forge or garble log lines, are replaced. + */ + static String forLog(String value) { + return value == null ? null : value.replaceAll("\\p{Cntrl}", "_"); + } + + /** The host of an origin or authority, without its port; null when it is none. */ + private static String hostOf(String origin) { + if (NULL_ORIGIN.equalsIgnoreCase(origin.trim())) { + return null; + } + try { + return new URI(origin.trim()).getHost(); + } catch (URISyntaxException ex) { + return null; + } + } +} diff --git a/components/core/core-base/src/test/java/org/eclipse/dirigible/components/base/http/access/BrowserSecurityConfiguratorTest.java b/components/core/core-base/src/test/java/org/eclipse/dirigible/components/base/http/access/BrowserSecurityConfiguratorTest.java new file mode 100644 index 00000000000..4c707cd607e --- /dev/null +++ b/components/core/core-base/src/test/java/org/eclipse/dirigible/components/base/http/access/BrowserSecurityConfiguratorTest.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.base.http.access; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.eclipse.dirigible.commons.config.Configuration; +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.eclipse.dirigible.commons.config.InvalidConfigException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * A chain the configurator reaches answers with the browser security headers and refuses a forged + * cross-site post, whatever the chain itself configured. + */ +@SpringJUnitWebConfig(BrowserSecurityConfiguratorTest.Config.class) +class BrowserSecurityConfiguratorTest { + + @EnableWebMvc + @EnableWebSecurity + static class Config { + + @Bean + SecurityFilterChain chain(HttpSecurity http) throws Exception { + // the shape of every platform chain: tokens off, frames left open + http.csrf(csrf -> csrf.disable()) + .headers(headers -> headers.frameOptions(options -> options.disable())) + .authorizeHttpRequests(authz -> authz.anyRequest() + .permitAll()); + new BrowserSecurityConfigurator().configure(http); + return http.build(); + } + + @Bean + Endpoint endpoint() { + return new Endpoint(); + } + } + + @RestController + static class Endpoint { + + @PostMapping("/services/ts/app/gen/api/Invoice") + String create() { + return "created"; + } + } + + @Autowired + private WebApplicationContext context; + + private MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @AfterEach + void clearConfiguration() { + Configuration.remove(DirigibleConfig.SECURITY_FRAME_OPTIONS.getKey()); + Configuration.remove(DirigibleConfig.SECURITY_HSTS.getKey()); + Configuration.remove(DirigibleConfig.SECURITY_REFERRER_POLICY.getKey()); + } + + @Test + void everyResponseCarriesTheBrowserSecurityHeaders() throws Exception { + mvc.perform(get("/")) + .andExpect(header().string("X-Frame-Options", "SAMEORIGIN")) + .andExpect(header().string("Referrer-Policy", "strict-origin-when-cross-origin")) + .andExpect(header().string("X-Content-Type-Options", "nosniff")); + } + + @Test + void hstsIsSentOnSecureRequests() throws Exception { + mvc.perform(get("/").secure(true)) + .andExpect(header().exists("Strict-Transport-Security")); + mvc.perform(get("/")) + .andExpect(header().doesNotExist("Strict-Transport-Security")); + } + + @Test + void aForgedCrossSitePostIsRefused() throws Exception { + mvc.perform(post("/services/ts/app/gen/api/Invoice").header("Sec-Fetch-Site", "cross-site") + .header("Origin", "https://evil.example.org") + .with(BrowserSecurityConfiguratorTest::withSession)) + .andExpect(status().is(HttpStatus.FORBIDDEN.value())); + } + + @Test + void aSameOriginPostReachesTheEndpoint() throws Exception { + mvc.perform(post("/services/ts/app/gen/api/Invoice").header("Sec-Fetch-Site", "same-origin") + .with(BrowserSecurityConfiguratorTest::withSession)) + .andExpect(status().isOk()); + } + + @Test + void anUnsupportedFrameOptionIsRefused() { + DirigibleConfig.SECURITY_FRAME_OPTIONS.setStringValue("ALLOW-FROM https://example.org"); + + assertThrows(InvalidConfigException.class, () -> new BrowserSecurityConfigurator().configureHeaders(headers())); + } + + @Test + void anUnsupportedHstsModeIsRefused() { + DirigibleConfig.SECURITY_HSTS.setStringValue("sometimes"); + + assertThrows(InvalidConfigException.class, () -> new BrowserSecurityConfigurator().configureHeaders(headers())); + } + + @Test + void anUnsupportedReferrerPolicyIsRefused() { + DirigibleConfig.SECURITY_REFERRER_POLICY.setStringValue("whatever"); + + assertThrows(InvalidConfigException.class, () -> new BrowserSecurityConfigurator().configureHeaders(headers())); + } + + private static MockHttpServletRequest withSession(MockHttpServletRequest request) { + request.setRequestedSessionId("4A2C1F"); + return request; + } + + @SuppressWarnings("unchecked") + private static HeadersConfigurer headers() { + return mock(HeadersConfigurer.class); + } +} diff --git a/components/core/core-base/src/test/java/org/eclipse/dirigible/components/base/http/access/CrossSiteRequestFilterTest.java b/components/core/core-base/src/test/java/org/eclipse/dirigible/components/base/http/access/CrossSiteRequestFilterTest.java new file mode 100644 index 00000000000..312875a8d63 --- /dev/null +++ b/components/core/core-base/src/test/java/org/eclipse/dirigible/components/base/http/access/CrossSiteRequestFilterTest.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.base.http.access; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.eclipse.dirigible.commons.config.Configuration; +import org.eclipse.dirigible.commons.config.DirigibleConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** + * A state-changing request another site's page sends with the user's ambient credentials is + * refused; everything a same-origin page, a server-side client or a bearer client sends passes. + */ +class CrossSiteRequestFilterTest { + + private static final String HOST = "app.example.com"; + + @AfterEach + void clearConfiguration() { + Configuration.remove(DirigibleConfig.CORS_ALLOWED_ORIGINS.getKey()); + } + + @Test + void aCrossSiteFormPostWithTheSessionCookieIsRefused() throws Exception { + MockHttpServletRequest request = withSession(post()); + request.addHeader(CrossSiteRequestFilter.SEC_FETCH_SITE, "cross-site"); + request.addHeader("Origin", "https://evil.example.org"); + + MockHttpServletResponse response = run(request); + + assertEquals(403, response.getStatus()); + } + + @Test + void aSameSiteSubdomainIsRefusedToo() throws Exception { + // a sibling subdomain may be another tenant's + MockHttpServletRequest request = withSession(post()); + request.addHeader(CrossSiteRequestFilter.SEC_FETCH_SITE, "same-site"); + request.addHeader("Origin", "https://other.example.com"); + + assertEquals(403, run(request).getStatus()); + } + + @Test + void aSameOriginPostPasses() throws Exception { + MockHttpServletRequest request = withSession(post()); + request.addHeader(CrossSiteRequestFilter.SEC_FETCH_SITE, "same-origin"); + request.addHeader("Origin", "https://" + HOST); + + assertPassed(request); + } + + @Test + void theUsersOwnNavigationPasses() throws Exception { + MockHttpServletRequest request = withSession(post()); + request.addHeader(CrossSiteRequestFilter.SEC_FETCH_SITE, "none"); + + assertPassed(request); + } + + @Test + void aSafeMethodPasses() throws Exception { + MockHttpServletRequest request = withSession(new MockHttpServletRequest("GET", "/services/ts/app/gen/api/Invoice")); + request.addHeader(CrossSiteRequestFilter.SEC_FETCH_SITE, "cross-site"); + + assertPassed(request); + } + + @Test + void aCrossSitePostWithoutCredentialsPasses() throws Exception { + MockHttpServletRequest request = post(); + request.addHeader(CrossSiteRequestFilter.SEC_FETCH_SITE, "cross-site"); + + assertPassed(request); + } + + @Test + void browserRememberedBasicAuthenticationIsAmbient() throws Exception { + MockHttpServletRequest request = post(); + request.addHeader("Authorization", "Basic YWRtaW46YWRtaW4="); + request.addHeader(CrossSiteRequestFilter.SEC_FETCH_SITE, "cross-site"); + + assertEquals(403, run(request).getStatus()); + } + + @Test + void aBearerTokenIsNeverAmbient() throws Exception { + MockHttpServletRequest request = withSession(post()); + request.addHeader("Authorization", "Bearer eyJhbGciOiJSUzI1NiJ9"); + request.addHeader(CrossSiteRequestFilter.SEC_FETCH_SITE, "cross-site"); + + assertPassed(request); + } + + @Test + void aServerSideClientPasses() throws Exception { + // neither Sec-Fetch-Site nor Origin: not a browser page + MockHttpServletRequest request = post(); + request.addHeader("Authorization", "Basic YWRtaW46YWRtaW4="); + + assertPassed(request); + } + + @Test + void withoutFetchMetadataAForeignOriginIsRefused() throws Exception { + MockHttpServletRequest request = withSession(post()); + request.addHeader("Origin", "https://evil.example.org"); + + assertEquals(403, run(request).getStatus()); + } + + @Test + void withoutFetchMetadataTheNullOriginIsRefused() throws Exception { + MockHttpServletRequest request = withSession(post()); + request.addHeader("Origin", "null"); + + assertEquals(403, run(request).getStatus()); + } + + @Test + void withoutFetchMetadataTheOwnOriginPassesWhateverThePort() throws Exception { + MockHttpServletRequest request = withSession(post()); + request.addHeader("Origin", "https://" + HOST + ":8443"); + + assertPassed(request); + } + + @Test + void theForwardedHostIsTheOneTheBrowserAddressed() throws Exception { + MockHttpServletRequest request = withSession(post()); + request.removeHeader("Host"); + request.addHeader("Host", "10.0.0.12:8080"); + request.addHeader(CrossSiteRequestFilter.X_FORWARDED_HOST, HOST); + request.addHeader("Origin", "https://" + HOST); + + assertPassed(request); + } + + @Test + void aConfiguredOriginIsTrusted() throws Exception { + DirigibleConfig.CORS_ALLOWED_ORIGINS.setStringValue("https://front.example.org"); + MockHttpServletRequest request = withSession(post()); + request.addHeader(CrossSiteRequestFilter.SEC_FETCH_SITE, "cross-site"); + request.addHeader("Origin", "https://front.example.org"); + + assertPassed(request); + } + + @Test + void theWildcardOriginTrustsNobody() throws Exception { + DirigibleConfig.CORS_ALLOWED_ORIGINS.setStringValue("*"); + MockHttpServletRequest request = withSession(post()); + request.addHeader(CrossSiteRequestFilter.SEC_FETCH_SITE, "cross-site"); + request.addHeader("Origin", "https://evil.example.org"); + + assertEquals(403, run(request).getStatus()); + } + + private static MockHttpServletRequest post() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/services/ts/app/gen/api/Invoice"); + request.setServerName(HOST); + request.addHeader("Host", HOST); + return request; + } + + private static MockHttpServletRequest withSession(MockHttpServletRequest request) { + request.setRequestedSessionId("4A2C1F"); + return request; + } + + private static void assertPassed(MockHttpServletRequest request) throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletResponse response = new MockHttpServletResponse(); + new CrossSiteRequestFilter().doFilter(request, response, chain); + assertEquals(200, response.getStatus()); + assertNotNull(chain.getRequest(), "the request should have reached the chain"); + } + + private static MockHttpServletResponse run(MockHttpServletRequest request) throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletResponse response = new MockHttpServletResponse(); + new CrossSiteRequestFilter().doFilter(request, response, chain); + if (response.getStatus() == 403) { + assertNull(chain.getRequest(), "a refused request must not reach the chain"); + } + return response; + } +} 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 2d0065e476b..842b2e41149 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 @@ -46,11 +46,12 @@ SecurityFilterChain filterChain(HttpSecurity http, TenantContextInitFilter tenan HttpSecurityURIConfigurator httpSecurityURIConfigurator) throws Exception { http.cors(Customizer.withDefaults()) .httpBasic(Customizer.withDefaults()) - .csrf(csrf -> csrf.disable())// if enabled, some functionalities will not work - like creating a project + // no client sends a CSRF token - BrowserSecurityConfigurator refuses forged cross-site requests and + // sets the frame options + .csrf(csrf -> csrf.disable()) .addFilterBefore(tenantContextInitFilter, UsernamePasswordAuthenticationFilter.class) .formLogin(Customizer.withDefaults()) .logout(logout -> logout.deleteCookies("JSESSIONID")) - .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 diff --git a/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoSecurityConfiguration.java b/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoSecurityConfiguration.java index 2d3efbcdbd8..79064239d4d 100644 --- a/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoSecurityConfiguration.java +++ b/components/security/security-cognito/src/main/java/org/eclipse/dirigible/components/security/cognito/CognitoSecurityConfiguration.java @@ -88,9 +88,10 @@ SecurityFilterChain filterChain(HttpSecurity http, HttpSecurityURIConfigurator h BearerUnauthorizedEntryPoint bearerEntryPoint = new BearerUnauthorizedEntryPoint(); http.authorizeHttpRequests(authz -> authz.requestMatchers("/oauth2/**", "/login/**") .permitAll()) + // no client sends a CSRF token - BrowserSecurityConfigurator refuses forged cross-site requests and + // sets the frame options .csrf(csrf -> csrf.disable()) .addFilterBefore(new OAuth2SessionRevalidationFilter(authorizedClientService, userAuthoritiesMapper), AuthorizationFilter.class) - .headers(headers -> headers.frameOptions(frameOpts -> frameOpts.disable())) .exceptionHandling(handling -> handling.defaultAuthenticationEntryPointFor(bearerEntryPoint, new ProgrammaticRequestMatcher())) .oauth2Client(oauth2Client -> oauth2Client.authorizationCodeGrant( grant -> grant.authorizationRequestResolver(authorizationRequestResolver))) diff --git a/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakSecurityConfiguration.java b/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakSecurityConfiguration.java index 7fb12bca68e..98aa8313a40 100644 --- a/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakSecurityConfiguration.java +++ b/components/security/security-keycloak/src/main/java/org/eclipse/dirigible/components/security/keycloak/KeycloakSecurityConfiguration.java @@ -94,10 +94,11 @@ SecurityFilterChain configure(HttpSecurity http, TenantContextInitFilter tenantC BearerUnauthorizedEntryPoint bearerEntryPoint = new BearerUnauthorizedEntryPoint(); http.authorizeHttpRequests(authz -> authz.requestMatchers("/oauth2/**", "/login/**") .permitAll()) + // no client sends a CSRF token - BrowserSecurityConfigurator refuses forged cross-site requests and + // sets the frame options .csrf(csrf -> csrf.disable()) .addFilterBefore(tenantContextInitFilter, OAuth2LoginAuthenticationFilter.class) .addFilterBefore(new OAuth2SessionRevalidationFilter(authorizedClientService, userAuthoritiesMapper), AuthorizationFilter.class) - .headers(headers -> headers.frameOptions(frameOpts -> frameOpts.sameOrigin())) .exceptionHandling(handling -> handling.defaultAuthenticationEntryPointFor(bearerEntryPoint, new ProgrammaticRequestMatcher())) .oauth2Client(oauth2Client -> oauth2Client.authorizationCodeGrant( grant -> grant.authorizationRequestResolver(authorizationRequestResolver))) diff --git a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/OAuth2SecurityConfiguration.java b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/OAuth2SecurityConfiguration.java index d1edf42f7bd..4d46823a0e3 100644 --- a/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/OAuth2SecurityConfiguration.java +++ b/components/security/security-oauth2/src/main/java/org/eclipse/dirigible/components/security/oauth2/OAuth2SecurityConfiguration.java @@ -56,9 +56,10 @@ SecurityFilterChain filterChain(HttpSecurity http, HttpSecurityURIConfigurator h http// .authorizeHttpRequests(authz -> authz.requestMatchers("/oauth2/**", "/login/**") .permitAll()) + // no client sends a CSRF token - BrowserSecurityConfigurator refuses forged cross-site requests and + // sets the frame options .csrf(csrf -> csrf.disable()) .addFilterBefore(new OAuth2SessionRevalidationFilter(authorizedClientService), AuthorizationFilter.class) - .headers(headers -> headers.frameOptions(frameOpts -> frameOpts.disable())) .exceptionHandling(handling -> handling.defaultAuthenticationEntryPointFor(bearerEntryPoint, new ProgrammaticRequestMatcher())) .oauth2Client(Customizer.withDefaults()) .oauth2Login(Customizer.withDefaults()) diff --git a/components/security/security-snowflake/src/main/java/org/eclipse/dirigible/components/security/snowflake/SnowflakeSecurityConfig.java b/components/security/security-snowflake/src/main/java/org/eclipse/dirigible/components/security/snowflake/SnowflakeSecurityConfig.java index f0e8766b396..19515706242 100644 --- a/components/security/security-snowflake/src/main/java/org/eclipse/dirigible/components/security/snowflake/SnowflakeSecurityConfig.java +++ b/components/security/security-snowflake/src/main/java/org/eclipse/dirigible/components/security/snowflake/SnowflakeSecurityConfig.java @@ -45,13 +45,14 @@ SecurityFilterChain filterChain(HttpSecurity http, TenantContextInitFilter tenan HttpSecurityURIConfigurator httpSecurityURIConfigurator) throws Exception { LOGGER.info("Configure snowflake security configurations"); http.cors(Customizer.withDefaults()) - .csrf(csrf -> csrf.disable()) // if enabled, some functionalities will not work - like creating a project + // no client sends a CSRF token - BrowserSecurityConfigurator refuses forged cross-site requests and + // sets the frame options + .csrf(csrf -> csrf.disable()) .logout(logout -> logout.deleteCookies("JSESSIONID") // consider redirect to reserved path "/sfc-endpoint/logout" and snowflakeLogoutHandler removal .addLogoutHandler(snowflakeLogoutHandler) .logoutSuccessUrl("/") .invalidateHttpSession(true)) - .headers(headers -> headers.frameOptions(frameOpts -> frameOpts.disable())) .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .addFilterBefore(tenantContextInitFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAt(snowflakeAuthFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/modules/commons/commons-config/src/main/java/org/eclipse/dirigible/commons/config/DirigibleConfig.java b/modules/commons/commons-config/src/main/java/org/eclipse/dirigible/commons/config/DirigibleConfig.java index 7be7e6e75ae..a7b5250dd2f 100644 --- a/modules/commons/commons-config/src/main/java/org/eclipse/dirigible/commons/config/DirigibleConfig.java +++ b/modules/commons/commons-config/src/main/java/org/eclipse/dirigible/commons/config/DirigibleConfig.java @@ -259,6 +259,51 @@ public enum DirigibleConfig { /** Seconds a browser may cache the answer to a preflight request. */ CORS_MAX_AGE("DIRIGIBLE_CORS_MAX_AGE", "3600"), + /** + * Whether a state-changing request (POST, PUT, PATCH, DELETE) that a browser sends from another + * site with the user's ambient credentials - the session cookie, or HTTP authentication the browser + * remembered - is refused with 403. That is a cross-site request forgery: a page the logged in user + * visits posts a form to a generated endpoint. The browser's own {@code Sec-Fetch-Site} header + * decides, {@code Origin} where it is missing; a bearer token, a request without either header (a + * server-side client) and an origin configured in {@link #CORS_ALLOWED_ORIGINS} by host are let + * through. + */ + SECURITY_CROSS_SITE_PROTECTION("DIRIGIBLE_SECURITY_CROSS_SITE_PROTECTION", Boolean.TRUE.toString()), + + /** + * The {@code X-Frame-Options} every chain answers with: {@code SAMEORIGIN} (the IDE and the + * generated shells frame their own pages), {@code DENY}, or {@code DISABLED} for a deployment whose + * pages are framed by another site. + */ + SECURITY_FRAME_OPTIONS("DIRIGIBLE_SECURITY_FRAME_OPTIONS", "SAMEORIGIN"), + + /** + * When {@code Strict-Transport-Security} is sent: {@code auto} on requests the platform sees as + * secure (behind a TLS-terminating proxy only with {@code server.forward-headers-strategy}), + * {@code always} on every response - for a deployment reached only over https whose proxy does not + * forward the scheme - or {@code off}. + */ + SECURITY_HSTS("DIRIGIBLE_SECURITY_HSTS", "auto"), + + /** The {@code Referrer-Policy} every chain answers with; blank sends none. */ + SECURITY_REFERRER_POLICY("DIRIGIBLE_SECURITY_REFERRER_POLICY", "strict-origin-when-cross-origin"), + + /** + * A {@code Content-Security-Policy} every chain answers with; unset sends none. Sent as + * {@code Content-Security-Policy-Report-Only} while + * {@link #SECURITY_CONTENT_SECURITY_POLICY_REPORT_ONLY} holds, so a policy can be tried against the + * IDE and the generated applications before it is enforced. + */ + SECURITY_CONTENT_SECURITY_POLICY("DIRIGIBLE_SECURITY_CONTENT_SECURITY_POLICY", null), + + /** + * Whether {@link #SECURITY_CONTENT_SECURITY_POLICY} only reports violations instead of blocking. + */ + SECURITY_CONTENT_SECURITY_POLICY_REPORT_ONLY("DIRIGIBLE_SECURITY_CONTENT_SECURITY_POLICY_REPORT_ONLY", Boolean.TRUE.toString()), + + /** A {@code Permissions-Policy} every chain answers with; unset sends none. */ + SECURITY_PERMISSIONS_POLICY("DIRIGIBLE_SECURITY_PERMISSIONS_POLICY", null), + /** * Comma-separated kinds of bearer tokens the OAuth2 login profiles (cognito, keycloak) accept: * {@code id} - an ID token identifies a user by the principal claim and grants the roles of the diff --git a/modules/commons/commons-resources/src/main/resources/application-common.properties b/modules/commons/commons-resources/src/main/resources/application-common.properties index 2f8aacee12b..15bee61156c 100644 --- a/modules/commons/commons-resources/src/main/resources/application-common.properties +++ b/modules/commons/commons-resources/src/main/resources/application-common.properties @@ -13,6 +13,14 @@ spring.application.name=Eclipse Dirigible server.port=${DIRIGIBLE_SERVER_PORT:8080} +# The session cookie: SameSite=Lax keeps a browser from sending it with a form another site posts +# (cross-site request forgery), HttpOnly keeps it from scripts. Tomcat marks it Secure on every request +# it sees as secure (behind a TLS-terminating proxy only with server.forward-headers-strategy); +# DIRIGIBLE_SESSION_COOKIE_SECURE=true forces it where the proxy does not forward the scheme. +server.servlet.session.cookie.same-site=${DIRIGIBLE_SESSION_COOKIE_SAMESITE:lax} +server.servlet.session.cookie.http-only=true +server.servlet.session.cookie.secure=${DIRIGIBLE_SESSION_COOKIE_SECURE:false} + spring.main.allow-bean-definition-overriding=true # Surface the reason of a ResponseStatusException in the JSON error body - without it every # REST error reaches API callers and UIs as a bare status phrase with no actionable detail. diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/EndpointAuthorizationIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/EndpointAuthorizationIT.java index 009dedb6bf6..25a9ea9cc99 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/EndpointAuthorizationIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/EndpointAuthorizationIT.java @@ -10,13 +10,17 @@ package org.eclipse.dirigible.integration.tests.api; import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsStringIgnoringCase; import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertNotNull; import org.eclipse.dirigible.components.base.http.roles.Roles; import org.eclipse.dirigible.tests.base.IntegrationTest; import org.eclipse.dirigible.tests.framework.restassured.RestAssuredExecutor; import org.eclipse.dirigible.tests.framework.security.SecurityUtil; import org.junit.jupiter.api.Test; +import io.restassured.http.ContentType; +import io.restassured.response.Response; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.annotation.DirtiesContext; @@ -39,6 +43,9 @@ class EndpointAuthorizationIT extends IntegrationTest { private static final String PLAIN_USER = "endpoint-authz-it-user"; private static final String ADMIN_USER = "endpoint-authz-it-admin"; private static final String PASSWORD = "endpoint-authz-it-password"; + private static final String FOREIGN_ORIGIN = "https://evil.example.org"; + /** The shape of a generated create endpoint - the filter refuses before any handler resolves it. */ + private static final String GENERATED_CREATE_ENDPOINT = "/services/ts/endpoint-authz-it/gen/invoices/api/Invoice/InvoiceController.ts"; /** * Administrative surfaces that must never answer a role-less but authenticated caller. @@ -147,4 +154,91 @@ void task_variables_are_not_readable_for_a_foreign_task() { .statusCode(403), PLAIN_USER, PASSWORD); } + + /** + * A page of another site that a logged in user visits can post a form to a generated create + * endpoint, and the browser attaches the session cookie: CSRF tokens are disabled on every chain. + * The cross-site request filter refuses such a write on the browser's own word - + * {@code Sec-Fetch-Site}, or {@code Origin} where an older browser sends no fetch metadata - before + * any endpoint or authentication sees it, while the same write from the platform's own page passes + * (#7644). + */ + @Test + void a_cross_site_form_post_with_the_session_cookie_is_refused() { + securityUtil.ensureUserInDefaultTenant(ADMIN_USER, PASSWORD, Roles.RoleNames.ADMINISTRATOR); + String session = formLoginSession(ADMIN_USER, PASSWORD); + + given().cookie("JSESSIONID", session) + .header("Sec-Fetch-Site", "cross-site") + .header("Origin", FOREIGN_ORIGIN) + .contentType(ContentType.URLENC) + .formParam("Name", "forged") + .when() + .post(GENERATED_CREATE_ENDPOINT) + .then() + .statusCode(403); + + given().cookie("JSESSIONID", session) + .header("Origin", FOREIGN_ORIGIN) + .contentType(ContentType.URLENC) + .formParam("Name", "forged") + .when() + .post(GENERATED_CREATE_ENDPOINT) + .then() + .statusCode(403); + + given().cookie("JSESSIONID", session) + .header("Sec-Fetch-Site", "same-origin") + .contentType(ContentType.JSON) + .body("{\"Name\":\"own\"}") + .when() + .post(GENERATED_CREATE_ENDPOINT) + .then() + .statusCode(not(403)); + } + + /** + * The session cookie keeps itself out of cross-site posts and scripts, and every response tells the + * browser not to frame the page for another site, not to sniff it and not to leak its URL (#7644). + */ + @Test + void the_session_cookie_and_the_responses_carry_the_browser_protections() { + securityUtil.ensureUserInDefaultTenant(ADMIN_USER, PASSWORD, Roles.RoleNames.ADMINISTRATOR); + + Response login = formLogin(ADMIN_USER, PASSWORD); + login.then() + .header("Set-Cookie", containsStringIgnoringCase("SameSite=Lax")) + .header("Set-Cookie", containsStringIgnoringCase("HttpOnly")); + + given().when() + .get("/index.html") + .then() + .header("X-Frame-Options", "SAMEORIGIN") + .header("Referrer-Policy", "strict-origin-when-cross-origin") + .header("X-Content-Type-Options", "nosniff"); + // an authenticated answer carries them as well + restAssuredExecutor.execute(() -> given().when() + .get("/services/core/configurations") + .then() + .statusCode(200) + .header("X-Frame-Options", "SAMEORIGIN") + .header("Referrer-Policy", "strict-origin-when-cross-origin"), + ADMIN_USER, PASSWORD); + } + + private static Response formLogin(String user, String password) { + return given().redirects() + .follow(false) + .contentType(ContentType.URLENC) + .formParam("username", user) + .formParam("password", password) + .when() + .post("/login"); + } + + private static String formLoginSession(String user, String password) { + String session = formLogin(user, password).getCookie("JSESSIONID"); + assertNotNull(session, "the form login answers with the session cookie"); + return session; + } }