ข้ามไปยังเนื้อหา

Origin and Handshake Auth

จุดแรกที่ WebSocket จะทรยศคุณได้คือ handshake — HTTP request หนึ่งเดียวที่อัปเกรดการเชื่อมต่อธรรมดาให้กลายเป็น socket มีสองคำถามที่ต้องตอบตรงนั้น และถ้าคุณข้ามข้อใดข้อหนึ่ง ไม่มีอะไรในภายหลังจะช่วยคุณได้: หน้าไหนกำลังเปิดการเชื่อมต่อนี้? และ ผู้ใช้ที่อยู่เบื้องหลังเป็นใคร?

นี่คือกับดักที่ดักได้แทบทุกคน เมื่อหน้าเว็บเรียก fetch('https://your-api/...') ข้าม origin เบราว์เซอร์จะรันกลไก CORS: อาจส่ง preflight ก่อน และจะซ่อน response เว้นแต่ server ของคุณจะ opt in ด้วย header ที่ถูกต้อง การป้องกันนั้นเป็นแบบอัตโนมัติ

การอัปเกรด WebSocket ไม่ได้รับ อะไรเลยจากสิ่งนั้น เมื่อหน้าเว็บใด ๆ — รวมถึง https://evil.example — รันสิ่งนี้:

const ws = new WebSocket('wss://your-server/socket');

เบราว์เซอร์จะ ไม่ ส่ง CORS preflight, ไม่ ปรึกษา same-origin policy, และ จะ แนบ cookie ของผู้ใช้สำหรับ your-server ถ้ามีอยู่ คำขออัปเกรดออกจากเบราว์เซอร์และมาถึง server ของคุณโดยดูเหมือนคำขอที่ถูกต้องเป๊ะ ๆ ถ้าผู้ใช้ล็อกอินอยู่ หน้าเว็บของผู้โจมตีก็มี socket ที่ authenticate แล้ว นี่คือ Cross-Site WebSocket Hijacking — ญาติของ CSRF

สิ่งเดียวที่เบราว์เซอร์ ให้ คุณคือ Origin header บน handshake ซึ่งตั้งเป็นหน้าที่เปิด socket และสคริปต์ปลอมแปลงไม่ได้ การอ่านและตรวจ header นั้นคือการป้องกัน และอยู่บนฝั่งของคุณทั้งหมด

GET /socket HTTP/1.1
Host: your-server
Upgrade: websocket
Connection: Upgrade
Origin: https://app.example.com
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Cookie: session=…

จัดเก็บ allowlist ของ origin ที่คุณไว้ใจ และปฏิเสธทุกอย่างที่เหลือ ก่อน ที่จะอัปเกรดให้เสร็จ

Origin บอกคุณว่า หน้าไหน คุณยังต้องการ ผู้ใช้คนไหน ทำ authenticate การเชื่อมต่อตอนที่ยังเป็น HTTP request อยู่ — นั่นคือช่วงที่คุณมี header, cookie, และอิสระที่จะปฏิเสธอย่างสะอาด มีสามวิธีทั่วไปในการพา credential ไป:

  • Session cookie ถ้าผู้ใช้ล็อกอินอยู่แล้วบน https://app.example.com, handshake จะพา cookie ของพวกเขาไปโดยอัตโนมัติ Validate cookie นั้นฝั่ง server วิธีนี้สะดวกแต่เป็น สิ่งที่ทำให้ Cross-Site WebSocket Hijacking เป็นไปได้พอดี — นั่นคือเหตุผลว่าทำไมการตรวจ Origin จึงเป็นข้อบังคับเมื่อคุณ authenticate ด้วย cookie
  • Token ใน subprotocol ส่ง bearer token ผ่าน Sec-WebSocket-Protocol header (อาร์กิวเมนต์ที่สองของ new WebSocket(url, protocols)) token เดินทางใน header ไม่ใช่ตำแหน่งที่ถูก log และ server อ่านได้ระหว่างการอัปเกรด
  • Token ใน message แรก เปิด socket จากนั้นทำให้ frame แรกสุดของ client เป็น message แบบ auth server จะคงการเชื่อมต่อไว้แบบยังไม่ authenticate และเงียบจนกว่า frame นั้นจะ validate ผ่าน

และมี anti-pattern หนึ่งที่ต้องเลี่ยงเหนือสิ่งอื่นใด:

sequenceDiagram
  participant C as Browser (page)
  participant S as WebSocket server
  C->>S: GET /socket  Upgrade: websocket<br/>Origin: app.example.com<br/>Sec-WebSocket-Protocol: bearer, &lt;token&gt;
  S->>S: 1. Origin in allowlist?
  alt Origin not allowed
    S-->>C: reject upgrade / close 1008
  else Origin OK
    S->>S: 2. Validate token / cookie
    alt Token invalid or expired
      S-->>C: close 1008 (policy violation)
    else Authenticated
      S-->>C: 101 Switching Protocols
      Note over C,S: Connection open — identity now fixed
    end
  end
การตรวจ Origin และ validate token ระหว่าง WebSocket handshake

สังเกตว่าการตรวจทั้งสองเกิดขึ้น ก่อน 101 Switching Protocols origin ที่ถูกปฏิเสธหรือ token ที่ผิดจะไม่มีวันกลายเป็น socket ที่เปิดอยู่ — และเมื่อเราปิด เราใช้ code 1008 (policy violation) ที่เป็นสัญญาณมาตรฐานว่าการเชื่อมต่อถูกปฏิเสธเพราะกฎ ไม่ใช่ความผิดพลาดของเครือข่าย

เดโมด้านล่างคือ body ของ connection handler สำหรับ server ws จริง ๆ ไลบรารี ws ส่ง req ให้คุณ — คำขออัปเกรดดั้งเดิม — ดังนั้น header ของตัวเองยังคงใช้ได้ เราตรวจ req.headers.origin เทียบกับ allowlist จากนั้นดึง token จาก Sec-WebSocket-Protocol header แล้ว validate token นั้น อะไรก็ตามที่ไม่ผ่านจะได้รับ ws.close(1008, …) เปิดใน StackBlitz แล้วรัน npm run client เพื่อดูว่าการเชื่อมต่อถูกยอมรับหรือถูกปฏิเสธ

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

client ของ starter เปิดด้วย origin แบบ localhost แต่ไม่มี token ดังนั้นคุณจะเห็น server ปฏิเสธการเชื่อมต่อด้วย close 1008 และ invalid token — ตรงกับการปฏิเสธที่หน้าเว็บของผู้โจมตีจะเจอเป๊ะ ๆ แก้ VALID_TOKENS หรือให้ client ส่ง subprotocol เพื่อดูเส้นทางที่ถูกยอมรับ แนวคิดสำคัญ: เมื่อ socket เปิดแล้ว ทั้ง หน้าไหน และ ผู้ใช้คนไหน ก็ถูกจัดการเรียบร้อยแล้ว และคำตอบของ “นี่คือใคร?” ถูกเก็บไว้บนตัวการเชื่อมต่อเองแล้ว

Auth MethodSecurityConvenience
Cookie (session)ดี ถ้า validate Origin ด้วยauto-sent ทุก browser request
Token ใน Subprotocol headerดี — ไม่ถูก logต้อง set ใน WebSocket constructor
Token ใน First Messageดี — ควบคุมได้เพิ่ม latency เล็กน้อย
Token ใน URL Query Stringแย่ — token รั่วใน logอย่าใช้

ไม่ Validate Origin — Cross-Site WebSocket Hijacking อาการ:

  • server accept connection จากทุก origin
  • หน้า evil.example เปิด socket กับ your-server ด้วย cookie ของ user
  • validate Origin header เทียบกับ allowlist ก่อนทุกอย่าง

ใส่ Token ใน URL Query String อาการ:

  • new WebSocket('wss://server/ws?token=eyJ...') — token ติด access log ทุก proxy
  • token รั่วใน browser history และ referer header
  • ใช้ subprotocol header หรือ first-message auth แทน

💡 ตัวอย่างจากของจริง

GitHub Codespaces:

  • validate Origin + token ระหว่าง handshake
  • reject immediately ด้วย 1008 ถ้า token หมดอายุ — ไม่จัดสรร resource

Stripe Dashboard:

  • WebSocket ใช้ session cookie authentication
  • validate Origin เทียบกับ dashboard.stripe.com domain allowlist
ทำไมการเช็ค Origin header ที่ server ถึงจำเป็นสำหรับ WebSocket ที่ authenticate ด้วย cookie?
ไม่ควรวาง authentication token ไว้ที่ไหน?
demo ใช้ close code ไหนปฏิเสธการเชื่อมต่อที่มี origin หรือ token ผิด?