From c3b0b962bfb22c29bd699f49ea4093d607ff7f27 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:36:57 +0700 Subject: [PATCH 01/13] feat: adapt to webhooks v2 --- mocks/integration_create_payload.json | 19 ++++ mocks/integration_delete_payload.json | 6 ++ mocks/vote_create_payload.json | 21 ++++ mocks/webhook_test_payload.json | 17 +++ payload.go | 37 +++++++ webhook.go | 110 ------------------- webhook_test.go | 50 --------- webhooks.go | 147 ++++++++++++++++++++++++++ webhooks_test.go | 84 +++++++++++++++ 9 files changed, 331 insertions(+), 160 deletions(-) create mode 100644 mocks/integration_create_payload.json create mode 100644 mocks/integration_delete_payload.json create mode 100644 mocks/vote_create_payload.json create mode 100644 mocks/webhook_test_payload.json create mode 100644 payload.go delete mode 100644 webhook.go delete mode 100644 webhook_test.go create mode 100644 webhooks.go create mode 100644 webhooks_test.go diff --git a/mocks/integration_create_payload.json b/mocks/integration_create_payload.json new file mode 100644 index 0000000..77df6b0 --- /dev/null +++ b/mocks/integration_create_payload.json @@ -0,0 +1,19 @@ +{ + "type": "integration.create", + "data": { + "connection_id": "112402021105124", + "webhook_secret": "whs_abcd", + "project": { + "id": "1230954036934033243", + "platform": "discord", + "platform_id": "3949456393249234923", + "type": "bot" + }, + "user": { + "id": "3949456393249234923", + "platform_id": "3949456393249234923", + "name": "username", + "avatar_url": "" + } + } +} \ No newline at end of file diff --git a/mocks/integration_delete_payload.json b/mocks/integration_delete_payload.json new file mode 100644 index 0000000..cb44375 --- /dev/null +++ b/mocks/integration_delete_payload.json @@ -0,0 +1,6 @@ +{ + "type": "integration.delete", + "data": { + "connection_id": "112402021105124" + } +} \ No newline at end of file diff --git a/mocks/vote_create_payload.json b/mocks/vote_create_payload.json new file mode 100644 index 0000000..6850196 --- /dev/null +++ b/mocks/vote_create_payload.json @@ -0,0 +1,21 @@ +{ + "type": "vote.create", + "data": { + "id": "808499215864008704", + "weight": 1, + "created_at": "2026-02-09T00:47:14.2510149+00:00", + "expires_at": "2026-02-09T12:47:14.2510149+00:00", + "project": { + "id": "803190510032756736", + "type": "bot", + "platform": "discord", + "platform_id": "160105994217586689" + }, + "user": { + "id": "160105994217586689", + "platform_id": "160105994217586689", + "name": "username", + "avatar_url": "" + } + } +} \ No newline at end of file diff --git a/mocks/webhook_test_payload.json b/mocks/webhook_test_payload.json new file mode 100644 index 0000000..b7a7432 --- /dev/null +++ b/mocks/webhook_test_payload.json @@ -0,0 +1,17 @@ +{ + "type": "webhook.test", + "data": { + "user": { + "id": "160105994217586689", + "platform_id": "160105994217586689", + "name": "username", + "avatar_url": "" + }, + "project": { + "id": "803190510032756736", + "type": "bot", + "platform": "discord", + "platform_id": "160105994217586689" + } + } +} \ No newline at end of file diff --git a/payload.go b/payload.go new file mode 100644 index 0000000..539058b --- /dev/null +++ b/payload.go @@ -0,0 +1,37 @@ +package dbl + +import "time" + +// An `integration.create` webhook payload. Fires when a user has connected to your webhook integration. +type IntegrationCreatePayload struct { + ConnectionId string `json:"connection_id"` // The unique identifier for this connection. + Secret string `json:"webhook_secret"` // The secret used to verify future webhook deliveries. + Project PartialProject `json:"project"` // The project that the integration refers to. + User User `json:"user"` // The user who triggered this event. +} + +// An `integration.delete` webhook payload. Fires when a user has disconnected from your webhook integration. +type IntegrationDeletePayload struct { + ConnectionId string `json:"connection_id"` // The unique identifier for this connection. +} + +// A `webhook.test` webhook payload. Fires upon sent test from the project dashboard. +type TestPayload struct { + Project PartialProject `json:"project"` // The project that the test refers to. + User User `json:"user"` // The user who triggered this test. +} + +// A `vote.create` webhook payload. Fires when a user votes for your project. +type VoteCreatePayload struct { + Id string `json:"id"` // The vote's ID. + Weight int `json:"weight"` // The number of votes this vote counted for. This is a rounded integer value which determines how many points this individual vote was worth. + VotedAt time.Time `json:"created_at"` // When the vote was cast. + ExpiresAt time.Time `json:"expires_at"` // When the vote expires and the user is required to vote again. + Project PartialProject `json:"project"` // The project that received this vote. + User User `json:"user"` // The user who voted for this project. +} + +// All possible webhook payloads. +type Payload interface { + IntegrationCreatePayload | IntegrationDeletePayload | TestPayload | VoteCreatePayload +} diff --git a/webhook.go b/webhook.go deleted file mode 100644 index fc01bf4..0000000 --- a/webhook.go +++ /dev/null @@ -1,110 +0,0 @@ -package dbl - -import ( - "encoding/json" - "io/ioutil" - "net/http" - "net/url" -) - -type ListenerFunc func(*WebhookPayload) - -type WebhookListener struct { - token string - handler ListenerFunc - mux *http.ServeMux -} - -type WebhookPayload struct { - // ID of the bot that received a vote - Bot string - - // ID of the user who voted - User string - - // The type of the vote (should always be "upvote" except when using the test button it's "test") - Type string - - // Whether the weekend multiplier is in effect, meaning users votes count as two - IsWeekend bool - - // Query string params found on the /bot/:ID/vote page. Example: ?a=1&b=2 - Query url.Values -} - -type wPayload struct { - // ID of the bot that received a vote - Bot string `json:"bot"` - - // ID of the user who voted - User string `json:"user"` - - // The type of the vote (should always be "upvote" except when using the test button it's "test") - Type string `json:"type"` - - // Whether the weekend multiplier is in effect, meaning users votes count as two - IsWeekend bool `json:"isWeekend"` - - // Query string params found on the /bot/:ID/vote page. Example: ?a=1&b=2 - Query string `json:"query"` -} - -// Create a new webhook listener -func NewListener(token string, handler func(*WebhookPayload)) *WebhookListener { - return &WebhookListener{ - token: token, - handler: ListenerFunc(handler), - } -} - -// Starts listening on specific address. A Blocking call. -// Returns non-nil error from ListenAndServe -func (wl *WebhookListener) Serve(addr string) error { - wl.mux = http.NewServeMux() - - wl.mux.HandleFunc("/", wl.handlePayload) - - return http.ListenAndServe(addr, wl.mux) -} - -func (wl *WebhookListener) handlePayload(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.WriteHeader(http.StatusMethodNotAllowed) - - return - } - - if r.Header.Get("Authorization") != wl.token { - w.WriteHeader(http.StatusUnauthorized) - - return - } - - body, err := ioutil.ReadAll(r.Body) - - if err != nil { - return - } - - p := &wPayload{} - - if err = json.Unmarshal(body, p); err != nil { - return - } - - m, err := url.ParseQuery(p.Query) - - if err != nil { - return - } - - w.WriteHeader(http.StatusNoContent) - - wl.handler(&WebhookPayload{ - Bot: p.Bot, - User: p.User, - Type: p.Type, - IsWeekend: p.IsWeekend, - Query: m, - }) -} diff --git a/webhook_test.go b/webhook_test.go deleted file mode 100644 index 2647c9c..0000000 --- a/webhook_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package dbl - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -const ( - testToken = "wblAV@d!Od9uL761Rz23BEQC$#YCJdQ0nDlZUEfnDxY" -) - -var ( - testPayload = []byte(`{"bot":"441751906428256277","user":"105122038586286080","type":"upvote","isWeekend":false,"query":""}`) - testListener = NewListener(testToken, func(p *WebhookPayload) {}) -) - -func TestHookMethod(t *testing.T) { - rec := httptest.NewRecorder() - - req := httptest.NewRequest(http.MethodGet, "/", bytes.NewBuffer(testPayload)) - - testListener.handlePayload(rec, req) - - assert.Equal(t, http.StatusMethodNotAllowed, rec.Code, "GET method should not be allowed") -} - -func TestHookAuthentication(t *testing.T) { - rec := httptest.NewRecorder() - - req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(testPayload)) - - testListener.handlePayload(rec, req) - - assert.Equal(t, http.StatusUnauthorized, rec.Code, "Unauthorized request should not be processed") -} - -func TestWebhookProcessing(t *testing.T) { - rec := httptest.NewRecorder() - - req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(testPayload)) - req.Header.Set("Authorization", testToken) - - testListener.handlePayload(rec, req) - - assert.Equal(t, http.StatusNoContent, rec.Code, "Request should succeed w/o content") -} diff --git a/webhooks.go b/webhooks.go new file mode 100644 index 0000000..e4c90ae --- /dev/null +++ b/webhooks.go @@ -0,0 +1,147 @@ +package dbl + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type rawListener = func(http.ResponseWriter, json.RawMessage, string) + +// A Top.gg webhook manager. +type Webhooks struct { + Secret string + listeners map[string]rawListener +} + +// Creates a new webhook manager instance. +func NewWebhooks(Secret string) *Webhooks { + return &Webhooks{ + Secret: Secret, + listeners: make(map[string]rawListener), + } +} + +func newRawListener[P Payload](listener func(http.ResponseWriter, *P, string)) rawListener { + return func(res http.ResponseWriter, rawPayload json.RawMessage, trace string) { + var payload P + + if err := json.Unmarshal(rawPayload, &payload); err != nil { + res.WriteHeader(http.StatusBadRequest) + } else { + listener(res, &payload, trace) + } + } +} + +// Registers a listener that fires when a user has connected to your webhook integration. +func (webhooks *Webhooks) OnIntegrationCreate(listener func(http.ResponseWriter, *IntegrationCreatePayload, string)) { + webhooks.listeners["integration.create"] = newRawListener(listener) +} + +// Registers a listener that fires when a user has disconnected from your webhook integration. +func (webhooks *Webhooks) OnIntegrationDelete(listener func(http.ResponseWriter, *IntegrationDeletePayload, string)) { + webhooks.listeners["integration.delete"] = newRawListener(listener) +} + +// Registers a listener that fires upon sent test from the project dashboard. +func (webhooks *Webhooks) OnTest(listener func(http.ResponseWriter, *TestPayload, string)) { + webhooks.listeners["webhook.test"] = newRawListener(listener) +} + +// Registers a listener that fires when a user votes for your project. +func (webhooks *Webhooks) OnVoteCreate(listener func(http.ResponseWriter, *VoteCreatePayload, string)) { + webhooks.listeners["vote.create"] = newRawListener(listener) +} + +type rawPayload struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +// The handler function to be passed to HandleFunc. +func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + res.WriteHeader(http.StatusMethodNotAllowed) + + return + } + + signatureHeader := req.Header.Get("x-topgg-signature") + trace := req.Header.Get("x-topgg-trace") + + if signatureHeader == "" || trace == "" { + res.WriteHeader(http.StatusUnauthorized) + + return + } + + var timestamp, signature string + + for pair := range strings.SplitSeq(signatureHeader, ",") { + parts := strings.Split(pair, "=") + + if len(parts) != 2 { + res.WriteHeader(http.StatusUnprocessableEntity) + + return + } + + switch parts[0] { + case "t": + timestamp = parts[1] + case APIVersion: + signature = parts[1] + } + } + + if timestamp == "" || signature == "" { + res.WriteHeader(http.StatusUnprocessableEntity) + + return + } + + defer req.Body.Close() + + body, err := io.ReadAll(req.Body) + + if err != nil { + res.WriteHeader(http.StatusBadRequest) + + return + } + + mac := hmac.New(sha256.New, []byte(webhooks.Secret)) + mac.Write(fmt.Appendf(nil, "%s.%s", timestamp, body)) + + digest := hex.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(digest), []byte(signature)) { + res.WriteHeader(http.StatusForbidden) + + return + } + + payload := &rawPayload{} + + if err = json.Unmarshal(body, payload); err != nil { + res.WriteHeader(http.StatusBadRequest) + + return + } + + for event, listener := range webhooks.listeners { + if event == payload.Type { + listener(res, body, trace) + + return + } + } + + res.WriteHeader(http.StatusNoContent) +} diff --git a/webhooks_test.go b/webhooks_test.go new file mode 100644 index 0000000..b190ab0 --- /dev/null +++ b/webhooks_test.go @@ -0,0 +1,84 @@ +package dbl + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const webhooksSecret = "secret" +const webhooksTrace = "trace" + +var webhookEvents = []string{"integration.create", "integration.delete", "webhook.test", "vote.create"} + +func defaultResponse(res http.ResponseWriter, name, trace string) { + res.WriteHeader(http.StatusOK) + res.Write(fmt.Appendf(nil, "%s:%s", name, trace)) +} + +func mockSignature(body []byte) string { + timestamp := time.Now().UTC().Unix() + + mac := hmac.New(sha256.New, []byte(webhooksSecret)) + mac.Write(fmt.Appendf(nil, "%d.%s", timestamp, body)) + + digest := hex.EncodeToString(mac.Sum(nil)) + + return fmt.Sprintf("t=%d,"+APIVersion+"=%s", timestamp, digest) +} + +func TestWebhooks(t *testing.T) { + webhooks := NewWebhooks(webhooksSecret) + + webhooks.OnIntegrationCreate(func(res http.ResponseWriter, payload *IntegrationCreatePayload, trace string) { + defaultResponse(res, "integration.create", trace) + }) + + webhooks.OnIntegrationDelete(func(res http.ResponseWriter, payload *IntegrationDeletePayload, trace string) { + defaultResponse(res, "integration.delete", trace) + }) + + webhooks.OnTest(func(res http.ResponseWriter, payload *TestPayload, trace string) { + defaultResponse(res, "webhook.test", trace) + }) + + webhooks.OnVoteCreate(func(res http.ResponseWriter, payload *VoteCreatePayload, trace string) { + defaultResponse(res, "vote.create", trace) + }) + + for _, event := range webhookEvents { + rec := httptest.NewRecorder() + + file, err := os.Open(fmt.Sprintf("mocks/%s_payload.json", strings.ReplaceAll(event, ".", "_"))) + + assert.Nilf(t, err, "The mock request body JSON file for %s must be available.", event) + + defer file.Close() + + body, err := io.ReadAll(file) + + assert.Nilf(t, err, "The mock request body JSON file for %s must be readable.", event) + + req := httptest.NewRequest(http.MethodPost, "/webhook", io.NopCloser(bytes.NewReader(body))) + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("x-topgg-signature", mockSignature(body)) + req.Header.Add("x-topgg-trace", webhooksTrace) + + webhooks.Handler(rec, req) + + assert.Equalf(t, http.StatusOK, rec.Code, "Sending a %s payload must be responded with 200.", event) + assert.Equalf(t, fmt.Sprintf("%s:%s", event, webhooksTrace), rec.Body.String(), "Sending a %s payload must be responded with the expected response body.", event) + } +} From f7239aa16db1f5a81c48ebc05255b593f431b282 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:41:31 +0700 Subject: [PATCH 02/13] refactor: use req.Header.Set instead of req.Header.Add --- webhooks_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webhooks_test.go b/webhooks_test.go index b190ab0..16af131 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -72,9 +72,9 @@ func TestWebhooks(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/webhook", io.NopCloser(bytes.NewReader(body))) - req.Header.Add("Content-Type", "application/json") - req.Header.Add("x-topgg-signature", mockSignature(body)) - req.Header.Add("x-topgg-trace", webhooksTrace) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-topgg-signature", mockSignature(body)) + req.Header.Set("x-topgg-trace", webhooksTrace) webhooks.Handler(rec, req) From 0d5bd3427c8561ccffad554f128b78569fbf691a Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:27:35 +0700 Subject: [PATCH 03/13] doc: make docstrings always start with the subject they are describing --- payload.go | 10 +++++----- webhooks.go | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/payload.go b/payload.go index 539058b..e34b32c 100644 --- a/payload.go +++ b/payload.go @@ -2,7 +2,7 @@ package dbl import "time" -// An `integration.create` webhook payload. Fires when a user has connected to your webhook integration. +// IntegrationCreatePayload represents an 'integration.create' webhook.payload. Fires when a user has connected to your webhook integration. type IntegrationCreatePayload struct { ConnectionId string `json:"connection_id"` // The unique identifier for this connection. Secret string `json:"webhook_secret"` // The secret used to verify future webhook deliveries. @@ -10,18 +10,18 @@ type IntegrationCreatePayload struct { User User `json:"user"` // The user who triggered this event. } -// An `integration.delete` webhook payload. Fires when a user has disconnected from your webhook integration. +// IntegrationDeletePayload represents an 'integration.delete' webhook.payload. Fires when a user has disconnected from your webhook integration. type IntegrationDeletePayload struct { ConnectionId string `json:"connection_id"` // The unique identifier for this connection. } -// A `webhook.test` webhook payload. Fires upon sent test from the project dashboard. +// TestPayload represents a 'webhook.test' webhook.payload. Fires upon sent test from the project dashboard. type TestPayload struct { Project PartialProject `json:"project"` // The project that the test refers to. User User `json:"user"` // The user who triggered this test. } -// A `vote.create` webhook payload. Fires when a user votes for your project. +// VoteCreatePayload represents a 'vote.create' webhook.payload. Fires when a user votes for your project. type VoteCreatePayload struct { Id string `json:"id"` // The vote's ID. Weight int `json:"weight"` // The number of votes this vote counted for. This is a rounded integer value which determines how many points this individual vote was worth. @@ -31,7 +31,7 @@ type VoteCreatePayload struct { User User `json:"user"` // The user who voted for this project. } -// All possible webhook payloads. +// Payload represents all possible webhook payloads. type Payload interface { IntegrationCreatePayload | IntegrationDeletePayload | TestPayload | VoteCreatePayload } diff --git a/webhooks.go b/webhooks.go index e4c90ae..26e06aa 100644 --- a/webhooks.go +++ b/webhooks.go @@ -13,13 +13,13 @@ import ( type rawListener = func(http.ResponseWriter, json.RawMessage, string) -// A Top.gg webhook manager. +// Webhooks represents a Top.gg webhook manager. type Webhooks struct { Secret string listeners map[string]rawListener } -// Creates a new webhook manager instance. +// NewWebhooks is a function that creates a new webhook manager instance. func NewWebhooks(Secret string) *Webhooks { return &Webhooks{ Secret: Secret, @@ -39,22 +39,22 @@ func newRawListener[P Payload](listener func(http.ResponseWriter, *P, string)) r } } -// Registers a listener that fires when a user has connected to your webhook integration. +// OnIntegrationCreate is a method that registers a listener that fires when a user has connected to your webhook integration. func (webhooks *Webhooks) OnIntegrationCreate(listener func(http.ResponseWriter, *IntegrationCreatePayload, string)) { webhooks.listeners["integration.create"] = newRawListener(listener) } -// Registers a listener that fires when a user has disconnected from your webhook integration. +// OnIntegrationDelete is a method that registers a listener that fires when a user has disconnected from your webhook integration. func (webhooks *Webhooks) OnIntegrationDelete(listener func(http.ResponseWriter, *IntegrationDeletePayload, string)) { webhooks.listeners["integration.delete"] = newRawListener(listener) } -// Registers a listener that fires upon sent test from the project dashboard. +// OnTest is a method that registers a listener that fires upon sent test from the project dashboard. func (webhooks *Webhooks) OnTest(listener func(http.ResponseWriter, *TestPayload, string)) { webhooks.listeners["webhook.test"] = newRawListener(listener) } -// Registers a listener that fires when a user votes for your project. +// OnVoteCreate is a method that registers a listener that fires when a user votes for your project. func (webhooks *Webhooks) OnVoteCreate(listener func(http.ResponseWriter, *VoteCreatePayload, string)) { webhooks.listeners["vote.create"] = newRawListener(listener) } @@ -64,7 +64,7 @@ type rawPayload struct { Data json.RawMessage `json:"data"` } -// The handler function to be passed to HandleFunc. +// Handler is the handler function to be passed to http.HandleFunc. func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { res.WriteHeader(http.StatusMethodNotAllowed) From a763bd76b9b1d90a5651342e4b54c180eb0c27c5 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:15:20 +0700 Subject: [PATCH 04/13] fix: report unmarshal failure and return 204 instead of 400 --- webhooks.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/webhooks.go b/webhooks.go index 26e06aa..2b52edf 100644 --- a/webhooks.go +++ b/webhooks.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "strings" ) @@ -27,12 +28,18 @@ func NewWebhooks(Secret string) *Webhooks { } } +func reportPayloadUnmarshalFailure(body []byte, err error) { + slog.Warn(fmt.Sprintf("Unable to parse Top.gg webhook payload. Please report this bug to the SDK maintainers.\nCause: %s\n--- BEGIN BODY DUMP ---\n%s\n--- END BODY DUMP ---", err.Error(), body)) +} + func newRawListener[P Payload](listener func(http.ResponseWriter, *P, string)) rawListener { return func(res http.ResponseWriter, rawPayload json.RawMessage, trace string) { var payload P if err := json.Unmarshal(rawPayload, &payload); err != nil { - res.WriteHeader(http.StatusBadRequest) + reportPayloadUnmarshalFailure(rawPayload, err) + + res.WriteHeader(http.StatusNoContent) } else { listener(res, &payload, trace) } @@ -130,7 +137,9 @@ func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { payload := &rawPayload{} if err = json.Unmarshal(body, payload); err != nil { - res.WriteHeader(http.StatusBadRequest) + reportPayloadUnmarshalFailure(body, err) + + res.WriteHeader(http.StatusNoContent) return } From 5299653df6ae87c759b97966ba6677dfb0e48bdf Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:50:32 +0700 Subject: [PATCH 05/13] refactor: apply LimitReader constraints to ensure body is smaller than 2MiB --- webhooks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webhooks.go b/webhooks.go index 2b52edf..f4a6f71 100644 --- a/webhooks.go +++ b/webhooks.go @@ -115,7 +115,7 @@ func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { defer req.Body.Close() - body, err := io.ReadAll(req.Body) + body, err := io.ReadAll(io.LimitReader(req.Body, 2*1024*1024)) if err != nil { res.WriteHeader(http.StatusBadRequest) From 8ab3191f08428fb6cd2089df39f243add77406fd Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:59:22 +0700 Subject: [PATCH 06/13] refactor: use maxBodySize constant (see #15) --- webhooks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webhooks.go b/webhooks.go index f4a6f71..19c08a1 100644 --- a/webhooks.go +++ b/webhooks.go @@ -115,7 +115,7 @@ func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { defer req.Body.Close() - body, err := io.ReadAll(io.LimitReader(req.Body, 2*1024*1024)) + body, err := io.ReadAll(io.LimitReader(req.Body, maxBodySize)) if err != nil { res.WriteHeader(http.StatusBadRequest) From b72ff7ef0f5ea2caa1bebf537b9a70600501919d Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 05:15:52 +0700 Subject: [PATCH 07/13] revert: revert the constraint in the client, use hardcoded literal again --- webhooks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webhooks.go b/webhooks.go index 19c08a1..f4a6f71 100644 --- a/webhooks.go +++ b/webhooks.go @@ -115,7 +115,7 @@ func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { defer req.Body.Close() - body, err := io.ReadAll(io.LimitReader(req.Body, maxBodySize)) + body, err := io.ReadAll(io.LimitReader(req.Body, 2*1024*1024)) if err != nil { res.WriteHeader(http.StatusBadRequest) From 646b57359474cffc76fb85120fc493dd8f7f8dd7 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 05:49:03 +0700 Subject: [PATCH 08/13] meta: remove all references to DBL --- payload.go | 2 +- webhooks.go | 2 +- webhooks_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/payload.go b/payload.go index e34b32c..b08b66d 100644 --- a/payload.go +++ b/payload.go @@ -1,4 +1,4 @@ -package dbl +package topgg import "time" diff --git a/webhooks.go b/webhooks.go index f4a6f71..7742358 100644 --- a/webhooks.go +++ b/webhooks.go @@ -1,4 +1,4 @@ -package dbl +package topgg import ( "crypto/hmac" diff --git a/webhooks_test.go b/webhooks_test.go index 16af131..5ffed58 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -1,4 +1,4 @@ -package dbl +package topgg import ( "bytes" From e57d4205a67bfcae62f296161ff4ad254a140b44 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:15:27 +0700 Subject: [PATCH 09/13] doc: fix awkward grammar in OnTest docstring --- webhooks.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webhooks.go b/webhooks.go index 7742358..ed1465b 100644 --- a/webhooks.go +++ b/webhooks.go @@ -56,7 +56,7 @@ func (webhooks *Webhooks) OnIntegrationDelete(listener func(http.ResponseWriter, webhooks.listeners["integration.delete"] = newRawListener(listener) } -// OnTest is a method that registers a listener that fires upon sent test from the project dashboard. +// OnTest is a method that registers a listener that fires when a test webhook was sent from the dashboard. func (webhooks *Webhooks) OnTest(listener func(http.ResponseWriter, *TestPayload, string)) { webhooks.listeners["webhook.test"] = newRawListener(listener) } @@ -154,3 +154,4 @@ func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { res.WriteHeader(http.StatusNoContent) } + From 39dcfd8c928299de7936fa2535feacb76bdc45a7 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:54:18 +0700 Subject: [PATCH 10/13] feat: employ timeout handling --- webhooks.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/webhooks.go b/webhooks.go index ed1465b..ae3b8e3 100644 --- a/webhooks.go +++ b/webhooks.go @@ -10,13 +10,15 @@ import ( "log/slog" "net/http" "strings" + "time" ) type rawListener = func(http.ResponseWriter, json.RawMessage, string) // Webhooks represents a Top.gg webhook manager. type Webhooks struct { - Secret string + Secret string // The secret to use to authorize external requests. + Timeout time.Duration // The timeout for reading payloads. listeners map[string]rawListener } @@ -24,6 +26,7 @@ type Webhooks struct { func NewWebhooks(Secret string) *Webhooks { return &Webhooks{ Secret: Secret, + Timeout: 5 * time.Second, listeners: make(map[string]rawListener), } } @@ -115,6 +118,14 @@ func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { defer req.Body.Close() + controller := http.NewResponseController(res) + + if err := controller.SetReadDeadline(time.Now().Add(webhooks.Timeout)); err != nil { + res.WriteHeader(http.StatusInternalServerError) + + return + } + body, err := io.ReadAll(io.LimitReader(req.Body, 2*1024*1024)) if err != nil { From 7905696e7b53907cc8c678a6e3f680a489a2c03b Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:07:55 +0700 Subject: [PATCH 11/13] doc: specify default timeout seconds --- webhooks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webhooks.go b/webhooks.go index ae3b8e3..ca0d794 100644 --- a/webhooks.go +++ b/webhooks.go @@ -18,7 +18,7 @@ type rawListener = func(http.ResponseWriter, json.RawMessage, string) // Webhooks represents a Top.gg webhook manager. type Webhooks struct { Secret string // The secret to use to authorize external requests. - Timeout time.Duration // The timeout for reading payloads. + Timeout time.Duration // The timeout for reading payloads. Defaults to five seconds. listeners map[string]rawListener } From 53c3a9f723ae9d16a1690792a21deaf0a9df4b8f Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:44:41 +0700 Subject: [PATCH 12/13] style: rename mock variables --- webhooks_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/webhooks_test.go b/webhooks_test.go index 5ffed58..8726243 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -17,8 +17,8 @@ import ( "github.com/stretchr/testify/assert" ) -const webhooksSecret = "secret" -const webhooksTrace = "trace" +const mockWebhookSecret = "testsecret1234" +const mockWebhookTrace = "trace" var webhookEvents = []string{"integration.create", "integration.delete", "webhook.test", "vote.create"} @@ -30,7 +30,7 @@ func defaultResponse(res http.ResponseWriter, name, trace string) { func mockSignature(body []byte) string { timestamp := time.Now().UTC().Unix() - mac := hmac.New(sha256.New, []byte(webhooksSecret)) + mac := hmac.New(sha256.New, []byte(mockWebhookSecret)) mac.Write(fmt.Appendf(nil, "%d.%s", timestamp, body)) digest := hex.EncodeToString(mac.Sum(nil)) @@ -39,7 +39,7 @@ func mockSignature(body []byte) string { } func TestWebhooks(t *testing.T) { - webhooks := NewWebhooks(webhooksSecret) + webhooks := NewWebhooks(mockWebhookSecret) webhooks.OnIntegrationCreate(func(res http.ResponseWriter, payload *IntegrationCreatePayload, trace string) { defaultResponse(res, "integration.create", trace) @@ -74,11 +74,11 @@ func TestWebhooks(t *testing.T) { req.Header.Set("Content-Type", "application/json") req.Header.Set("x-topgg-signature", mockSignature(body)) - req.Header.Set("x-topgg-trace", webhooksTrace) + req.Header.Set("x-topgg-trace", mockWebhookTrace) webhooks.Handler(rec, req) assert.Equalf(t, http.StatusOK, rec.Code, "Sending a %s payload must be responded with 200.", event) - assert.Equalf(t, fmt.Sprintf("%s:%s", event, webhooksTrace), rec.Body.String(), "Sending a %s payload must be responded with the expected response body.", event) + assert.Equalf(t, fmt.Sprintf("%s:%s", event, mockWebhookTrace), rec.Body.String(), "Sending a %s payload must be responded with the expected response body.", event) } } From 7a3fd86774da05eeab1988171e64b6a2b0a15831 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:49:55 +0700 Subject: [PATCH 13/13] feat: add replay attack mitigation --- webhooks.go | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/webhooks.go b/webhooks.go index ca0d794..97fe034 100644 --- a/webhooks.go +++ b/webhooks.go @@ -8,7 +8,9 @@ import ( "fmt" "io" "log/slog" + "math" "net/http" + "strconv" "strings" "time" ) @@ -17,17 +19,19 @@ type rawListener = func(http.ResponseWriter, json.RawMessage, string) // Webhooks represents a Top.gg webhook manager. type Webhooks struct { - Secret string // The secret to use to authorize external requests. - Timeout time.Duration // The timeout for reading payloads. Defaults to five seconds. - listeners map[string]rawListener + Secret string // The secret to use to authorize external requests. + Timeout time.Duration // The timeout for reading payloads. Defaults to five seconds. + TimestampWindow time.Duration // The accepted time window for timestamps before they get rejected to help mitigate replay attacks. Defaults to 30 seconds. + listeners map[string]rawListener } // NewWebhooks is a function that creates a new webhook manager instance. func NewWebhooks(Secret string) *Webhooks { return &Webhooks{ - Secret: Secret, - Timeout: 5 * time.Second, - listeners: make(map[string]rawListener), + Secret: Secret, + Timeout: 5 * time.Second, + TimestampWindow: 30 * time.Second, + listeners: make(map[string]rawListener), } } @@ -76,6 +80,8 @@ type rawPayload struct { // Handler is the handler function to be passed to http.HandleFunc. func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { + currentTimestamp := time.Now().UTC().Unix() + if req.Method != http.MethodPost { res.WriteHeader(http.StatusMethodNotAllowed) @@ -91,7 +97,8 @@ func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { return } - var timestamp, signature string + timestamp := int64(0) + var signature string for pair := range strings.SplitSeq(signatureHeader, ",") { parts := strings.Split(pair, "=") @@ -104,15 +111,25 @@ func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { switch parts[0] { case "t": - timestamp = parts[1] + t, err := strconv.Atoi(parts[1]) + + if err != nil { + break + } + + timestamp = int64(t) case APIVersion: signature = parts[1] } } - if timestamp == "" || signature == "" { + if timestamp == 0 || signature == "" { res.WriteHeader(http.StatusUnprocessableEntity) + return + } else if math.Abs(currentTimestamp-timestamp) > int64(webhooks.TimestampWindow.Seconds()) { + res.WriteHeader(http.StatusForbidden) + return } @@ -135,7 +152,7 @@ func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { } mac := hmac.New(sha256.New, []byte(webhooks.Secret)) - mac.Write(fmt.Appendf(nil, "%s.%s", timestamp, body)) + mac.Write(fmt.Appendf(nil, "%d.%s", timestamp, body)) digest := hex.EncodeToString(mac.Sum(nil)) @@ -165,4 +182,3 @@ func (webhooks *Webhooks) Handler(res http.ResponseWriter, req *http.Request) { res.WriteHeader(http.StatusNoContent) } -