skip to content

Origin, TLS, and Proxies

Hardening one server's sockets: validating the Origin header, terminating wss, and getting the upgrade past a reverse proxy. Interviewers probe it because a proxy silently breaks the socket.

part ofWebSocketsoverview, primer and where to startread it →
on this pageshow

questions

5

Why can a page on an attacker's domain open a WebSocket to your server with the victim's cookies attached, and what stops it?

level: middleimportance: must knowfreq 70%

answer

  1. the upgrade is an ordinary request
  2. browsers guard responses, not requests
  3. one request field carries the caller
  4. refuse before the 101
  5. whole-string allowlist, never a prefix

basics

~20 s

Nothing in the browser blocks a cross-site WebSocket upgrade, and cookies for your host may ride along, so the socket is authenticated but unintended. The server must check the Origin request field against an allowlist and refuse the handshake.

solid answer

~50 s

A WebSocket connection opens as an ordinary HTTP/1.1 `GET` carrying `Upgrade: websocket` and `Connection: Upgrade`, so the browser sends it like any other request to that host and may attach the cookies stored for it, held back only by a cookie's own same-site attribute. The cross-origin machinery that guards a scripted HTTP request protects the *response* a page is allowed to read; it does not stop this request from arriving, and there is no response opt-in for an upgrade. `RFC 6455` closes the hole differently: a browser client must send the `Origin` request field, a server not meant to serve arbitrary pages should verify it, and a server that finds the origin unacceptable must refuse with `403 Forbidden`. Compare the whole scheme-host-port string against a short allowlist and refuse before you send `101 Switching Protocols`.

code

http · 10 lines
http
GET /occupancy HTTP/1.1
Host: board.example
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://not-the-console.attacker.test

HTTP/1.1 403 Forbidden
Content-Length: 0

go deeper

for a junior

Remember that a WebSocket starts as a normal HTTP request, and that the browser sends it to your host with the cookies it already holds for that host.

for a middle

Explain why the cross-origin machinery does not apply — it guards the response a script may read, not the arrival of the request — and name the Origin request field as what the server checks instead.

for a senior

Show the refusal landing on the handshake with 403 Forbidden rather than as a close after acceptance, and call out whole-string matching and the absent-field policy as the parts that are usually got wrong.

for a principal

Decide where this check belongs across services — an allowlist maintained per endpoint drifts, so argue for one shared origin policy and a logged refusal metric over each team writing its own comparison.

