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

TLS and DoS

บทเรียนที่ผ่านมาป้องกัน message รายตัวบนการเชื่อมต่อ บทเรียนสุดท้ายนี้ซูมออกไปยังชั้นการเชื่อมต่อเอง: bytes บนสาย และอันตรายของการปล่อยให้การเชื่อมต่อมากเกินไป — หรือการเชื่อมต่อที่ทำไม่เสร็จมากเกินไป — สะสมกองพะเนิน มีสองธีมที่วิ่งผ่านบทเรียนนี้: เข้ารหัสทุกอย่าง ด้วย TLS, และ อย่าใช้ทรัพยากรก่อนที่คุณจะรู้ว่าการเชื่อมต่อนั้นคุ้มค่า

WebSocket สืบทอดความปลอดภัยจาก scheme ที่เปิดบน ws:// คือ plaintext: handshake, auth token ของคุณ, และทุก frame เดินทางเป็น cleartext ที่ router, proxy, หรือ peer บน Wi-Fi ร้านกาแฟใด ๆ ระหว่าง client และ server อ่านหรือเขียนทับได้ wss:// ห่อทั้งหมดไว้ใน TLS เหมือนกับที่ https:// ทำให้ HTTP เป๊ะ ๆ กฎนั้นเรียบง่ายและเด็ดขาด:

  • ใช้ wss:// ใน production เสมอ Terminate TLS ที่ load balancer ของคุณหรือใน server โดยตรง
  • หน้าเว็บที่เสิร์ฟผ่าน https:// เปิดการเชื่อมต่อ ws:// ไม่ได้ — เบราว์เซอร์บล็อกเป็น mixed content ดังนั้นเมื่อไซต์ของคุณอยู่บน HTTPS (ซึ่งก็อยู่อยู่แล้ว) wss ก็เป็นตัวเลือกเดียวที่ใช้งานได้อยู่แล้ว
  • จงมอง ws:// เป็นความสะดวกในการพัฒนาบน localhost เท่านั้น ไม่มีอะไรมากกว่านั้น
// Production: TLS-terminated WebSocket server.
import { createServer } from 'node:https';
import { readFileSync } from 'node:fs';
import { WebSocketServer } from 'ws';
const server = createServer({
cert: readFileSync('/etc/tls/fullchain.pem'),
key: readFileSync('/etc/tls/privkey.pem'),
});
// maxPayload caps frame size at the protocol level (see the previous lesson).
const wss = new WebSocketServer({ server, maxPayload: 64 * 1024 });
server.listen(443);

Rate limiting จำกัด message ภายใน การเชื่อมต่อ แต่ผู้โจมตีก็โจมตีคุณด้วยตัวการเชื่อมต่อ เอง ได้เช่นกัน:

  • Connection flood เปิด socket หลายพันจาก IP เดียวหรือไม่กี่ IP socket ที่เปิดแต่ละอันกิน memory และ file descriptor; ถ้ามากพอก็ทำให้ server หมดก่อนที่จะส่ง message สักอัน
  • Slowloris / slow handshake เริ่มการอัปเกรด HTTP แต่ส่ง bytes ของคำขอทีละนิดอย่างเชื่องช้า หรือไม่ทำให้จบเลย server ที่ถือการเชื่อมต่อเปิดค้างรออยู่จะกินช่องหนึ่งต่อผู้โจมตีหนึ่งคนฟรี ๆ การป้องกันคือ handshake timeout: ถ้าการอัปเกรดไม่เสร็จภายในไม่กี่วินาที ก็ตัดการเชื่อมต่อทิ้ง
  • Idle hoarding เปิด socket, authenticate, แล้วเงียบไปตลอดกาล — ผูกทรัพยากรไว้โดยไม่ทำอะไรเลย heartbeat บวก idle timeout ทวงคืนพวกนี้ได้: ping เป็นระยะ และปิดการเชื่อมต่อใด ๆ ที่ pong ไม่กลับหรือไม่ส่งอะไรมานานเกินไป
flowchart TB
  N["New TCP / upgrade attempt"] --> IP{"Connections from<br/>this IP < limit?"}
  IP -- "no" --> R1["Refuse — protect capacity"]
  IP -- "yes" --> HT{"Handshake completes<br/>within timeout?"}
  HT -- "no (slowloris)" --> R2["Drop — reclaim slot"]
  HT -- "yes" --> AU{"Authenticated?"}
  AU -- "no" --> R3["Close 1008 — before allocating state"]
  AU -- "yes" --> AL["Allocate per-connection state<br/>+ start heartbeat / idle timeout"]
การจำกัดการเชื่อมต่อ: limit, time out handshake, authenticate ก่อนจัดสรร

