feat(webhook): require line ingress signatures
This commit is contained in:
@@ -79,8 +79,8 @@ func (h *LineWebhookHandler) HandleLineWebhook(c *gin.Context) {
|
||||
}
|
||||
signature := c.GetHeader("X-Line-Signature")
|
||||
|
||||
if channelSecret != "" && signature != "" {
|
||||
if !h.service.VerifySignature(channelSecret, string(body), signature) {
|
||||
if channelSecret != "" {
|
||||
if signature == "" || h.service == nil || !h.service.VerifySignature(channelSecret, string(body), signature) {
|
||||
applogger.L().Warnf("LINE webhook: invalid signature for inbox=%d", inbox.ID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
|
||||
return
|
||||
|
||||
@@ -192,6 +192,12 @@ func shopifyHMAC(secret string, body []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func lineSignature(secret string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func metaSignature(secret string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
@@ -306,6 +312,10 @@ func TestLineWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "line")
|
||||
inbox.ChannelConfig = `{"channel_secret":"line-secret"}`
|
||||
if err := db.Save(&inbox).Error; err != nil {
|
||||
t.Fatalf("update line inbox config: %v", err)
|
||||
}
|
||||
channelRecord := channelmodel.ChannelLINE{AccountID: 1, InboxID: inbox.ID, ChannelID: "line-channel-1", Name: "LINE OA"}
|
||||
if err := db.Create(&channelRecord).Error; err != nil {
|
||||
t.Fatalf("create line channel: %v", err)
|
||||
@@ -320,6 +330,7 @@ func TestLineWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
body := []byte(`{"destination":"line-channel-1","events":[{"type":"message","replyToken":"reply-1","timestamp":1710000000000,"source":{"type":"user","userId":"line-user-1"},"message":{"type":"text","id":"line-msg-1","text":"hello line"}}]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/line/line-channel-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Line-Signature", lineSignature("line-secret", body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
@@ -330,6 +341,44 @@ func TestLineWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
assertPersistedMessage(t, db, inbox.ID, "line-msg-1", "hello line")
|
||||
}
|
||||
|
||||
func TestLineWebhookRejectsMissingSignatureWhenSecretConfigured(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "line")
|
||||
inbox.ChannelConfig = `{"channel_secret":"line-secret"}`
|
||||
if err := db.Save(&inbox).Error; err != nil {
|
||||
t.Fatalf("update line inbox config: %v", err)
|
||||
}
|
||||
channelRecord := channelmodel.ChannelLINE{AccountID: 1, InboxID: inbox.ID, ChannelID: "line-channel-1", Name: "LINE OA"}
|
||||
if err := db.Create(&channelRecord).Error; err != nil {
|
||||
t.Fatalf("create line channel: %v", err)
|
||||
}
|
||||
|
||||
lineRepo := linechannel.NewRepository(db)
|
||||
lineService := linechannel.NewLineService(lineRepo)
|
||||
linePipeline := linechannel.NewIncomingProcessor(lineService)
|
||||
h := NewLineWebhookHandler(nil, linePipeline, lineService, db)
|
||||
r := gin.New()
|
||||
r.POST("/webhooks/line/:line_channel_id", h.HandleLineWebhook)
|
||||
body := []byte(`{"destination":"line-channel-1","events":[{"type":"message","replyToken":"reply-1","timestamp":1710000000000,"source":{"type":"user","userId":"line-user-1"},"message":{"type":"text","id":"line-msg-missing-sig","text":"hello line"}}]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/line/line-channel-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Message{}).Where("inbox_id = ? AND source_id = ?", inbox.ID, "line-msg-missing-sig").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count message: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected no persisted message, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwilioWebhookLookupInboxByPhoneNumber(t *testing.T) {
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "twilio_sms")
|
||||
|
||||
Reference in New Issue
Block a user