## What actually happens when a page opens a socket A WebSocket connection begins life as a normal HTTP/1.1 request. The client sends a `GET` for the target path carrying `Upgrade: websocket`, `Connection: Upgrade`, a freshly generated `Sec-WebSocket-Key` and `Sec-WebSocket-Version: 13`. A server that accepts it answers `101 Switching Protocols` with the matching `Sec-WebSocket-Accept`, and from that moment the bytes on the connection are WebSocket frames rather than HTTP messages. Because the opening request is a real request aimed at your host, the browser dresses it the way it dresses any other: it fills in `Host`, and it attaches the cookies stored for that host. The one thing that may hold those cookies back is the cookie's own same-site attribute — a cookie the site marked for cross-site use is sent from any page, and a cookie restricted to same-site use is not. You cannot rely on that attribute being set the way you hope, and on the municipal parking-garage occupancy board the session cookie is exactly the credential an operator's console already holds. ## Why the browser does not refuse on your behalf For a scripted cross-origin HTTP request, browsers run a familiar dance: for some requests a preflight goes first, and the response is withheld from the calling script unless the server says that origin may read it. Two properties of that machinery matter here, and neither helps: - it protects the **response**, not the request — the request usually reaches your server either way; - it is defined for fetching HTTP resources, and a protocol upgrade is not one of them. `RFC 6455` took a different route. Rather than a response opt-in, it requires a **browser** client to include the `Origin` request field on the handshake and leaves the judgement entirely to the server: a server not intended to process input from any page **should** verify that field, and when it decides an origin is unacceptable it **must** reply with `403 Forbidden`. That is a decision the specification hands you; if you never wrote the check, you never had one. The result is the cross-site socket hijack. An operator signs into the occupancy console, then visits some other page in another tab. That page opens a socket to your server, the browser attaches the operator's cookie, your server completes the handshake, and the attacker's page now holds a live, authenticated, full-duplex channel into the board — able to read every occupancy update and to send whatever the console could send. ## Validate on the handshake, never after it The refusal has to land while the exchange is still HTTP: 1. read `Origin` from the upgrade request; 2. compare it whole — scheme, host and port together — against a short allowlist you control; 3. on a miss, answer `403 Forbidden` and never send the `101`. Accepting first and closing a moment later is worse than it looks. The handshake succeeded, so any per-connection setup your application does on open has already run; the refusal is now a WebSocket Close control frame rather than an HTTP status, which is harder to alert on; and your access log records a completed upgrade, so the attack does not show up as a rejection anywhere. Two details decide whether the check is real: - **Match the whole string.** A prefix or substring test against `https://console.example` also accepts `https://console.example.attacker.test`, which is a completely different registrable domain that an attacker can register today. - **Decide deliberately what an absent `Origin` means.** A non-browser client is not obliged to send one at all. Treating absence as "trusted" is a hole; treating it as "not a browser, so fall back to the credential check that actually authenticates" is a policy you can defend. ## What the check is and is not | | ordinary cross-origin request | WebSocket upgrade | |---|---|---| | who decides | the browser, from the response | the server, from the request | | what is consulted | the server's response opt-in | the `Origin` request field | | binds a non-browser client | no | no | | what a refusal looks like | the script cannot read the response | `403 Forbidden`, no `101` | The check is a **cross-site** defence: it stops a page the victim's own browser loaded from spending the victim's ambient credentials. It is not authentication, because only a browser is obliged to fill the field in honestly. Both properties are true at once, and an interviewer will usually ask for the second one the moment you have given the first. ## On the occupancy board The board's server keeps one allowlist entry — the operator console's origin — reads `Origin` on every upgrade, refuses everything else with `403 Forbidden`, and logs the refused value. The credential check still runs afterwards, because the `Origin` allowlist is the outer gate and not the lock.

  • Your allowlist test is a prefix match on the console's origin string. What does that let through?
    Any origin that merely starts with it — `https://console.example.attacker.test` passes a prefix test and is a different registrable domain an attacker can register. Compare the whole scheme-host-port triple for equality against a fixed list, and never build the comparison out of substring or suffix tests.
  • An upgrade arrives with no Origin field at all. Is that an attack?
    Not necessarily: only browser clients are obliged to send it, so an absent field usually means a non-browser client. Treat it as "no cross-site evidence either way", fall through to the credential check that actually authenticates, and decide as a policy whether a field-less upgrade is allowed on that endpoint at all.
  • Why is closing the socket right after accepting it a worse refusal than a 403?
    The handshake already succeeded, so per-connection setup has run, the access log records a completed upgrade, and the refusal is now a WebSocket Close control frame rather than an HTTP status — harder to count, alert on, or distinguish from an ordinary disconnect.

saying these in an interview costs you the question

  • Believes cross-origin browser rules block a WebSocket upgrade outright
  • Assumes cookies are never attached to a cross-site upgrade
  • Validates the origin by prefix or substring instead of whole-string equality
  • Accepts the handshake first and closes the socket afterwards
  • Treats an allowlisted Origin as proof of who the client is
  • Expects an opt-in response field to authorize the upgrade the way ordinary requests work
open as a page

A reverse proxy now sits in front of your WebSocket service and clients get 200 OK with an HTML body instead of 101. What happened?

level: seniorimportance: must knowfreq 64%

basics

~20 s

The proxy did not forward the Upgrade and Connection fields. They are connection-scoped, meaningful only on the hop that carries them, so the origin saw a plain GET for that path and answered it normally with the page it serves there.

open as a page

In the WebSocket protocol, what does a wss: URL give you that a ws: URL does not, and where does that protection stop?

level: juniorimportance: should knowfreq 61%

basics

~20 s

Both schemes run the same WebSocket protocol; wss: runs it over TLS, on port 443 by default rather than 80. The protection stops wherever TLS is terminated, so any hop beyond that point carries frames in whatever the operator configured.

open as a page

Your server refuses any WebSocket upgrade whose Origin field is not allowlisted, yet a scripted non-browser client connects freely. Why?

level: middleimportance: should knowfreq 46%

basics

~20 s

Only a browser is obliged to fill in the Origin request field honestly. Any other client writes whatever string it likes, so the check is a defence against pages the victim's browser loads, not authentication of the caller.

open as a page

When a server hits the operator-set cap on concurrent WebSocket connections it will hold, what does the next client actually observe?

level: seniorimportance: nice to knowfreq 33%

basics

~20 s

A failed handshake, not a closed socket. The refusal lands while the exchange is still HTTP — an error status instead of 101 Switching Protocols — or, enforced lower down, a connection dropped with no response at all.

open as a page