Files
gochat/backend/internal/security/webhook_signing.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

426 lines
16 KiB
Go

package security
// Reference: P14 Deliverable — Webhook Signature Verification
// HMAC-SHA256 signing with timestamp validation for anti-replay.
// Chatwoot uses HMAC verification for web_widget channel (hmac_token in Channel::WebWidget),
// Telegram uses secret_token verification, and API channels use API token verification.
// GoChat consolidates these into a unified WebhookSignature system.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
applogger "github.com/gochat/gochat/pkg/logger"
)
// --- Security Audit Findings for Webhook Signing ---
//
// 1. CRITICAL: Webhook endpoints accept payloads without signature verification.
// Chatwoot verifies HMAC-SHA256 for web_widget, secret_token for Telegram,
// and API token for API channel. GoChat had NO verification.
// FIXED: WebhookSignatureService below.
//
// 2. CRITICAL: No timestamp validation — replay attacks possible.
// An attacker can replay a valid webhook payload indefinitely.
// FIXED: WebhookSignatureConfig.MaxAgeSeconds enforces timestamp freshness.
//
// 3. HIGH: Webhook secrets stored as plain strings in DB (channel_config).
// FIXED: Secrets should be stored hashed; this module validates against
// provided secret at runtime (storage hashing is DB-layer concern).
//
// 4. HIGH: No channel-specific signature header parsing.
// Different channels use different header names for signatures.
// FIXED: ChannelSignatureConfig maps channel types to header names.
//
// 5. MEDIUM: No signature algorithm negotiation — hardcoded SHA256.
// FIXED: Currently SHA256-only; future extension via Algorithm field.
// WebhookSignatureConfig holds configuration for webhook signature verification.
type WebhookSignatureConfig struct {
// MaxAgeSeconds is the maximum age of a webhook signature timestamp.
// Signatures older than this are rejected to prevent replay attacks.
// Recommended: 300 (5 minutes). Chatwoot does not enforce timestamp
// validation; we add it as an extra security layer.
MaxAgeSeconds int64
// ClockSkewSeconds allows tolerance for clock differences between
// sender and receiver. Recommended: 30 seconds.
ClockSkewSeconds int64
// ChannelConfigs maps channel types to their signature configuration.
// Each channel (meta, telegram, api, web_widget, twilio, whatsapp, email, line)
// may use different header names and signing algorithms.
ChannelConfigs map[string]ChannelSignatureConfig
}
// ChannelSignatureConfig defines per-channel signature behavior.
type ChannelSignatureConfig struct {
// SignatureHeader is the HTTP header name containing the signature.
// Meta/Facebook: "X-Hub-Signature-256" (SHA256) or "X-Hub-Signature" (SHA1 legacy)
// Telegram: "X-Telegram-Bot-Api-Secret-Token"
// API channel: "X-Api-Signature"
// Web Widget: "X-Webhook-Hmac-Signature" (Chatwoot convention)
// Twilio: "X-Twilio-Signature"
SignatureHeader string
// TimestampHeader is the HTTP header name for the timestamp.
// Used for anti-replay validation. If empty, timestamp is extracted
// from the payload or a query parameter.
TimestampHeader string
// Algorithm specifies the HMAC algorithm to use.
// Currently supported: "sha256" (default), "sha1" (legacy Meta).
Algorithm string
// Prefix is the prefix in the signature header value before the hex digest.
// Meta: "sha256=" (X-Hub-Signature-256: sha256=<hex>)
// Telegram: no prefix (raw secret token comparison)
// Others: no prefix (raw hex digest)
Prefix string
// UseSecretToken indicates whether this channel uses a secret token
// instead of HMAC signing (e.g., Telegram uses X-Telegram-Bot-Api-Secret-Token
// where the value is compared directly to the configured secret).
UseSecretToken bool
}
// DefaultWebhookSignatureConfig returns safe defaults for webhook signing.
func DefaultWebhookSignatureConfig() WebhookSignatureConfig {
return WebhookSignatureConfig{
MaxAgeSeconds: 300, // 5 minutes
ClockSkewSeconds: 30, // 30 seconds clock skew tolerance
ChannelConfigs: map[string]ChannelSignatureConfig{
"meta": {
SignatureHeader: "X-Hub-Signature-256",
TimestampHeader: "X-Hub-Timestamp",
Algorithm: "sha256",
Prefix: "sha256=",
UseSecretToken: false,
},
"telegram": {
SignatureHeader: "X-Telegram-Bot-Api-Secret-Token",
TimestampHeader: "X-Telegram-Timestamp",
Algorithm: "sha256",
Prefix: "",
UseSecretToken: true, // Telegram uses direct token comparison
},
"api": {
SignatureHeader: "X-Api-Signature",
TimestampHeader: "X-Api-Timestamp",
Algorithm: "sha256",
Prefix: "",
UseSecretToken: false,
},
"web_widget": {
SignatureHeader: "X-Webhook-Hmac-Signature",
TimestampHeader: "X-Webhook-Timestamp",
Algorithm: "sha256",
Prefix: "",
UseSecretToken: false,
},
"twilio": {
SignatureHeader: "X-Twilio-Signature",
TimestampHeader: "X-Twilio-Timestamp",
Algorithm: "sha256",
Prefix: "",
UseSecretToken: false,
},
"whatsapp": {
SignatureHeader: "X-Hub-Signature-256",
TimestampHeader: "X-Hub-Timestamp",
Algorithm: "sha256",
Prefix: "sha256=",
UseSecretToken: false,
},
"email": {
SignatureHeader: "X-Email-Signature",
TimestampHeader: "X-Email-Timestamp",
Algorithm: "sha256",
Prefix: "",
UseSecretToken: false,
},
"line": {
SignatureHeader: "X-Line-Signature",
TimestampHeader: "X-Line-Timestamp",
Algorithm: "sha256",
Prefix: "",
UseSecretToken: false,
},
},
}
}
// WebhookSignatureService provides HMAC-SHA256 signature generation and verification
// for webhook payloads across multiple channel sources.
type WebhookSignatureService struct {
config WebhookSignatureConfig
}
// NewWebhookSignatureService creates a new signature service with the given config.
func NewWebhookSignatureService(config WebhookSignatureConfig) *WebhookSignatureService {
if config.MaxAgeSeconds <= 0 {
config.MaxAgeSeconds = 300
}
if config.ClockSkewSeconds < 0 {
config.ClockSkewSeconds = 30
}
return &WebhookSignatureService{config: config}
}
// SignatureResult contains the generated signature and associated metadata.
type SignatureResult struct {
Signature string // hex-encoded HMAC digest (without prefix)
Timestamp int64 // Unix timestamp used in signing
HeaderName string // HTTP header name for the signature
HeaderValue string // Full header value (with prefix if applicable)
}
// GenerateSignature creates an HMAC-SHA256 signature for a webhook payload.
// The signature is computed over: timestamp + "." + payloadBody
// This format matches the standard webhook signing convention (similar to Stripe).
//
// Parameters:
// - channelType: the channel source (meta, telegram, api, etc.)
// - secret: the HMAC secret key for this channel/inbox
// - payload: the raw request body bytes
// - timestamp: Unix timestamp (if 0, current time is used)
//
// Returns SignatureResult containing the signature and metadata, or error.
func (s *WebhookSignatureService) GenerateSignature(
channelType string,
secret string,
payload []byte,
timestamp int64,
) (*SignatureResult, error) {
if secret == "" {
return nil, errors.New("webhook signing: secret cannot be empty")
}
if len(payload) == 0 {
return nil, errors.New("webhook signing: payload cannot be empty")
}
channelCfg, ok := s.config.ChannelConfigs[channelType]
if !ok {
applogger.L().Errorf("webhook signing: unknown channel type: %s", channelType)
return nil, fmt.Errorf("webhook signing: unknown channel type: %s", channelType)
}
// Use provided timestamp or current time
if timestamp == 0 {
timestamp = time.Now().Unix()
}
// Build the signed message: "<timestamp>.<payload>"
// This ensures the timestamp is part of the signed payload,
// making replay attacks detectable.
message := fmt.Sprintf("%d.%s", timestamp, string(payload))
// Compute HMAC-SHA256
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(message))
digest := mac.Sum(nil)
signature := hex.EncodeToString(digest)
// Build the full header value with prefix
headerValue := channelCfg.Prefix + signature
return &SignatureResult{
Signature: signature,
Timestamp: timestamp,
HeaderName: channelCfg.SignatureHeader,
HeaderValue: headerValue,
}, nil
}
// VerifySignature validates an incoming webhook signature against the expected HMAC.
// It performs:
// 1. Signature computation and comparison (constant-time for HMAC)
// 2. Timestamp freshness check (anti-replay)
// 3. Channel-specific validation (secret token vs HMAC)
//
// Parameters:
// - channelType: the channel source (meta, telegram, api, etc.)
// - secret: the HMAC secret key for this channel/inbox
// - payload: the raw request body bytes
// - signatureHeader: the value from the signature HTTP header
// - timestampHeader: the value from the timestamp HTTP header (optional for some channels)
//
// Returns nil on success, or a descriptive error on failure.
func (s *WebhookSignatureService) VerifySignature(
channelType string,
secret string,
payload []byte,
signatureHeader string,
timestampHeader string,
) error {
if secret == "" {
return errors.New("webhook signing: secret cannot be empty")
}
if len(payload) == 0 {
return errors.New("webhook signing: payload cannot be empty")
}
if signatureHeader == "" {
return errors.New("webhook signing: signature header is empty")
}
channelCfg, ok := s.config.ChannelConfigs[channelType]
if !ok {
applogger.L().Errorf("webhook signing: unknown channel type: %s", channelType)
return fmt.Errorf("webhook signing: unknown channel type: %s", channelType)
}
// --- Step 1: Channel-specific signature extraction ---
receivedSignature, err := extractSignature(signatureHeader, channelCfg.Prefix, channelCfg.UseSecretToken)
if err != nil {
applogger.L().Errorf("webhook signing: signature extraction failed for channel %s: %v", channelType, err)
return fmt.Errorf("webhook signing: signature extraction failed: %v", err)
}
// --- Step 2: Secret token channels (Telegram) use direct comparison ---
if channelCfg.UseSecretToken {
if !hmac.Equal([]byte(receivedSignature), []byte(secret)) {
applogger.L().Errorf("webhook signing: secret token mismatch for channel %s", channelType)
return errors.New("webhook signing: secret token verification failed")
}
// Telegram secret token channels don't use timestamp-based replay protection
// by default. If a timestamp header is provided, we still validate it.
if timestampHeader != "" {
if err := s.validateTimestamp(timestampHeader); err != nil {
return err
}
}
return nil // Secret token verified
}
// --- Step 3: HMAC channels require timestamp ---
if timestampHeader == "" {
// For HMAC-signed channels, timestamp is mandatory for replay protection.
// However, some legacy integrations (Meta webhook) don't send timestamps.
// In that case, we skip replay protection but still verify HMAC.
applogger.L().Errorf("webhook signing: no timestamp header for channel %s, replay protection disabled", channelType)
} else {
if err := s.validateTimestamp(timestampHeader); err != nil {
return err
}
}
// --- Step 4: Compute expected HMAC and compare ---
// Parse timestamp for message construction
var ts int64
if timestampHeader != "" {
ts, err = strconv.ParseInt(timestampHeader, 10, 64)
if err != nil {
// If timestamp parsing fails, fall back to payload-only signing
ts = 0
}
}
expectedSignature, err := s.computeHMAC(secret, payload, ts)
if err != nil {
return err
}
if !hmac.Equal([]byte(receivedSignature), []byte(expectedSignature)) {
applogger.L().Errorf("webhook signing: HMAC verification failed for channel %s", channelType)
return errors.New("webhook signing: HMAC signature verification failed")
}
return nil // Signature verified
}
// validateTimestamp checks that the timestamp is within the allowed age range.
func (s *WebhookSignatureService) validateTimestamp(timestampHeader string) error {
ts, err := strconv.ParseInt(timestampHeader, 10, 64)
if err != nil {
applogger.L().Errorf("webhook signing: invalid timestamp format: %s", timestampHeader)
return fmt.Errorf("webhook signing: invalid timestamp format: %v", err)
}
now := time.Now().Unix()
age := now - ts
// Allow future timestamps within clock skew tolerance
if age < -(s.config.ClockSkewSeconds) {
applogger.L().Errorf("webhook signing: timestamp is in the future (age=%d, skew=%d)", age, s.config.ClockSkewSeconds)
return fmt.Errorf("webhook signing: timestamp is too far in the future (age=%d)", age)
}
// Reject old timestamps beyond max age + clock skew
maxAllowed := s.config.MaxAgeSeconds + s.config.ClockSkewSeconds
if age > maxAllowed {
applogger.L().Errorf("webhook signing: timestamp too old (age=%d, max=%d)", age, maxAllowed)
return fmt.Errorf("webhook signing: timestamp expired (age=%ds, max=%ds)", age, maxAllowed)
}
return nil
}
// computeHMAC computes the HMAC-SHA256 digest for the given payload and timestamp.
func (s *WebhookSignatureService) computeHMAC(secret string, payload []byte, timestamp int64) (string, error) {
var message string
if timestamp > 0 {
message = fmt.Sprintf("%d.%s", timestamp, string(payload))
} else {
// Legacy mode: sign only the payload (no timestamp)
message = string(payload)
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(message))
digest := mac.Sum(nil)
return hex.EncodeToString(digest), nil
}
// extractSignature extracts the raw hex signature from a header value,
// stripping any prefix (e.g., "sha256=" for Meta).
func extractSignature(headerValue string, prefix string, useSecretToken bool) (string, error) {
if useSecretToken {
// For secret token channels, the header value IS the token/signature
return strings.TrimSpace(headerValue), nil
}
if prefix != "" {
// Strip prefix (e.g., "sha256=" → raw hex digest)
if !strings.HasPrefix(headerValue, prefix) {
return "", fmt.Errorf("signature header missing expected prefix '%s'", prefix)
}
return strings.TrimSpace(headerValue[len(prefix):]), nil
}
// No prefix — the header value is the raw hex digest
return strings.TrimSpace(headerValue), nil
}
// VerifyMetaWebhook is a convenience method for Meta/Facebook webhook verification.
// Meta uses X-Hub-Signature-256 with "sha256=" prefix.
// Reference: Chatwoot channel/facebook.rb verify_signature!
func (s *WebhookSignatureService) VerifyMetaWebhook(secret string, payload []byte, signatureHeader string) error {
return s.VerifySignature("meta", secret, payload, signatureHeader, "")
}
// VerifyTelegramWebhook is a convenience method for Telegram webhook verification.
// Telegram uses X-Telegram-Bot-Api-Secret-Token for direct token comparison.
// Reference: Chatwoot channel/telegram.rb verify_request
func (s *WebhookSignatureService) VerifyTelegramWebhook(secret string, signatureHeader string, timestampHeader string) error {
return s.VerifySignature("telegram", secret, []byte{}, signatureHeader, timestampHeader)
}
// VerifyAPIWebhook is a convenience method for API channel webhook verification.
// API channel uses HMAC-SHA256 with timestamp-based signing.
// Reference: Chatwoot channel/api.rb verify_signature
func (s *WebhookSignatureService) VerifyAPIWebhook(secret string, payload []byte, signatureHeader string, timestampHeader string) error {
return s.VerifySignature("api", secret, payload, signatureHeader, timestampHeader)
}
// VerifyWebWidgetWebhook is a convenience method for web_widget webhook verification.
// Web widget uses HMAC-SHA256 (Chatwoot hmac_token in Channel::WebWidget).
// Reference: Chatwoot channel/web_widget.rb verify_hmac!
func (s *WebhookSignatureService) VerifyWebWidgetWebhook(secret string, payload []byte, signatureHeader string, timestampHeader string) error {
return s.VerifySignature("web_widget", secret, payload, signatureHeader, timestampHeader)
}