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 (ดู ส่งต่อให้เจ้าหน้าที่)