The WebSocket protocol begins as an HTTP request that upgrades to WebSocket:
Client Request:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Server Response:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Key Generation (Client):
Sec-WebSocket-Key headerSec-WebSocket-Accept Computation (Server):
258EAFA5-E914-47DA-95CA-C5AB0DC85B11Sec-WebSocket-Accept headerExample computation:
Client Key: dGhlIHNhbXBsZSBub25jZQ==
Concatenated: dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
SHA-1 Hash: b37a4f2cc0cb4e7e8cf769a5f3f8f2e8e4c9f7a3
Base64: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Validation (Client):
Sec-WebSocket-Accept matches expected valueThe Origin header provides protection against cross-site WebSocket hijacking:
Server-side validation:
func checkOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
allowedOrigins := []string{
"https://example.com",
"https://app.example.com",
}
for _, allowed := range allowedOrigins {
if origin == allowed {
return true
}
}
return false
}
Security consideration: Browser-based clients MUST send Origin header. Non-browser clients MAY omit it. Servers SHOULD validate Origin for browser clients to prevent CSRF attacks.
WebSocket frames use a binary format with variable-length fields:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
FIN (1 bit):
1 = Final fragment in message0 = More fragments followRSV1, RSV2, RSV3 (1 bit each):
Opcode (4 bits):
MASK (1 bit):
1 = Payload is masked (required for client-to-server)0 = Payload is not masked (required for server-to-client)Payload Length (7 bits, 7+16 bits, or 7+64 bits):
Masking-key (0 or 4 bytes):
Data Frame Opcodes:
0x0 - Continuation Frame- Used for fragmented messages - Must follow initial data frame (text/binary) - Carries same data type as initial frame
0x1 - Text Frame- Payload is UTF-8 encoded text - MUST be valid UTF-8 - Endpoint MUST fail connection if invalid UTF-8
0x2 - Binary Frame- Payload is arbitrary binary data - Application interprets data
0x3-0x7 - Reserved for future non-control framesControl Frame Opcodes:
0x8 - Connection Close- Initiates or acknowledges connection closure - MAY contain status code and reason - See "Close Handshake" section
0x9 - Ping- Heartbeat mechanism - MAY contain application data - Recipient MUST respond with Pong
0xA - Pong- Response to Ping - MUST contain identical payload as Ping - MAY be sent unsolicited (unidirectional heartbeat)
0xB-0xF - Reserved for future control framesControl frames are subject to strict rules:
- Allows control frames to fit in single IP packet - Reduces fragmentation
- FIN bit MUST be 1 - Ensures immediate processing
- Enables ping/pong during long transfers - Close frames can interrupt any operation
Purpose of masking:
Masking algorithm:
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
Implementation:
func maskBytes(data []byte, mask [4]byte) {
for i := range data {
data[i] ^= mask[i%4]
}
}
Example:
Original: [0x48, 0x65, 0x6C, 0x6C, 0x6F] // "Hello"
Masking Key: [0x37, 0xFA, 0x21, 0x3D]
Masked: [0x7F, 0x9F, 0x4D, 0x51, 0x58]
Calculation:
0x48 XOR 0x37 = 0x7F
0x65 XOR 0xFA = 0x9F
0x6C XOR 0x21 = 0x4D
0x6C XOR 0x3D = 0x51
0x6F XOR 0x37 = 0x58 (wraps around to mask[0])
Security requirement: Masking key MUST be derived from strong source of entropy. Predictable masking keys defeat the security purpose.
Sender rules:
Receiver rules:
Sending "Hello World" in 3 fragments:
Frame 1 (Text, More Fragments):
FIN=0, Opcode=0x1, Payload="Hello"
Frame 2 (Continuation, More Fragments):
FIN=0, Opcode=0x0, Payload=" Wor"
Frame 3 (Continuation, Final):
FIN=1, Opcode=0x0, Payload="ld"
With interleaved Ping:
Frame 1: FIN=0, Opcode=0x1, Payload="Hello"
Frame 2: FIN=1, Opcode=0x9, Payload="" <- Ping (complete)
Frame 3: FIN=0, Opcode=0x0, Payload=" Wor"
Frame 4: FIN=1, Opcode=0x0, Payload="ld"
type fragmentState struct {
messageType int
fragments [][]byte
}
func (ws *WebSocket) handleFrame(fin bool, opcode int, payload []byte) {
switch opcode {
case 0x1, 0x2: // Text or Binary (first fragment)
if fin {
ws.handleCompleteMessage(opcode, payload)
} else {
ws.fragmentState = &fragmentState{
messageType: opcode,
fragments: [][]byte{payload},
}
}
case 0x0: // Continuation
if ws.fragmentState == nil {
ws.fail("Unexpected continuation frame")
return
}
ws.fragmentState.fragments = append(ws.fragmentState.fragments, payload)
if fin {
complete := bytes.Join(ws.fragmentState.fragments, nil)
ws.handleCompleteMessage(ws.fragmentState.messageType, complete)
ws.fragmentState = nil
}
case 0x8, 0x9, 0xA: // Control frames
ws.handleControlFrame(opcode, payload)
}
}
Ping (0x9):
Pong (0xA):
No Response:
Pattern 1: Automatic Pong (most WebSocket libraries)
// Library handles pong automatically
ws.SetPingHandler(func(appData string) error {
// Custom handler if needed
return nil // Library sends pong automatically
})
Pattern 2: Manual Pong
func (ws *WebSocket) handlePing(payload []byte) {
pongFrame := Frame{
FIN: true,
Opcode: 0xA,
Payload: payload, // Echo same payload
}
ws.writeFrame(pongFrame)
}
Pattern 3: Periodic Client Ping
func (ws *WebSocket) pingLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := ws.writePing([]byte{}); err != nil {
return // Connection dead
}
case <-ws.done:
return
}
}
}
Pattern 4: Timeout Detection
const pongWait = 60 * time.Second
ws.SetReadDeadline(time.Now().Add(pongWait))
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// If no frame received in pongWait, ReadMessage returns timeout error
Server-side:
Client-side:
Close frame (Opcode 0x8) payload:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Status Code (16) | Reason (variable length)... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Status Code (2 bytes, optional):
Reason (variable length, optional):
Initiator (either endpoint):
Recipient:
Normal Closure Codes:
1000 - Normal Closure- Successful operation complete - Default if no code specified
1001 - Going Away- Endpoint going away (server shutdown, browser navigation) - Client navigating to new page
Error Closure Codes:
1002 - Protocol Error- Endpoint terminating due to protocol error - Invalid frame format, unexpected opcode, etc.
1003 - Unsupported Data- Endpoint cannot accept data type - Server received binary when expecting text
1007 - Invalid Frame Payload Data- Inconsistent data (e.g., non-UTF-8 in text frame)
1008 - Policy Violation- Message violates endpoint policy - Generic code when specific code doesn't fit
1009 - Message Too Big- Message too large to process
1010 - Mandatory Extension- Client expected server to negotiate extension - Server didn't respond with extension
1011 - Internal Server Error- Server encountered unexpected condition - Prevents fulfilling request
Reserved Codes:
1004 - Reserved1005 - No Status Rcvd (internal use only, never sent)1006 - Abnormal Closure (internal use only, never sent)1015 - TLS Handshake (internal use only, never sent)Custom Application Codes:
3000-3999 - Library/framework use4000-4999 - Application use (e.g., Nostr-specific)Graceful close (initiator):
func (ws *WebSocket) Close() error {
// Send close frame
closeFrame := Frame{
FIN: true,
Opcode: 0x8,
Payload: encodeCloseStatus(1000, "goodbye"),
}
ws.writeFrame(closeFrame)
// Wait for close frame response (with timeout)
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
frame, err := ws.readFrame()
if err != nil || frame.Opcode == 0x8 {
break
}
// Process other frames
}
// Close TCP connection
return ws.conn.Close()
}
Handling received close:
func (ws *WebSocket) handleCloseFrame(payload []byte) {
status, reason := decodeClosePayload(payload)
log.Printf("Close received: %d %s", status, reason)
// Send close response
closeFrame := Frame{
FIN: true,
Opcode: 0x8,
Payload: payload, // Echo same status/reason
}
ws.writeFrame(closeFrame)
// Close connection
ws.conn.Close()
}
Nostr relay close examples:
// Client subscription limit exceeded
ws.SendClose(4000, "subscription limit exceeded")
// Invalid message format
ws.SendClose(1002, "protocol error: invalid JSON")
// Relay shutting down
ws.SendClose(1001, "relay shutting down")
// Client rate limit exceeded
ws.SendClose(4001, "rate limit exceeded")
Threat: Malicious web page opens WebSocket to victim server using user's credentials
Mitigation:
Origin headerExample:
func validateOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
// Allow same-origin
if origin == "https://"+r.Host {
return true
}
// Allowlist trusted origins
trusted := []string{
"https://app.example.com",
"https://mobile.example.com",
}
for _, t := range trusted {
if origin == t {
return true
}
}
return false
}
Why masking is required:
Example attack (without masking):
WebSocket payload: "GET /admin HTTP/1.1\r\nHost: victim.com\r\n\r\n"
Proxy might interpret as separate HTTP request
Defense: Client MUST mask all frames. Server MUST reject unmasked frames from client.
Prevent resource exhaustion:
type ConnectionLimiter struct {
connections map[string]int
maxPerIP int
mu sync.Mutex
}
func (cl *ConnectionLimiter) Allow(ip string) bool {
cl.mu.Lock()
defer cl.mu.Unlock()
if cl.connections[ip] >= cl.maxPerIP {
return false
}
cl.connections[ip]++
return true
}
func (cl *ConnectionLimiter) Release(ip string) {
cl.mu.Lock()
defer cl.mu.Unlock()
cl.connections[ip]--
}
Use WSS (WebSocket Secure) for:
WSS connection flow:
URL schemes:
ws:// - Unencrypted WebSocket (default port 80)wss:// - Encrypted WebSocket over TLS (default port 443)Prevent memory exhaustion:
const maxMessageSize = 512 * 1024 // 512 KB
ws.SetReadLimit(maxMessageSize)
// Or during frame reading:
if payloadLength > maxMessageSize {
ws.SendClose(1009, "message too large")
ws.Close()
}
Prevent abuse:
type RateLimiter struct {
limiter *rate.Limiter
}
func (rl *RateLimiter) Allow() bool {
return rl.limiter.Allow()
}
// Per-connection limiter
limiter := rate.NewLimiter(10, 20) // 10 msgs/sec, burst 20
if !limiter.Allow() {
ws.SendClose(4001, "rate limit exceeded")
}
Types of errors:
Handling strategy:
for {
frame, err := ws.ReadFrame()
if err != nil {
// Check error type
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Timeout - connection likely dead
log.Println("Connection timeout")
ws.Close()
return
}
if err == io.EOF || err == io.ErrUnexpectedEOF {
// Connection closed
log.Println("Connection closed")
return
}
if protocolErr, ok := err.(*ProtocolError); ok {
// Protocol violation
log.Printf("Protocol error: %v", protocolErr)
ws.SendClose(1002, protocolErr.Error())
ws.Close()
return
}
// Unknown error
log.Printf("Unknown error: %v", err)
ws.Close()
return
}
// Process frame
}
Text frames MUST contain valid UTF-8:
func validateUTF8(data []byte) bool {
return utf8.Valid(data)
}
func handleTextFrame(payload []byte) error {
if !validateUTF8(payload) {
return fmt.Errorf("invalid UTF-8 in text frame")
}
// Process valid text
return nil
}
For fragmented messages: Validate UTF-8 across all fragments when reassembled.
Mistake: Writing to WebSocket from multiple goroutines without synchronization
Fix: Use mutex or single-writer goroutine
type WebSocket struct {
conn *websocket.Conn
mutex sync.Mutex
}
func (ws *WebSocket) WriteMessage(data []byte) error {
ws.mutex.Lock()
defer ws.mutex.Unlock()
return ws.conn.WriteMessage(websocket.TextMessage, data)
}
Mistake: Sending Ping but not updating read deadline on Pong
Fix:
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
Mistake: Just calling conn.Close() without sending Close frame
Fix: Send Close frame first, wait for response, then close TCP
Mistake: Accepting any bytes in text frames
Fix: Validate UTF-8 and fail connection on invalid text
Mistake: Allowing unlimited message sizes
Fix: Set SetReadLimit() to reasonable value (e.g., 512 KB)
Mistake: Blocking indefinitely on slow clients
Fix: Set write deadline before each write
ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
Mistake: Not cleaning up resources on disconnect
Fix: Use defer for cleanup, ensure all goroutines terminate
Mistake: Multiple goroutines trying to close connection
Fix: Use sync.Once for close operation
type WebSocket struct {
conn *websocket.Conn
closeOnce sync.Once
}
func (ws *WebSocket) Close() error {
var err error
ws.closeOnce.Do(func() {
err = ws.conn.Close()
})
return err
}