Skip to content

[Security] Fix CSWSH: same-origin WebSocket Origin check by default - #766

Merged
sdogruyol merged 1 commit into
masterfrom
fix/cswsh
Aug 5, 2026
Merged

[Security] Fix CSWSH: same-origin WebSocket Origin check by default#766
sdogruyol merged 1 commit into
masterfrom
fix/cswsh

Conversation

@sdogruyol

Copy link
Copy Markdown
Member

Summary

This PR fixes Cross-Site WebSocket Hijacking (CSWSH) by making WebSocket Origin validation secure by default.

Previously, an empty Kemal.config.websocket_allowed_origins (the default) accepted all origins. A malicious site could open a WebSocket to a Kemal app from a victim’s browser; cookies were still sent with the handshake, so cookie-authenticated sockets were exposed.

New behavior

websocket_allowed_origins Policy
[] (default) Same-origin: Origin must match the request Host. Missing/empty/null Origin → 403 Forbidden.
Explicit list Unchanged: Origin must match an entry after normalization. Missing Origin → 403.
Includes "*" Opt-in allow-all (previous default), including requests without Origin (curl/wscat/native clients).

Same-origin matching uses the scheme from Origin and the host/port from Host. That way TLS termination in front of Kemal still works (Origin: https://app.example.com + Host: app.example.com succeeds even when Kemal itself is plain HTTP).

# Default — same-origin (secure)
# Kemal.config.websocket_allowed_origins = [] of String

# Cross-origin frontend
Kemal.config.websocket_allowed_origins = ["https://myapp.com", "http://localhost:3000"]

# Previous allow-all behavior (explicit opt-in)
Kemal.config.websocket_allowed_origins = ["*"]

No Config property type changes. normalize_websocket_origin / reject_websocket_forbidden! are unchanged in role; same-origin logic lives next to the existing allowlist check in WebSocketHandler.

Alternate Designs

  1. Keep empty = allow-all, document the risk — Rejected. That leaves CSWSH as the default for every app that never sets the allowlist.
  2. Compare against Kemal.config.scheme + Host — Rejected. Under a reverse-proxy TLS terminator config.scheme is usually "http" while the browser sends https://…, causing false 403s for legitimate same-site clients.
  3. Require an explicit allowlist always (no same-origin default) — Rejected. Too breaking for the common same-page WebSocket case; Rails/Phoenix/Spring all treat same-origin as the safe default.
  4. Chosen: same-origin by default + allowlist + "*" escape hatch — Matches Action Cable / Phoenix / Spring posture, keeps same-origin browser apps working, and gives a one-line rollback for non-browser clients.

Benefits

  • Closes the CSWSH gap for cookie-backed WebSockets without requiring every app to discover and configure an allowlist first.
  • Proxy-friendly same-origin check (scheme from Origin, authority from Host).
  • Clear migration path: cross-origin SPAs set an allowlist; tools that omit Origin use ["*"].
  • Documented in Config comments and CHANGELOG (Unreleased).

Possible Drawbacks

  • Behavior change (security-motivated): apps that relied on empty allowlist = allow-all will start seeing 403 for:
    • cross-origin browser clients, or
    • clients that send no Origin (curl, wscat, many native clients).
  • Mitigation is one line: set websocket_allowed_origins to the real origins, or ["*"] if the old open policy is intentional.
  • Same-origin browser apps and apps that already configured an allowlist are unaffected.
  • Origin checks only mitigate browser CSWSH; native clients can forge Origin. Authentication on the socket remains necessary.

@YasinSeyhun

Copy link
Copy Markdown

Hi @sdogruyol,

Thanks for the fast turnaround. I pulled fix/cswsh locally (commit 7600f31) and verified the fix with the PR's own spec suite, a before/after comparison against master, and manual handshake tests against a local Kemal app.

Spec suite (fix/cswsh):

$ crystal spec spec/websocket_handler_spec.cr
...................
Finished in 9.13 milliseconds
19 examples, 0 failures, 0 errors, 0 pending

$ crystal spec spec/router_spec.cr
........................
Finished in 11.91 milliseconds
24 examples, 0 failures, 0 errors, 0 pending

Confirms all scenarios described in the PR (same-origin, TLS-terminated proxy, port mismatch, allowlist match, wildcard, missing/empty/null Origin) are covered by automated tests with no regressions — routing behavior is unaffected too.

Before/after comparison, same cross-origin request (Origin: https://evil.example, default config, no websocket_allowed_origins set):

On master (pre-fix):

HTTP/1.1 101 Switching Protocols

Confirms the vulnerability is real — the handshake completes and the socket is hijackable.

On fix/cswsh (patched):

HTTP/1.1 403 Forbidden

Confirms the fix closes it.

Additional manual verification on fix/cswsh, default config:

Missing Origin -> rejected:

HTTP/1.1 403 Forbidden

Same-origin Origin -> accepted:

HTTP/1.1 101 Switching Protocols
connected as session=anonymous

websocket_allowed_origins = ["*"]:

$ curl -H "Origin: https://anything.com" ...
HTTP/1.1 101 Switching Protocols

Old allow-all behavior still available as an explicit opt-in.

websocket_allowed_origins = ["https://myapp.com"]:

$ curl -H "Origin: https://myapp.com" ...
HTTP/1.1 101 Switching Protocols

Explicit allowlist behaves as documented.

Everything matches the PR description exactly. This closes the CSWSH gap — default is now secure, and same-origin/allowlist/wildcard all work as expected.

One question for discussion (not a blocker): the same-origin check trusts the request's Host header for the comparison. In a typical browser attack that's fine since the browser sets both Origin and Host, but is there any guidance you'd want to add for deployments where a misconfigured reverse proxy might forward an untrusted Host? Might be worth a line in the docs either way.

Thanks again for closing the gap so quickly. 🙏

@sdogruyol
sdogruyol merged commit 385b58c into master Aug 5, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants