Skip to content

refactor(cors): reject cross-origin relay handshakes and drop wildcard def… - #1236

Open
sudhir-intc wants to merge 1 commit into
mainfrom
cm-323
Open

refactor(cors): reject cross-origin relay handshakes and drop wildcard def…#1236
sudhir-intc wants to merge 1 commit into
mainfrom
cm-323

Conversation

@sudhir-intc

@sudhir-intc sudhir-intc commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem :

The KVM/SOL/IDER relay accepted WebSocket connections from any origin, enabling Cross-Site WebSocket Hijacking: a malicious page could open a relay using a session the victim's browser attaches automatically. Separately, the allowed-origins setting defaulted to *, so the API answered every request with Access-Control-Allow-Origin: *, letting any website read API responses.

Changes

  • The relay now validates the Origin header against the configured allowlist instead of accepting everything.
  • A wildcard is deliberately not honored for the relay. No website has a legitimate reason to relay someone else's KVM session, so a wildcard or empty allowlist degrades to same-origin only rather than allow-all. This closes the hijack on existing installs that still have * in their config file, without anyone having to edit it.
  • Same-origin requests are always accepted, matching what the CORS middleware already does, so the built-in UI keeps working no matter what the allowlist says.
  • Hostnames are compared case-insensitively, consistent with the CORS middleware. Previously a mixed-case allowlist entry would pass CORS but fail the relay — REST calls working while KVM silently broke.
  • A missing Origin header is accepted for non-browser clients such as rpc-go and CLI tooling. Browsers always send Origin on a WebSocket handshake, so this isn't a bypass, and the redirection token is still required regardless.
  • Opaque origins are rejected — null from a sandboxed iframe, and data:/file: pages.
  • A warning is logged at startup if the allowlist still contains *.
  • Allowed headers are now an explicit list (Origin, Accept, Content-Type, Content-Length, Authorization, If-Match) instead of *. Browsers take * literally once credentials are enabled, and it never covered Authorization even without them.
  • Credentials stay enabled, which the UI needs for its HttpOnly session cookie. This is safe because credentials are still forced off whenever the allowlist contains * — the combination browsers reject anyway.
  • An empty allowlist is now rejected at startup with a clear.
  • The wildcard is removed from the shipped config.yml, .env.example
  • Vary: Origin required no code change — the CORS library emits it automatically once the wildcard is gone.

Compatibility.

Same-origin deployments are unaffected: the CORS middleware exempts same-origin before validating, so a Console reached at its own LAN address is not rejected by a localhost-only allowlist.

Here's some tests to check the functionality against main

Verification with curl

Expected outputs below are derived from reading the CORS library, the WebSocket library, and the relay handler — they have not been run live. Header order and letter case can shift a little between HTTP versions and Go releases; the lines themselves are the assertion.

Tests 1–3 need auth.disabled: true, which skips the redirection-token check so curl can reach the origin check. Otherwise you need a token signed with auth.jwtKey whose deviceId matches ?host=, sent in Sec-Websocket-Protocol.

Config L (what main ships). The wildcard matters here: under it the CORS middleware passes everything, so the request reaches the WebSocket upgrader and the test isolates the origin check.

http:
  host: 127.0.0.1
  port: "8181"
  allowed_origins: ["*"]
  allowed_headers: ["*"]
  tls:
    enabled: true
    certFile: ""   # empty -> Console generates a self-signed cert at startup
    keyFile: ""
auth:
  disabled: true

Config F (this PR): same, but allowed_origins: ["http://localhost:4200"], the explicit allowed_headers list, and allow_credentials: true.

Three things apply to every command below.

  • -k — with certFile/keyFile empty Console generates a self-signed certificate at startup, which curl would otherwise refuse.
  • --http1.1 on the WebSocket tests (1–3) — the upgrade relies on the Connection and Upgrade headers, which HTTP/2 forbids, and Console negotiates h2 over TLS by default. Without the flag the handshake never reaches the origin check. Tests 4–6 run over h2, which is why curl prints their header names in lowercase.
  • A grep filter so only the deciding lines show. On a successful upgrade curl prints 101 and then holds the connection until --max-time 3 expires, exiting 28 — that timeout is expected, not a failure.

1. Cross-Site WebSocket Hijacking (CM-323) — Config L

curl -k -i -s --http1.1 --max-time 3 \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  -H 'Origin: https://evil.example' \
  'https://127.0.0.1:8181/relay/webrelay.ashx?host=00000000-0000-0000-0000-000000000000&mode=kvm' \
  | grep -iE '^HTTP/|^sec-websocket|^upgrade:|^connection:|^forbidden|^could not'

main — any website can open the relay:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

this PR — refused:

HTTP/1.1 403 Forbidden
Sec-Websocket-Version: 13
Forbidden
Could not open websocket connection

The body carries two lines because the WebSocket library writes Forbidden with the 403, then the handler appends its own message after the status is already committed.

2. Same-origin relay still works — Config L (regression guard)

curl -k -i -s --http1.1 --max-time 3 \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  -H 'Origin: https://127.0.0.1:8181' \
  'https://127.0.0.1:8181/relay/webrelay.ashx?host=00000000-0000-0000-0000-000000000000&mode=kvm' \
  | grep -iE '^HTTP/|^sec-websocket|^upgrade:|^connection:|^forbidden|^could not'

Identical on both builds. This is the one that proves the hardening doesn't
break the built-in UI:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

3. Origin: null — Config L

Test 1 with -H 'Origin: null'. This is what sandboxed iframes and data:/file: pages send, and the header in the pentest evidence.

main returns the same 101 block as test 1; this PR returns the same 403 block.

4. Wildcard CORS (#6801) — Config L vs F

# Preflight against the reported endpoint
curl -k -i -s -X OPTIONS \
  -H 'Origin: https://evil.example' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type' \
  https://127.0.0.1:8181/api/v1/admin/ieee8021xconfigs \
  | grep -iE '^HTTP/|^access-control|^vary'

main (Config L) — reproduces the finding. Note access-control-allow-origin: *
and the complete absence of a vary: line:

HTTP/2 204
access-control-allow-headers: *
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS
access-control-allow-origin: *
access-control-max-age: 43200

this PR (Config F) — rejected outright, no CORS headers at all:

HTTP/2 403

The same split shows on a normal request:

curl -k -i -s -H 'Origin: https://evil.example' \
  https://127.0.0.1:8181/healthz \
  | grep -iE '^HTTP/|^access-control|^vary'

main (Config L):

HTTP/2 200
access-control-allow-origin: *

this PR (Config F):

HTTP/2 403

5. Allowed origin gets the complete header set — Config F

curl -k -i -s -X OPTIONS \
  -H 'Origin: http://localhost:4200' \
  -H 'Access-Control-Request-Method: PATCH' \
  -H 'Access-Control-Request-Headers: content-type,if-match' \
  https://127.0.0.1:8181/api/v1/admin/ieee8021xconfigs \
  | grep -iE '^HTTP/|^access-control|^vary'
HTTP/2 204
access-control-allow-credentials: true
access-control-allow-headers: Origin,Accept,Content-Type,Content-Length,Authorization,If-Match
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS
access-control-allow-origin: http://localhost:4200
access-control-max-age: 43200
vary: Origin
vary: Access-Control-Request-Method
vary: Access-Control-Request-Headers

PATCH and If-Match are both present — the two the hand-rolled middleware
would have dropped, breaking 8 routes and every optimistic-concurrency update.

6. Vary: Origin — cache poisoning

curl -k -i -s -H 'Origin: http://localhost:4200' \
  https://127.0.0.1:8181/healthz \
  | grep -iE '^HTTP/|^access-control|^vary'

main (Config L) — a shared cache can serve this to any other site:

HTTP/2 200
access-control-allow-origin: *

this PR (Config F) — origin echoed, and vary tells caches the response is
origin-dependent:

HTTP/2 200
access-control-allow-credentials: true
access-control-allow-origin: http://localhost:4200
vary: Origin

7. Mixed-case allowlist — correctness check

Not a vulnerability demo: main has no relay origin check at all, so there is
nothing to compare against. This only confirms the new allowlist is robust. With
allowed_origins: ["https://Console.Example:8181"], a request carrying
Origin: https://console.example:8181 is accepted by both the API and the relay
on this PR — previously such an entry passed CORS but failed the relay.

@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.44444% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 51.02%. Comparing base (ec2b707) to head (3a105b5).

Files with missing lines Patch % Lines
internal/app/app.go 90.24% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1236      +/-   ##
==========================================
+ Coverage   50.82%   51.02%   +0.19%     
==========================================
  Files         149      149              
  Lines       13872    13930      +58     
==========================================
+ Hits         7051     7108      +57     
- Misses       6217     6218       +1     
  Partials      604      604              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens Console’s CORS and WebSocket relay behavior to mitigate cross-site WebSocket hijacking and overly-permissive CORS defaults, while keeping same-origin behavior working for the embedded UI and non-browser clients.

Changes:

  • Adds an Origin allowlist check to the KVM/SOL/IDER relay WebSocket upgrader (rejecting opaque origins and cross-origin by default).
  • Removes wildcard CORS defaults and introduces explicit default origins/headers with credentials supported for enumerated origins.
  • Adds config validation and tests around CORS/Origin behavior and safer defaults.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/app/app.go Adds relay Origin validation and adjusts CORS credential handling when * is present.
internal/app/app_test.go Adds tests for the relay origin checker and CORS header behavior.
config/config.yml Updates shipped default CORS origins/headers and documents why * is unsafe.
config/config.go Updates in-memory defaults, adds validation for empty allowed_origins.
config/config_test.go Updates default expectations and adds validation tests for allowed origins.
.env.example Updates example env vars for explicit CORS origins/headers and credentials.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread config/config_test.go
Comment thread internal/app/app.go
Comment thread config/config.yml
Comment thread .env.example Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/app/app.go:183

  • parseOrigin currently accepts URLs with a non-empty path/query/fragment (e.g. "https://allowed.example/path"), and normalizeOrigin then silently drops the path when building the comparison key. Origins per RFC 6454 are strictly scheme://host[:port] (no userinfo/query/fragment, and no path beyond an optional trailing "/"), so this behavior can make a misconfigured allowlist more permissive than intended and can diverge from the CORS middleware’s exact Origin matching.
	originURL, err := url.Parse(origin)
	if err != nil || originURL.Scheme == "" || originURL.Host == "" {
		return nil
	}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread internal/app/app.go
@sudhir-intc
sudhir-intc force-pushed the cm-323 branch 2 times, most recently from 8f2ff8a to d51b54a Compare September 2, 2026 04:06
fix(cors): reject cross-origin relay handshakes and drop wildcard default

The KVM/SOL/IDER relay accepted websocket connections from any origin,
enabling Cross-Site WebSocket Hijacking, and allowed_origins defaulted to
"*", so the API answered every request with Access-Control-Allow-Origin: *.

- Validate the relay's Origin header against the configured allowlist.
- Do not honor "*" for the relay: a wildcard or empty allowlist degrades to
  same-origin only, which closes the hijack on installs that still carry "*"
  on disk. Same-origin is always accepted, so the embedded UI is unaffected.
  Opaque origins ("null", data:, file:) are rejected.
- Compare hosts case-insensitively, matching the CORS middleware. A
  mixed-case entry previously passed CORS but failed the relay.
- Replace the "*" allowed_headers with an explicit list, and reject an empty
  allowed_origins at startup instead of panicking in the CORS library.
- Remove the wildcard from the shipped config.yml and .env.example, and warn
  at startup if it is still configured.

Deployments serving the UI from a separate origin while relying on "*" must
now list that origin explicitly. Same-origin deployments are unaffected.

Co-Authored-By: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@sudhir-intc
sudhir-intc marked this pull request as ready for review September 2, 2026 04:48
@sudhir-intc
sudhir-intc requested a review from a team as a code owner September 2, 2026 04:48
@sudhir-intc sudhir-intc changed the title fix(cors): reject cross-origin relay handshakes and drop wildcard def… refactor(cors): reject cross-origin relay handshakes and drop wildcard def… Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants