Skip to content

security: refuse cross-site writes, SameSite session cookie, browser security headers on every chain (#7644) - #7683

Open
delchev wants to merge 1 commit into
masterfrom
issue-7644-csrf-samesite-headers
Open

delchev wants to merge 1 commit into
masterfrom
issue-7644-csrf-samesite-headers

Conversation

@delchev

@delchev delchev commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Cause

Every security chain (basic, cognito, keycloak, github, snowflake) runs csrf.disable(), and no IDE or Harmonia client sends a token. The session cookie had no SameSite. So a page of another site that a logged-in user visited could post a form to a generated POST/PUT/DELETE .../gen/.../api/... endpoint, and the browser would attach the user's session. X-Frame-Options was disabled on four of the five chains.

Change

1. Cross-site writes are refused, with no token and no client change. CrossSiteRequestFilter sits in the CSRF filter's slot on every chain, so it runs after CORS and before logout and authentication. It refuses with 403 a POST/PUT/PATCH/DELETE that carries an ambient credential when either:

  • Sec-Fetch-Site is anything other than same-origin or none (same-site counts as cross-site, because a sibling subdomain may belong to another tenant), or
  • the browser sends no fetch metadata (an older browser) and Origin is null or names a host other than X-Forwarded-Host/Host.

An ambient credential is a session id, or HTTP authentication other than Bearer. Browser-remembered Basic credentials count.

These pass: a request with neither header (a server-side client), a bearer token, and an origin from DIRIGIBLE_CORS_ALLOWED_ORIGINS that names a host (the wildcard trusts nobody). The escape hatch is DIRIGIBLE_SECURITY_CROSS_SITE_PROTECTION=false.

This departs from the issue's item 2. The issue asks for CookieCsrfTokenRepository plus X-XSRF-TOKEN in every client. Fetch Metadata / Origin verification is the OWASP-listed alternative. It closes the same hole without touching the hundreds of fetch/$http call sites in the IDE and Harmonia, and it cannot break a client that forgets the header. If the token pattern is still wanted on top, it can be added later behind this filter.

2. Session cookie SameSite=Lax; HttpOnly, set in application-common.properties:

  • DIRIGIBLE_SESSION_COOKIE_SAMESITE overrides SameSite.
  • Tomcat already adds Secure on any request it sees as secure. DIRIGIBLE_SESSION_COOKIE_SECURE=true forces it behind a proxy that does not forward the scheme.

3. Headers are decided in one place. BrowserSecurityConfigurator is a CustomSecurityConfigurator, so it reaches every chain, and the per-chain frameOptions lines are gone:

Header Default Setting
X-Frame-Options SAMEORIGIN DIRIGIBLE_SECURITY_FRAME_OPTIONS = SAMEORIGIN / DENY / DISABLED
Referrer-Policy strict-origin-when-cross-origin DIRIGIBLE_SECURITY_REFERRER_POLICY
Strict-Transport-Security on secure requests DIRIGIBLE_SECURITY_HSTS = auto / always / off
Content-Security-Policy not sent DIRIGIBLE_SECURITY_CONTENT_SECURITY_POLICY, report-only by default (..._REPORT_ONLY)
Permissions-Policy not sent DIRIGIBLE_SECURITY_PERMISSIONS_POLICY

X-Content-Type-Options: nosniff stays from Spring's defaults. No CSP is sent by default: I have no policy that is proven against Monaco and the Harmonia shells, and report-only violations would show up as console errors in downstream UI suites.

4. ZAP: the full scans in build.yml and release.yml now authenticate as the default basic user (ZAP_AUTH_HEADER*). They stay report-only, as the issue asks for the first release.

Verified

  • core-base unit suites green (16 suites): CrossSiteRequestFilterTest (15 cases) and BrowserSecurityConfiguratorTest (7 cases, MockMvc against a real chain shaped like the platform's).
  • EndpointAuthorizationIT gains two cases:
    • A form-login session cookie plus Sec-Fetch-Site: cross-site, or a foreign Origin without fetch metadata, POSTed to a generated create endpoint returns 403, and the log shows CrossSiteRequestFilter refusing it. The same post with same-origin gets past the filter.
    • Set-Cookie carries HttpOnly; SameSite=Lax. Anonymous and authenticated responses carry X-Frame-Options: SAMEORIGIN, Referrer-Policy and nosniff.
  • Green locally: EndpointAuthorizationIT, SecurityIT, ExternalFrontendIT (38 tests, keycloak and CORS with an external front end), StompCrossOriginCredentialsIT (56/56 together). Also the browser-driven CreateNewProjectIT, CreateNewFileIT, ModelGenerationIT and GeneratedShellTenantUsersIT in real headless Chrome. Those cover the IDE flows the old snowflake comment said CSRF would break, and no request was refused.
  • formatter:validate green with the cache wiped.

Not verified / left open

  • The Cognito, GitHub and Snowflake chains are covered by the shared configurator but were not booted. Snowflake in particular: if Snowsight frames the app, set DIRIGIBLE_SECURITY_FRAME_OPTIONS=DISABLED.
  • No ZAP run has been attached yet; that happens on the next CI run of this PR.
  • Still open from item 4: pointing ZAP at a published sample intent project instead of the IDE, fail_action on High, and a baseline scan on PRs.

Fixes #7644

🤖 Generated with Claude Code

…security headers on every chain (#7644)

Every chain disabled CSRF and no client sends a token, so a page of another
site could post a form to a generated create endpoint with the logged in
user's session cookie.

- CrossSiteRequestFilter, in the CSRF filter's slot of every chain via the new
  BrowserSecurityConfigurator: a POST/PUT/PATCH/DELETE carrying an ambient
  credential (session id, or non-Bearer HTTP authentication) is refused with
  403 when Sec-Fetch-Site is not same-origin/none, or - without fetch
  metadata - when Origin is null or names another host. Server-side clients,
  bearer tokens and CORS origins that name a host pass. No client change.
  DIRIGIBLE_SECURITY_CROSS_SITE_PROTECTION=false is the escape hatch.
- Session cookie SameSite=Lax; HttpOnly (DIRIGIBLE_SESSION_COOKIE_SAMESITE,
  DIRIGIBLE_SESSION_COOKIE_SECURE).
- Headers decided in one place instead of per chain: X-Frame-Options
  SAMEORIGIN (was disabled on basic, cognito, github, snowflake),
  Referrer-Policy strict-origin-when-cross-origin, HSTS auto|always|off,
  opt-in CSP (report-only by default) and Permissions-Policy.
- ZAP full scans authenticate as the default basic user (still report-only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
// the shape of every platform chain: tokens off, frames left open
http.csrf(csrf -> csrf.disable())
.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())
+ " 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);
Comment on lines +96 to +101
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.",
request.getMethod(), request.getRequestURI(), request.getHeader(HttpHeaders.ORIGIN), SEC_FETCH_SITE,
request.getHeader(SEC_FETCH_SITE), DirigibleConfig.CORS_ALLOWED_ORIGINS.getKey());

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants