Origin and Handshake Auth
จุดแรกที่ WebSocket จะทรยศคุณได้คือ handshake — HTTP request หนึ่งเดียวที่อัปเกรดการเชื่อมต่อธรรมดาให้กลายเป็น socket มีสองคำถามที่ต้องตอบตรงนั้น และถ้าคุณข้ามข้อใดข้อหนึ่ง ไม่มีอะไรในภายหลังจะช่วยคุณได้: หน้าไหนกำลังเปิดการเชื่อมต่อนี้? และ ผู้ใช้ที่อยู่เบื้องหลังเป็นใคร?
WebSocket ไม่ถูกป้องกันด้วย CORS
หัวข้อที่มีชื่อว่า “WebSocket ไม่ถูกป้องกันด้วย CORS”นี่คือกับดักที่ดักได้แทบทุกคน เมื่อหน้าเว็บเรียก 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.1Host: your-serverUpgrade: websocketConnection: UpgradeOrigin: https://app.example.comSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Cookie: session=…จัดเก็บ allowlist ของ origin ที่คุณไว้ใจ และปฏิเสธทุกอย่างที่เหลือ ก่อน ที่จะอัปเกรดให้เสร็จ
Authenticate ระหว่าง handshake
หัวข้อที่มีชื่อว่า “Authenticate ระหว่าง handshake”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-Protocolheader (อาร์กิวเมนต์ที่สองของnew WebSocket(url, protocols)) token เดินทางใน header ไม่ใช่ตำแหน่งที่ถูก log และ server อ่านได้ระหว่างการอัปเกรด - Token ใน message แรก เปิด socket จากนั้นทำให้ frame แรกสุดของ client เป็น message แบบ
authserver จะคงการเชื่อมต่อไว้แบบยังไม่ authenticate และเงียบจนกว่า frame นั้นจะ validate ผ่าน
และมี anti-pattern หนึ่งที่ต้องเลี่ยงเหนือสิ่งอื่นใด:
handshake ที่ authenticate แล้ว ทีละขั้น
หัวข้อที่มีชื่อว่า “handshake ที่ authenticate แล้ว ทีละขั้น”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, <token>
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 สังเกตว่าการตรวจทั้งสองเกิดขึ้น ก่อน 101 Switching Protocols origin ที่ถูกปฏิเสธหรือ token ที่ผิดจะไม่มีวันกลายเป็น socket ที่เปิดอยู่ — และเมื่อเราปิด เราใช้ code 1008 (policy violation) ที่เป็นสัญญาณมาตรฐานว่าการเชื่อมต่อถูกปฏิเสธเพราะกฎ ไม่ใช่ความผิดพลาดของเครือข่าย
server ที่รันได้จริงซึ่งปฏิเสธ origin และ token ที่แย่
หัวข้อที่มีชื่อว่า “server ที่รันได้จริงซึ่งปฏิเสธ origin และ token ที่แย่”เดโมด้านล่างคือ 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 เพื่อดูว่าการเชื่อมต่อถูกยอมรับหรือถูกปฏิเสธ
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 Method | Security | Convenience |
|---|---|---|
| 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
Originheader เทียบกับ 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.comdomain allowlist