Skip to content

Fix CORS implementation: validate origin, short-circuit preflights, add Vary#1105

Merged
ggallotti merged 1 commit intogeai-release-stablefrom
fix/cors-implementation
Apr 28, 2026
Merged

Fix CORS implementation: validate origin, short-circuit preflights, add Vary#1105
ggallotti merged 1 commit intogeai-release-stablefrom
fix/cors-implementation

Conversation

@ggallotti
Copy link
Copy Markdown
Member

@ggallotti ggallotti commented Apr 28, 2026

Summary

The CORS implementation in wrappercommon and the servlet/JAX-RS filters had several bugs and spec violations. This PR rewrites CORSHelper and updates all four filters (javax + jakarta, servlet + JAX-RS) and adds unit tests.

Companion PR for the release branch: #1105.

Bugs fixed

  • Preflights weren't short-circuited. The servlet CorsFilter set CORS headers and then always called filterChain.doFilter(...), so OPTIONS requests flowed down to handlers that didn't accept them (typically 405). Now valid preflights return 204 No Content immediately.
  • Access-Control-Allow-Origin: * was combined with Access-Control-Allow-Credentials: true. Browsers reject that combination per the CORS spec. Credentials are now only emitted when echoing a real origin.
  • Origin was never read or validated. The filter blindly returned the configured value regardless of the request. CORSHelper now reads Origin and validates it against the configured allowlist (single origin or comma-separated list).
  • Vary: Origin was missing. Caches/CDNs could serve a CORS response from one origin to a different origin. Now emitted whenever the response varies on Origin.
  • CORS headers were emitted on every response. Same-origin requests received them too. Now headers are only emitted when the request actually has an Origin that's in the allowlist.
  • Access-Control-Max-Age and Allow-Methods/Allow-Headers were sent on actual responses. They only make sense on preflights. Moved to preflight-only.
  • JAX-RS preflights had no request filter. JAXRSCorsFilter was a ContainerResponseFilter only, so preflights still went through resource matching. Added @PreMatching ContainerRequestFilter behavior that aborts preflights with 204 + CORS headers before resource matching runs.
  • Misc. Made constants final, added null-safe handling for httpMethod, made Map-based header lookup case-insensitive (HTTP headers are case-insensitive).

Configuration

Properties are resolved in this order (via IniFile + EnvVarReader):

  1. Environment variable (highest priority).
  2. confmapping.json remapping (if present in WEB-INF/), which lets you bind a property to a custom env var name.
  3. client.cfg, section [Client].
Env var client.cfg key (section [Client]) Default Description
GX_CORS_ALLOW_ORIGIN CORS_ALLOW_ORIGIN (empty → CORS disabled) Allowed origin(s). Accepts:
* — any origin (without credentials, per spec)
https://app.example — a single origin
https://a.example,https://b.example — comma-separated allowlist

Behavior

  • CORS disabled when the property is empty/unset — no headers are emitted, OPTIONS flows through the chain unchanged.
  • CORS enabled when set:
    • Requests without an Origin header are treated as same-origin and receive no CORS headers.
    • Requests with an Origin not in the allowlist receive no CORS headers (the browser then blocks the response).
    • Requests with an allowed Origin receive Access-Control-Allow-Origin echoing the matched origin (or * when configured), Vary: Origin (omitted for *), and Access-Control-Allow-Credentials: true (omitted for *).
    • Preflight (OPTIONS) also receives Access-Control-Max-Age: 86400, plus Access-Control-Allow-Methods / Access-Control-Allow-Headers reflecting the requested values, and the response is short-circuited with 204 No Content.

Notes on Access-Control-Max-Age = 86400

The implementation hardcodes 86400 (1 day). Browsers cap this:

  • Firefox: 86400
  • Chromium: 7200 (2h) since 2020
  • Safari: 600 (10 min)

So the effective cache lifetime is browser-determined; the configured value is just an upper bound. Not currently exposed as a property — can be added on request.

Tests

New CORSHelperTest (15 cases) covering:

  • Disabled / null / empty config
  • No Origin header → no headers emitted
  • Origin not in allowlist → no headers emitted
  • Single allowed origin (simple GET) and preflight (OPTIONS)
  • Wildcard never combined with credentials, no Vary for *
  • Comma-separated allowlist matching/non-matching
  • JAX-RS map overload with case-insensitive header lookup
  • isPreflight semantics
  • Null httpMethod doesn't throw

All tests pass locally (java org.junit.runner.JUnitCore com.genexus.cors.CORSHelperTestOK (15 tests)).

Test plan

  • CI runs the new CORSHelperTest.
  • Smoke test a deployed app with GX_CORS_ALLOW_ORIGIN=https://foo.example:
    • Same-origin request: no CORS headers in response.
    • Cross-origin GET from https://foo.example: response has Access-Control-Allow-Origin: https://foo.example, Vary: Origin, Allow-Credentials: true.
    • Cross-origin GET from https://bar.example: no CORS headers (browser blocks).
    • OPTIONS preflight from https://foo.example: 204 + Allow-Methods + Max-Age + Allow-Headers.
  • Smoke test with GX_CORS_ALLOW_ORIGIN=*: response has Allow-Origin: * and no Allow-Credentials.

…dd Vary

- CORSHelper now validates the request Origin against a configured allowlist
  (single origin or comma-separated list) and only emits CORS headers when
  the origin is allowed. Adds Vary: Origin to keep caches correct.
- '*' is no longer combined with Allow-Credentials (forbidden by the spec).
- Access-Control-Max-Age and Allow-Methods/Allow-Headers are emitted only on
  preflight (OPTIONS) responses.
- Servlet CorsFilter (javax + jakarta) now short-circuits valid preflights
  with 204 instead of letting them flow through to downstream handlers
  (which usually returned 405).
- JAXRSCorsFilter (javax + jakarta) now also implements a @PreMatching
  ContainerRequestFilter that aborts preflights with 204 + CORS headers
  before resource matching runs.
- Adds CORSHelperTest with 15 tests covering disabled mode, allowlist
  matching, wildcard semantics, preflight vs simple, Vary, Max-Age and
  case-insensitive header lookup.
@ggallotti ggallotti merged commit d040551 into geai-release-stable Apr 28, 2026
1 check passed
@ggallotti ggallotti deleted the fix/cors-implementation branch April 28, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants