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:
+ *
+ * - {@code Sec-Fetch-Site}, sent by every current browser and not settable by a script: anything
+ * but {@code same-origin} or {@code none} (the user's own navigation) is cross-site - including
+ * {@code same-site}, since a sibling subdomain may be another tenant's;
+ * - where it is missing, {@code Origin}: {@code null}, or a host other than the one the request
+ * was addressed to ({@code X-Forwarded-Host}, else {@code Host}), is cross-site;
+ * - without either header the request does not come from a browser page and passes - a
+ * server-side client sends neither.
+ *
+ * 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;
+ }
}