fix(security): constant-time webhook_secret comparison (#337)

Severity LOW. The /webhooks/:type handler compared the Telegram
X-Telegram-Bot-Api-Secret-Token header against the decrypted
webhook_secret using Go's `!=` operator, which short-circuits on the
first mismatched byte. Under low-latency Docker-network conditions an
attacker could time response latency byte-by-byte and converge on the
real secret, then inject Telegram-formatted messages into any channel.

Fix: switch to crypto/subtle.ConstantTimeCompare, which runs in time
proportional to the length of the shorter input regardless of content
match. Same posture as the cdp-proxy token compare in host-bridge
(which already used timingSafeEqual).

Risk profile over the public internet is low (Telegram webhooks have
natural jitter that masks the signal), but the defensive pattern
matters for consistency across all secret comparisons.

Closes #337

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-15 21:23:12 -07:00
parent c11d8f3ec3
commit 50819500f0

View File

@ -2,6 +2,7 @@ package handlers
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"log"
@ -423,10 +424,17 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
continue
}
// Verify webhook secret_token if the channel has one configured
// Verify webhook secret_token if the channel has one configured.
// #337: use constant-time comparison. Go's `!=` short-circuits on
// the first mismatched byte and leaks timing information; an
// attacker on the Docker network could enumerate the secret
// byte-by-byte. subtle.ConstantTimeCompare runs in time
// proportional to the length of the shorter input and returns
// 1 on match / 0 otherwise (never -1). Same posture as the
// cdp-proxy token compare in host-bridge.
if expectedSecret, _ := row.Config["webhook_secret"].(string); expectedSecret != "" {
receivedSecret := c.GetHeader("X-Telegram-Bot-Api-Secret-Token")
if receivedSecret != expectedSecret {
if subtle.ConstantTimeCompare([]byte(receivedSecret), []byte(expectedSecret)) != 1 {
continue // Wrong secret — try other channels (could be different bot)
}
}