หลักการที่สำคัญที่สุดเพียงข้อเดียวตรงนี้คือ ลำดับ ปฏิเสธที่ราคาถูกก่อน และใช้ทรัพยากรเป็นอย่างสุดท้าย การเชื่อมต่อที่ยังไม่ authenticate หรือ origin ผิดควรถูกปิด ก่อน ที่คุณจะจัดสรร session object ของตัวเอง, ลงทะเบียนเข้าห้องของคุณ, subscribe เข้ากับ pub/sub backend ของคุณ, หรือ buffer อะไรไว้ให้ ถ้าคุณจัดสรรก่อนแล้วค่อยตรวจทีหลัง ผู้โจมตีที่ถูกปฏิเสธทุกคนก็ยังทำให้คุณเสีย setup เต็มรูปแบบไปแล้ว — ที่เป็น resource exhaustion ที่คุณกำลังพยายามป้องกันพอดี ท่อนี้เป็นแบบนี้เสมอ: IP/connection limit → handshake timeout → origin check → authenticate → ตอนนี้ค่อยจัดสรร state

ภาพร่างด้านล่างแสดงรูปร่างของ connection-handler: counter ราย IP ที่ปฏิเสธการเชื่อมต่อส่วนเกิน, การ cleanup counter นั้นทันทีตอน close, และ idle timeout ที่ขับด้วย heartbeat ซึ่งปิด socket ที่ตายแล้ว นี่เป็น ts มากกว่าจะเป็นเดโมที่รันได้เพราะขึ้นอยู่กับเครือข่ายจริงและนาฬิกาจริง — แต่หย่อนลงใน server ws ได้ตรง ๆ

const MAX_PER_IP = 20;
const perIp = new Map<string, number>();
wss.on('connection', (ws, req) => {
const ip = req.socket.remoteAddress ?? 'unknown';
// 1. Per-IP connection limit — refuse BEFORE allocating anything.
const count = perIp.get(ip) ?? 0;
if (count >= MAX_PER_IP) {
ws.close(1008, 'too many connections');
return;
}
perIp.set(ip, count + 1);
// 2. (origin + auth checks happen here — see earlier lessons)
// 3. Heartbeat + idle timeout: reclaim dead/silent sockets.
let alive = true;
ws.on('pong', () => { alive = true; });
const beat = setInterval(() => {
if (!alive) { ws.terminate(); return; } // no pong since last beat → kill
alive = false;
ws.ping();
}, 30_000);
// 4. Always clean up on close — release the IP slot and the timer.
ws.on('close', () => {
clearInterval(beat);
perIp.set(ip, (perIp.get(ip) ?? 1) - 1);
});
});

handshake timeout เองอยู่สูงขึ้นไปหนึ่งระดับ บน HTTP server (server.headersTimeout / server.requestTimeout) ดังนั้น slowloris upgrade จะถูกทิ้งก่อนที่จะไปถึง handler นี้ด้วยซ้ำ เมื่อรวมกันสิ่งเหล่านี้จำกัด จำนวนเท่าไร ของการเชื่อมต่อที่มีอยู่, นานแค่ไหน ที่อันที่ยังไม่เสร็จจะค้างอยู่ได้, และ นานแค่ไหน ที่อัน idle จะรอดอยู่ได้ — ปิดช่องว่างสุดท้ายที่ผู้โจมตีจะใช้ประโยชน์ได้

Security Layerป้องกันอะไรวิธีทำ
TLS (wss://)Man-in-the-middle, eavesdroppingใช้ wss:// เสมอใน production
Connection limitDDoS จาก connection floodmax connection per IP, global limit
Message rate limitMessage floodtoken bucket per connection
Payload validationMalformed data attackvalidate schema ทุก message

ใช้ ws:// ใน Production อาการ:

  • message ส่งผ่าน network โดยไม่ encrypt
  • attacker ใน network เดียวกัน (café wifi) ดัก message ได้
  • ใช้ wss:// (WebSocket Secure) เสมอใน production

ไม่มี DDoS Protection บน WebSocket Endpoint อาการ:

  • attacker เปิด WebSocket connection จำนวนมากจาก IP หลายตัว
  • server file descriptor หมด — legitimate user เชื่อมต่อไม่ได้
  • ใช้ Cloudflare, AWS Shield, หรือ nginx rate limit บน /ws endpoint

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

Cloudflare WebSocket:

  • TLS terminate ที่ edge — wss:// ถึง Cloudflare, แล้วส่งต่อผ่าน internal network
  • DDoS protection built-in — absorb connection flood ก่อนถึง origin server

Discord:

  • wss:// เสมอ — message ทุกชิ้น encrypt บน wire
  • rate limit และ connection limit ป้องกัน abuse จาก bot farm
ทำไม WebSocket บน production ถึงต้องใช้ wss:// แทน ws://?
อะไรช่วยป้องกันการโจมตี slow handshake แบบ slowloris?
ลำดับที่ถูกต้องเพื่อไม่ให้เปลือง resource ไปกับการเชื่อมต่อที่ไม่ดีคืออะไร?