Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/docs/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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<HttpSecurity> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>
* Configured, the listed origins get exactly what the configuration grants, and the OAuth2 login
Expand Down Expand Up @@ -235,8 +235,9 @@

private static void warnAboutRisks(List<String> 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<String> insecureOrigins = origins.stream()
.filter(origin -> !isTransportSecure(origin))
Expand Down
Loading
Loading