ทำไม Webhook ถึงเป็นจุดอ่อนที่ทำให้ระบบพังได้
เวลาเราเขียนแอปพลิเคชันที่ต้องเชื่อมต่อกับบริการภายนอก เช่น การจ่ายเงินผ่าน Stripe หรือการแจ้งเตือนจาก GitHub เรามักจะใช้สิ่งที่เรียกว่า Webhook (ช่องทางการรับข้อมูลอัตโนมัติจากเซิร์ฟเวอร์อื่น) ซึ่งเปรียบเสมือนการจ้างพนักงานรับจดหมายให้คอยรับของจากหน้าบ้านตลอดเวลา
ปัญหาคือถ้าเราเอาพนักงานคนนี้ไปยืนหน้าบ้านที่ไม่มีรั้วกั้น ใครจะโยนอะไรมาก็ได้ ทั้งขยะ จดหมายปลอม หรือระเบิด ถ้าเราไม่มีระบบคัดกรองที่ดีพอ พนักงานรับจดหมายอาจจะสติแตกจนทำงานต่อไม่ได้ หรือที่เลวร้ายกว่านั้นคือระบบหลังบ้านของเราอาจจะล่มตามไปด้วย
Event Mesh (โครงข่ายสื่อสารระหว่างระบบ) อย่าง Kafka หรือ NATS ถูกออกแบบมาให้ทำงานภายในที่ปลอดภัย การเอา Webhook มาเสียบเข้ากับระบบเหล่านี้โดยตรงจึงเหมือนการเปิดประตูบ้านทิ้งไว้ให้คนแปลกหน้าเดินเข้ามาในห้องนอนโดยไม่มีการตรวจบัตรประชาชน
ความเสี่ยงของการรับข้อมูลแบบไม่คัดกรอง
หลายคนมักใช้วิธีง่ายๆ คือสร้างทางเข้า (Endpoint) แล้วให้ข้อมูลวิ่งตรงเข้าหาตัวเก็บข้อมูลหรือตัวประมวลผลทันที แต่วิธีนี้มีจุดตายที่อันตรายมาก คือถ้าข้อมูลที่ส่งมามีปริมาณมหาศาลพร้อมกัน ระบบจะรับไม่ไหวและเกิดอาการ "คอขวด" (ข้อมูลติดขัดจนระบบทำงานไม่ได้)
นอกจากนี้ ทุกบริการภายนอกมีวิธี "เซ็นชื่อ" (Signature) หรือการยืนยันตัวตนที่ไม่เหมือนกัน ถ้าเราไม่ตรวจสอบว่าใครเป็นคนส่งมาจริง เราอาจจะถูกหลอกให้ทำงานตามคำสั่งของแฮกเกอร์ได้ง่ายๆ การปล่อยให้ข้อมูลดิบวิ่งเข้าหาตัวประมวลผลจึงไม่ใช่ทางเลือกที่ดีสำหรับมืออาชีพ
ผู้ให้บริการ Webhook มักจะส่งข้อมูลมาซ้ำๆ หากเราตอบกลับช้า หรือถ้าเราทำระบบพังเพียงเสี้ยววินาที เขาอาจจะยกเลิกการส่งข้อมูลให้เราถาวร เพราะมองว่าระบบเราไม่เสถียร นี่คือความเสี่ยงที่มือใหม่มักมองข้ามจนทำให้โปรเจกต์พังไม่เป็นท่า
// ตัวอย่างการรับ Webhook แบบอันตรายที่มือใหม่มักทำ
app.post('/webhook', (req, res) => {
// รับข้อมูลดิบแล้วส่งเข้า Kafka ทันที
kafka.send('my-topic', req.body);
res.status(200).send('OK');
});
อธิบายโค้ด:
1. app.post คือการสร้างช่องรับข้อมูลจากภายนอก
2. kafka.send คือการโยนข้อมูลเข้าไปในระบบทันทีโดยไม่มีการตรวจสอบ
3. res.status(200) คือการตอบกลับว่าได้รับแล้ว ทั้งที่ยังไม่ได้เช็คว่าข้อมูลจริงหรือปลอม
ผลลัพธ์: ระบบจะทำงานเร็วมากในตอนแรก แต่เมื่อถูกยิงข้อมูลขยะหรือข้อมูลปลอมเข้ามา ระบบจะถูกโจมตีได้ทันทีโดยไม่มีการป้องกันใดๆ
สร้างด่านหน้า (Edge Gateway) เพื่อความปลอดภัย
วิธีแก้ปัญหาที่ดีที่สุดคือการสร้าง Edge Gateway (ด่านหน้าสำหรับคัดกรองข้อมูลก่อนเข้าสู่ระบบหลัก) เปรียบเหมือนยามหน้าหมู่บ้านที่มีหน้าที่ตรวจบัตรและคัดกรองพัสดุ ก่อนจะส่งต่อให้คนในบ้านทำงานต่ออย่างสบายใจ
ด่านหน้านี้จะมีหน้าที่สำคัญคือการเช็คว่าข้อมูลมาจากแหล่งที่เชื่อถือได้จริงหรือไม่ โดยการตรวจสอบ HMAC (รหัสลับที่ใช้ยืนยันความถูกต้องของข้อมูล) หากผลลัพธ์ไม่ตรงกัน ยามของเราจะปฏิเสธการรับทันทีโดยไม่ให้ผ่านเข้าไประบบข้างใน
นอกจากนี้ ด่านหน้ายังมีหน้าที่ "พักข้อมูล" (Buffer) ไว้ชั่วคราว หากระบบหลักกำลังยุ่งหรือหยุดพักไปชั่วคราว ข้อมูลจะไม่หายไปไหน แต่จะถูกเก็บไว้ที่ด่านหน้าจนกว่าระบบหลักจะพร้อมรับงาน นี่คือหัวใจของความเสถียรที่มืออาชีพต้องมี
ตัวอย่างขั้นตอนการทำงานของด่านหน้า:
- รับคำขอ (Request) เข้ามาที่ด่านหน้า
- ตรวจสอบลายเซ็นดิจิทัลว่ามาจากแหล่งที่ถูกต้อง
- คัดกรองข้อมูลขยะและตรวจสอบเวลาว่าเก่าเกินไปไหม
- ตอบกลับผู้ส่ง (202 Accepted) ว่ารับเรื่องแล้ว
- ส่งข้อมูลต่อเข้าสู่ Event Mesh อย่างปลอดภัย
การตรวจสอบลายเซ็น (Signature Verification) ทีละขั้นตอน
ผู้ให้บริการแต่ละเจ้ามีวิธีส่งลายเซ็นต่างกัน เช่น Stripe จะส่ง Stripe-Signature มาใน Header (ข้อมูลส่วนหัวที่แนบไปกับคำขอ) ถ้าเราเขียนโค้ดเช็คไม่ดี เราจะพลาดข้อมูลสำคัญไปเลย สิ่งที่ต้องระวังที่สุดคือต้องเช็คจาก Raw Body (ข้อมูลดิบที่ยังไม่ได้แปลงเป็นรูปแบบอื่น) เท่านั้น
ถ้าเราเผลอเอาข้อมูลไปแปลงเป็น JSON ก่อนแล้วค่อยเช็ค ลายเซ็นจะเปลี่ยนไปทันทีและตรวจสอบไม่ผ่าน นี่คือจุดที่มือใหม่มักเสียเวลาแก้บั๊ก (ข้อผิดพลาดในโปรแกรม) นานที่สุด เพราะมันดูเหมือนข้อมูลถูก แต่เช็คแล้วไม่ผ่านสักที
การเขียนโค้ดต้องใช้การเปรียบเทียบแบบ Constant-time (การเปรียบเทียบที่ใช้เวลาคงที่) เพื่อป้องกันการโจมตีแบบเดาสุ่ม ถ้าเราเปรียบเทียบแบบปกติ แฮกเกอร์อาจจะเดาความลับเราได้จากการจับเวลาว่าเราใช้เวลาเช็คข้อมูลนานแค่ไหน
// ตัวอย่างการตรวจสอบลายเซ็นแบบเบื้องต้น
const crypto = require('crypto');
const signature = req.headers['x-hub-signature-256'];
const hmac = crypto.createHmac('sha256', secret);
const digest = Buffer.from('sha256=' + hmac.update(req.rawBody).digest('hex'));
if (crypto.timingSafeEqual(digest, Buffer.from(signature))) {
console.log('ข้อมูลถูกต้อง ส่งต่อได้');
} else {
console.log('ข้อมูลปลอม ปฏิเสธการเข้าถึง');
}
อธิบายโค้ด:
1. crypto.createHmac สร้างเครื่องมือสำหรับตรวจลายเซ็น
2. req.rawBody ต้องใช้ข้อมูลดิบเท่านั้น ห้ามแปลงเป็น JSON ก่อน
3. timingSafeEqual คือคำสั่งเปรียบเทียบข้อมูลที่ปลอดภัยจากการถูกเดาเวลา
ผลลัพธ์: ถ้าลายเซ็นตรงกัน โปรแกรมจะอนุญาตให้ผ่าน แต่ถ้าไม่ตรงกัน ระบบจะปฏิเสธการทำงานทันที ช่วยป้องกันผู้ไม่หวังดีได้
มาตรฐานการส่งข้อมูลด้วย CloudEvents
เมื่อข้อมูลผ่านด่านหน้าเข้ามาแล้ว เราควรทำให้ข้อมูลทุกอย่างอยู่ในรูปแบบเดียวกัน ไม่ว่าจะเป็นข้อมูลจาก Stripe, GitHub หรือ Shopify การใช้มาตรฐาน CloudEvents (รูปแบบข้อมูลมาตรฐานสำหรับระบบเหตุการณ์) จะช่วยให้ทีมงานทำงานง่ายขึ้นมาก
ลองนึกภาพว่าคุณมีพนักงานหลายคน แต่ละคนพูดคนละภาษา การต้องมาแปลภาษาให้พนักงานแต่ละคนเข้าใจคงเหนื่อยมาก แต่ถ้าเรากำหนดให้ทุกคนส่งรายงานเป็นภาษาไทยมาตรฐานเหมือนกันหมด ทุกอย่างก็จะราบรื่น นี่คือสิ่งที่ CloudEvents ทำให้กับระบบซอฟต์แวร์
การใช้มาตรฐานนี้ทำให้เราสามารถเปลี่ยนผู้ให้บริการได้โดยไม่ต้องแก้โค้ดในระบบหลัก เพราะเราได้ทำความสะอาดข้อมูล (Normalization) ตั้งแต่หน้าด่านแล้ว ระบบข้างในจะรับรู้แค่ว่า "มีเหตุการณ์เกิดขึ้น" โดยไม่ต้องสนว่าเหตุการณ์นั้นมาจากไหน
สรุป: เปลี่ยนด่านหน้าให้เป็นปราการที่แข็งแกร่ง
การนำ Webhook มาเชื่อมต่อกับ Event Mesh ไม่ใช่แค่เรื่องของการต่อสายให้ข้อมูลวิ่งถึงกัน แต่เป็นเรื่องของการออกแบบความปลอดภัยและความเสถียรให้ระบบของคุณ คุณต้องมีด่านหน้าคอยคัดกรอง ตรวจสอบลายเซ็น และจัดระเบียบข้อมูลก่อนส่งเข้าสู่ระบบหลักเสมอ
สำหรับโปรแกรมเมอร์มือใหม่ สิ่งที่สำคัญที่สุดคืออย่ารีบเขียนโค้ดให้ข้อมูลวิ่งเข้าหาฐานข้อมูลทันที แต่ให้คิดถึง "ความปลอดภัย" และ "การรับมือกับความผิดพลาด" ก่อนเสมอ เพราะในโลกของการทำงานจริง ข้อมูลที่เข้ามาจากภายนอกไม่มีอะไรน่าไว้ใจได้เลย
จงจำไว้ว่าการป้องกันที่ต้นทาง คือวิธีที่ประหยัดเวลาและลดปัญหาให้คุณได้มากที่สุดในระยะยาว หากคุณเริ่มสร้างระบบด้วยแนวคิดนี้ตั้งแต่วันนี้ ไม่ว่าโปรเจกต์ของคุณจะขยายใหญ่แค่ไหน คุณก็จะมีระบบที่แข็งแกร่งและพร้อมรับมือกับทุกสถานการณ์ได้อย่างมืออาชีพ
ที่มา: Bridging Webhooks to Your Event Mesh: A Secure Edge Pattern for Kafka, Redpanda, and NATS — DEV Community