From 480de4690fc0e675fd2ca2765bcfa3bf764892d4 Mon Sep 17 00:00:00 2001 From: Snowball_233 Date: Fri, 20 Jun 2025 16:14:42 +0800 Subject: [PATCH 1/8] Fix Feishu webhook signature verification --- services/webhook/feishu.go | 71 +++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index 274aaf90b3b28..0054535ee6779 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -4,10 +4,16 @@ package webhook import ( + "bytes" "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" "fmt" "net/http" "strings" + "time" webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" @@ -16,10 +22,12 @@ import ( ) type ( - // FeishuPayload represents + // FeishuPayload represents the payload for Feishu webhook FeishuPayload struct { - MsgType string `json:"msg_type"` // text / post / image / share_chat / interactive / file /audio / media - Content struct { + Timestamp int64 `json:"timestamp,omitempty"` // Unix timestamp for signature verification + Sign string `json:"sign,omitempty"` // Signature for verification + MsgType string `json:"msg_type"` // text / post / image / share_chat / interactive / file /audio / media + Content struct { Text string `json:"text"` } `json:"content"` } @@ -178,9 +186,64 @@ func (feishuConvertor) WorkflowJob(p *api.WorkflowJobPayload) (FeishuPayload, er return newFeishuTextPayload(text), nil } +// GenSign generates a signature for Feishu webhook +// https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot +func GenSign(secret string, timestamp int64) (string, error) { + // timestamp + key do sha256, then base64 encode + stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret + + h := hmac.New(sha256.New, []byte(stringToSign)) + _, err := h.Write([]byte{}) + if err != nil { + return "", err + } + + signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + return signature, nil +} + func newFeishuRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { var pc payloadConvertor[FeishuPayload] = feishuConvertor{} - return newJSONRequest(pc, w, t, true) + + // Get the payload first + payload, err := newPayload(pc, []byte(t.PayloadContent), t.EventType) + if err != nil { + return nil, nil, err + } + + // Add timestamp and signature if secret is provided + if w.Secret != "" { + timestamp := time.Now().Unix() + payload.Timestamp = timestamp + + // Generate signature + sign, err := GenSign(w.Secret, timestamp) + if err != nil { + return nil, nil, err + } + payload.Sign = sign + } + + // Marshal the payload + body, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return nil, nil, err + } + + // Create the request + method := w.HTTPMethod + if method == "" { + method = http.MethodPost + } + + req, err := http.NewRequest(method, w.URL, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + + // Add default headers + return req, body, addDefaultHeaders(req, []byte(w.Secret), w, t, body) } func init() { From f4888c4c640a6bf580539a72196e143c8b379dfd Mon Sep 17 00:00:00 2001 From: Snowball_233 Date: Fri, 20 Jun 2025 16:41:52 +0800 Subject: [PATCH 2/8] lint --- services/webhook/feishu.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index 0054535ee6779..b5c5e50947dfb 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -26,7 +26,7 @@ type ( FeishuPayload struct { Timestamp int64 `json:"timestamp,omitempty"` // Unix timestamp for signature verification Sign string `json:"sign,omitempty"` // Signature for verification - MsgType string `json:"msg_type"` // text / post / image / share_chat / interactive / file /audio / media + MsgType string `json:"msg_type"` // text / post / image / share_chat / interactive / file /audio / media Content struct { Text string `json:"text"` } `json:"content"` @@ -204,18 +204,18 @@ func GenSign(secret string, timestamp int64) (string, error) { func newFeishuRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { var pc payloadConvertor[FeishuPayload] = feishuConvertor{} - + // Get the payload first payload, err := newPayload(pc, []byte(t.PayloadContent), t.EventType) if err != nil { return nil, nil, err } - + // Add timestamp and signature if secret is provided if w.Secret != "" { timestamp := time.Now().Unix() payload.Timestamp = timestamp - + // Generate signature sign, err := GenSign(w.Secret, timestamp) if err != nil { @@ -223,25 +223,25 @@ func newFeishuRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_mo } payload.Sign = sign } - + // Marshal the payload body, err := json.MarshalIndent(payload, "", " ") if err != nil { return nil, nil, err } - + // Create the request method := w.HTTPMethod if method == "" { method = http.MethodPost } - + req, err := http.NewRequest(method, w.URL, bytes.NewReader(body)) if err != nil { return nil, nil, err } req.Header.Set("Content-Type", "application/json") - + // Add default headers return req, body, addDefaultHeaders(req, []byte(w.Secret), w, t, body) } From d5dd799ce65cf33d6121f99a9d1d6d90c7931f90 Mon Sep 17 00:00:00 2001 From: Snowball_233 Date: Sat, 21 Jun 2025 00:33:29 +0800 Subject: [PATCH 3/8] Fix: build error --- services/webhook/feishu.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index b5c5e50947dfb..f2b573a6e93b4 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -9,14 +9,15 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" - "encoding/json" "fmt" "net/http" + "strconv" "strings" "time" webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" ) @@ -190,7 +191,7 @@ func (feishuConvertor) WorkflowJob(p *api.WorkflowJobPayload) (FeishuPayload, er // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot func GenSign(secret string, timestamp int64) (string, error) { // timestamp + key do sha256, then base64 encode - stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret + stringToSign := strconv.FormatInt(timestamp, 10) + "\n" + secret h := hmac.New(sha256.New, []byte(stringToSign)) _, err := h.Write([]byte{}) From 087dbda72d4764e5759da9a38190b482326ce22d Mon Sep 17 00:00:00 2001 From: Snowball_233 Date: Sat, 21 Jun 2025 01:28:23 +0800 Subject: [PATCH 4/8] Update services/webhook/feishu.go **Performance Overhead** `fmt.Sprintf` is **slower** than simple string concatenation or `strconv.FormatInt` because: * It uses reflection and parsing logic internally. * It's designed for formatting many types, not just basic strings or numbers. Co-Authored-By: hiifong --- services/webhook/feishu.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index 00b9d2c5684be..2acd391420d23 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -197,7 +197,7 @@ func (feishuConvertor) WorkflowJob(p *api.WorkflowJobPayload) (FeishuPayload, er // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot func GenSign(secret string, timestamp int64) (string, error) { // timestamp + key do sha256, then base64 encode - stringToSign := strconv.FormatInt(timestamp, 10) + "\n" + secret + stringToSign := fmt.Sprintf("%d\n%s", timestamp, secret) h := hmac.New(sha256.New, []byte(stringToSign)) _, err := h.Write([]byte{}) From 50219772164407850f10c36146b894ee875a25c8 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 21 Jun 2025 01:43:06 +0800 Subject: [PATCH 5/8] refactor --- services/webhook/feishu.go | 52 +++++---------------------------- services/webhook/feishu_test.go | 4 +++ services/webhook/payloader.go | 3 ++ 3 files changed, 14 insertions(+), 45 deletions(-) diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index 2acd391420d23..5d259dc7de7f8 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -4,20 +4,17 @@ package webhook import ( - "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "net/http" - "strconv" "strings" "time" webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" ) @@ -193,27 +190,17 @@ func (feishuConvertor) WorkflowJob(p *api.WorkflowJobPayload) (FeishuPayload, er return newFeishuTextPayload(text), nil } -// GenSign generates a signature for Feishu webhook +// feishuGenSign generates a signature for Feishu webhook // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot -func GenSign(secret string, timestamp int64) (string, error) { - // timestamp + key do sha256, then base64 encode +func feishuGenSign(secret string, timestamp int64) string { + // key="{timestamp}\n{secret}", then hmac-sha256, then base64 encode stringToSign := fmt.Sprintf("%d\n%s", timestamp, secret) - h := hmac.New(sha256.New, []byte(stringToSign)) - _, err := h.Write([]byte{}) - if err != nil { - return "", err - } - - signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) - return signature, nil + return base64.StdEncoding.EncodeToString(h.Sum(nil)) } func newFeishuRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { - var pc payloadConvertor[FeishuPayload] = feishuConvertor{} - - // Get the payload first - payload, err := newPayload(pc, []byte(t.PayloadContent), t.EventType) + payload, err := newPayload(feishuConvertor{}, []byte(t.PayloadContent), t.EventType) if err != nil { return nil, nil, err } @@ -222,35 +209,10 @@ func newFeishuRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_mo if w.Secret != "" { timestamp := time.Now().Unix() payload.Timestamp = timestamp - - // Generate signature - sign, err := GenSign(w.Secret, timestamp) - if err != nil { - return nil, nil, err - } - payload.Sign = sign - } - - // Marshal the payload - body, err := json.MarshalIndent(payload, "", " ") - if err != nil { - return nil, nil, err - } - - // Create the request - method := w.HTTPMethod - if method == "" { - method = http.MethodPost - } - - req, err := http.NewRequest(method, w.URL, bytes.NewReader(body)) - if err != nil { - return nil, nil, err + payload.Sign = feishuGenSign(w.Secret, timestamp) } - req.Header.Set("Content-Type", "application/json") - // Add default headers - return req, body, addDefaultHeaders(req, []byte(w.Secret), w, t, body) + return sendHTTPRequest(payload, w, t, false) } func init() { diff --git a/services/webhook/feishu_test.go b/services/webhook/feishu_test.go index c4249bdb30832..76159218e937a 100644 --- a/services/webhook/feishu_test.go +++ b/services/webhook/feishu_test.go @@ -190,3 +190,7 @@ func TestFeishuJSONPayload(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "[test/repo:test] \r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.Content.Text) } + +func TestFeishuGenSign(t *testing.T) { + assert.Equal(t, "rWZ84lcag1x9aBFhn1gtV4ZN+4gme3pilfQNMk86vKg=", feishuGenSign("a", 1)) +} diff --git a/services/webhook/payloader.go b/services/webhook/payloader.go index c25d700c231d0..8314bcd9bd6a0 100644 --- a/services/webhook/payloader.go +++ b/services/webhook/payloader.go @@ -95,7 +95,10 @@ func newJSONRequest[T any](pc payloadConvertor[T], w *webhook_model.Webhook, t * if err != nil { return nil, nil, err } + return sendHTTPRequest(payload, w, t, withDefaultHeaders) +} +func sendHTTPRequest[T any](payload T, w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) { body, err := json.MarshalIndent(payload, "", " ") if err != nil { return nil, nil, err From 2366dd7bd5df9a433e4bafe397a7da35dcd55325 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 21 Jun 2025 01:51:14 +0800 Subject: [PATCH 6/8] better name --- services/webhook/feishu.go | 2 +- services/webhook/payloader.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index 5d259dc7de7f8..b6ee80c44cc7a 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -212,7 +212,7 @@ func newFeishuRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_mo payload.Sign = feishuGenSign(w.Secret, timestamp) } - return sendHTTPRequest(payload, w, t, false) + return prepareJSONRequest(payload, w, t, false /* no default headers */) } func init() { diff --git a/services/webhook/payloader.go b/services/webhook/payloader.go index 8314bcd9bd6a0..b607bf3250332 100644 --- a/services/webhook/payloader.go +++ b/services/webhook/payloader.go @@ -95,10 +95,10 @@ func newJSONRequest[T any](pc payloadConvertor[T], w *webhook_model.Webhook, t * if err != nil { return nil, nil, err } - return sendHTTPRequest(payload, w, t, withDefaultHeaders) + return prepareJSONRequest(payload, w, t, withDefaultHeaders) } -func sendHTTPRequest[T any](payload T, w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) { +func prepareJSONRequest[T any](payload T, w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) { body, err := json.MarshalIndent(payload, "", " ") if err != nil { return nil, nil, err From 984d9fe19cf9d766106cd685e4a370eea0b171ca Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 21 Jun 2025 02:21:51 +0800 Subject: [PATCH 7/8] fix test --- services/webhook/feishu_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/services/webhook/feishu_test.go b/services/webhook/feishu_test.go index 76159218e937a..c07a1995a2356 100644 --- a/services/webhook/feishu_test.go +++ b/services/webhook/feishu_test.go @@ -183,7 +183,6 @@ func TestFeishuJSONPayload(t *testing.T) { assert.Equal(t, "POST", req.Method) assert.Equal(t, "https://feishu.example.com/", req.URL.String()) - assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) assert.Equal(t, "application/json", req.Header.Get("Content-Type")) var body FeishuPayload err = json.NewDecoder(req.Body).Decode(&body) From 457cb235ab2fa54fc73a5c9761e907daafce4728 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 21 Jun 2025 02:24:52 +0800 Subject: [PATCH 8/8] improve test --- services/webhook/feishu_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/webhook/feishu_test.go b/services/webhook/feishu_test.go index c07a1995a2356..7e200ea13201d 100644 --- a/services/webhook/feishu_test.go +++ b/services/webhook/feishu_test.go @@ -168,6 +168,7 @@ func TestFeishuJSONPayload(t *testing.T) { URL: "https://feishu.example.com/", Meta: `{}`, HTTPMethod: "POST", + Secret: "secret", } task := &webhook_model.HookTask{ HookID: hook.ID, @@ -188,8 +189,8 @@ func TestFeishuJSONPayload(t *testing.T) { err = json.NewDecoder(req.Body).Decode(&body) assert.NoError(t, err) assert.Equal(t, "[test/repo:test] \r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.Content.Text) -} + assert.Equal(t, feishuGenSign(hook.Secret, body.Timestamp), body.Sign) -func TestFeishuGenSign(t *testing.T) { + // a separate sign test, the result is generated by official python code, so the algo must be correct assert.Equal(t, "rWZ84lcag1x9aBFhn1gtV4ZN+4gme3pilfQNMk86vKg=", feishuGenSign("a", 1)) }