Skip to main content

การส่งต่อ Raw Body และลายเซ็น x-line-signature

ภาพรวม​

หัวข้อนี้เป็นจุดที่มักเข้าใจผิด: webhook-go ไม่ได้ verify ลายเซ็น x-line-signature ด้วยตัวเอง

จากการตรวจสอบโค้ดทั้ง internal/ และ cmd/ ไม่พบการทำ HMAC-SHA256 เทียบกับ channel secret ที่ใดเลยในโปรเจกต์นี้ สิ่งที่พบคือ internal/util/hash.go (ฟังก์ชัน HMACShort ที่ใช้สร้าง short hash ทั่วไป) และ internal/util/base64.go (ฟังก์ชัน fileSignature ที่อ่าน magic bytes ของไฟล์) ซึ่งไม่เกี่ยวข้องกับการตรวจลายเซ็นของ LINE

สิ่งที่ service นี้ทำแทนคือ รักษา bytes ดิบของ request body ไว้โดยไม่แตะต้อง เพื่อให้ปลายทาง ไม่ว่าจะเป็น endpoint ของลูกค้าที่รับ forward หรือ worker-go ที่ consume คิว สามารถ verify ลายเซ็นได้เอง จึงเป็นการ ย้ายความรับผิดชอบ ไม่ใช่การละเลย

เหตุผลทางเทคนิคคือ x-line-signature เป็น HMAC-SHA256 ของ body ดิบทั้งก้อน หากนำมา json.Unmarshal แล้ว json.Marshal กลับ Go จะเรียง key ของ map ตามลำดับตัวอักษร ทำให้ bytes เปลี่ยนและลายเซ็นใช้ไม่ได้ทันที แม้เนื้อหา JSON จะเหมือนเดิมทุกประการ

Business Flow​

  1. processLine อ่าน body ด้วย io.ReadAll(c.Request.Body) ได้ผลลัพธ์เป็น raw []byte
  2. สำเนา bytes เก็บไว้ใน BodyRaw ชนิด json.RawMessage โดยเป็นการ copy จริง ไม่ใช่การอ้างถึง buffer ที่ gin จะนำกลับไปใช้ซ้ำ
  3. parse raw เป็น map[string]interface{} เก็บไว้ในฟิลด์ Body ซึ่งใช้เฉพาะ logic routing ภายในเท่านั้น (อ่าน events[].type, source.userId, message.text, postback.data) และไม่เคยถูกนำไป serialize ต่อ
  4. เมื่อ publish ลงคิว ตัว LineWebhookPayload.MarshalJSON ถูก override ให้ปล่อย BodyRaw ออกไปตรง ๆ ในฟิลด์ body และจะ fallback ไป marshal จาก map Body ก็ต่อเมื่อ BodyRaw ว่าง คือกรณีที่ body ไม่ใช่ JSON object
  5. เมื่อ forward ต่อผ่าน HTTP ระบบจะส่ง BodyRaw เป็น request body พร้อม copy header x-line-signature ไปด้วย ทำให้ปลายทางเทียบลายเซ็นกับ body ที่ได้รับได้พอดี
  6. header ทุกตัวถูกแปลง key เป็นตัวพิมพ์เล็กตั้งแต่ชั้น handler เพราะ Go จะ canonicalize เป็น X-Line-Signature ขณะที่ระบบปลายทางซึ่ง port มาจาก Express จะ lookup ด้วย คีย์ x-line-signature

ผลกระทบด้านความปลอดภัยที่ควรบันทึกไว้​

  • endpoint POST /api/line/:id เปิดสาธารณะและไม่มี auth หากผู้อื่นทราบ webhookId ก็สามารถยิง payload ปลอมเข้ามาได้ ด่านป้องกันที่มีอยู่จริงคือ rate limit ต่อ IP และการที่ webhookId เดาได้ยาก
  • การ verify จริงต้องเกิดที่ worker-go ในฐานะ consumer ของคิว line_webhook หาก worker ไม่ verify ก็เท่ากับทั้ง pipeline ไม่มีการ verify
  • payload ที่ publish ลงคิวแนบ headers ครบทุกตัวรวมถึงลายเซ็น จึงมีข้อมูลเพียงพอ ให้ปลายทาง verify ได้

ไฟล์และฟังก์ชันหลัก​

ไฟล์จุดที่เกี่ยวข้อง
internal/line/handler.goHandler.processLine ทำ io.ReadAll แล้ว copy เป็น bodyRaw และ toLowerHeader
internal/line/service.goLineWebhookPayload.MarshalJSON, webhookPayload.MarshalJSON และ marshalWebhookBody ซึ่งเป็นตัวกลางที่เลือกใช้ raw ก่อน map
internal/line/service.goService.forwardWebhook ส่ง raw พร้อมเซ็ต header ลายเซ็นกลับเข้าไปใน request
internal/line/rawbody_test.gotest ที่ยืนยันว่า body ที่ออกไปเป็น bytes เดิมทุกประการ

จุดเชื่อมต่อกับ Service อื่น​

  • worker-go — เป็นผู้รับผิดชอบการ verify จริง เนื่องจากได้รับทั้ง headers และ body ดิบ
  • endpoint ของลูกค้า (forwardWebhookUrl) — verify ได้เองเพราะได้รับทั้ง raw body และ header ลายเซ็น (ดู ส่งต่อ Webhook ไปยังระบบลูกค้า)
  • ข้อควรระวังเวลาแก้โค้ด: ห้ามแก้ไข map Body แล้ว re-marshal ในเส้นทาง publish หรือ forward เพราะจะทำลาย parity นี้ทันที โดยในโค้ดมี comment เตือนไว้ทั้ง 3 จุด
  • จุดที่ parity ยังไม่สมบูรณ์: ใน mbox_handoff บาง branch ได้แก่ exit และ mbox_team ส่ง webhookPayload โดยไม่ได้เซ็ต BodyRaw ทำให้ body ที่ออกไปถูก marshal ใหม่จาก map และมีการเรียง key ใหม่ ซึ่งต่างจาก branch handoff และ department_picker ที่ส่ง raw (ดู ส่งต่อให้เจ้าหน้าที่)