Merge branch 'main' into security/csrf-ssrf-ratelimit
All checks were successful
check / check (push) Successful in 1m59s
All checks were successful
check / check (push) Successful in 1m59s
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -97,6 +98,12 @@ type HTTPTargetConfig struct {
|
||||
Timeout int `json:"timeout,omitempty"` // seconds, 0 = default
|
||||
}
|
||||
|
||||
// SlackTargetConfig holds configuration for slack target types.
|
||||
// Compatible with any Slack-format incoming webhook (Slack, Mattermost, etc.).
|
||||
type SlackTargetConfig struct {
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
}
|
||||
|
||||
// EngineParams are the fx dependencies for the delivery engine.
|
||||
//
|
||||
//nolint:revive // EngineParams is a standard fx naming convention
|
||||
@@ -836,6 +843,8 @@ func (e *Engine) processDelivery(ctx context.Context, webhookDB *gorm.DB, d *dat
|
||||
e.deliverDatabase(webhookDB, d)
|
||||
case database.TargetTypeLog:
|
||||
e.deliverLog(webhookDB, d)
|
||||
case database.TargetTypeSlack:
|
||||
e.deliverSlack(webhookDB, d)
|
||||
default:
|
||||
e.log.Error("unknown target type",
|
||||
"target_id", d.TargetID,
|
||||
@@ -982,6 +991,142 @@ func (e *Engine) deliverLog(webhookDB *gorm.DB, d *database.Delivery) {
|
||||
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusDelivered)
|
||||
}
|
||||
|
||||
// deliverSlack formats the webhook event as a human-readable Slack message
|
||||
// and POSTs it to a Slack-compatible incoming webhook URL (works with Slack,
|
||||
// Mattermost, and other compatible services). The message includes metadata
|
||||
// (method, content type, timestamp, body size) and the payload pretty-printed
|
||||
// in a code block if it is valid JSON.
|
||||
func (e *Engine) deliverSlack(webhookDB *gorm.DB, d *database.Delivery) {
|
||||
cfg, err := e.parseSlackConfig(d.Target.Config)
|
||||
if err != nil {
|
||||
e.log.Error("invalid Slack target config",
|
||||
"target_id", d.TargetID,
|
||||
"error", err,
|
||||
)
|
||||
e.recordResult(webhookDB, d, 1, false, 0, "", err.Error(), 0)
|
||||
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed)
|
||||
return
|
||||
}
|
||||
|
||||
msg := FormatSlackMessage(&d.Event)
|
||||
|
||||
payload, err := json.Marshal(map[string]string{"text": msg})
|
||||
if err != nil {
|
||||
e.log.Error("failed to marshal Slack payload",
|
||||
"target_id", d.TargetID,
|
||||
"error", err,
|
||||
)
|
||||
e.recordResult(webhookDB, d, 1, false, 0, "", err.Error(), 0)
|
||||
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed)
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
req, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
e.recordResult(webhookDB, d, 1, false, 0, "", err.Error(), 0)
|
||||
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||
|
||||
resp, err := e.client.Do(req)
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
e.recordResult(webhookDB, d, 1, false, 0, "", fmt.Errorf("sending request: %w", err).Error(), durationMs)
|
||||
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBodyLog))
|
||||
if readErr != nil {
|
||||
e.log.Error("failed to read Slack response body", "error", readErr)
|
||||
}
|
||||
respBody := string(body)
|
||||
|
||||
success := resp.StatusCode >= 200 && resp.StatusCode < 300
|
||||
errMsg := ""
|
||||
if !success {
|
||||
errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
e.recordResult(webhookDB, d, 1, success, resp.StatusCode, respBody, errMsg, durationMs)
|
||||
|
||||
if success {
|
||||
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusDelivered)
|
||||
} else {
|
||||
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) parseSlackConfig(configJSON string) (*SlackTargetConfig, error) {
|
||||
if configJSON == "" {
|
||||
return nil, fmt.Errorf("empty target config")
|
||||
}
|
||||
var cfg SlackTargetConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config JSON: %w", err)
|
||||
}
|
||||
if cfg.WebhookURL == "" {
|
||||
return nil, fmt.Errorf("webhook_url is required")
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// FormatSlackMessage builds a Slack-compatible message string from a webhook
|
||||
// event. It includes metadata (method, content type, timestamp, body size)
|
||||
// and pretty-prints the payload in a code block if it is valid JSON.
|
||||
func FormatSlackMessage(event *database.Event) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("*Webhook Event Received*\n")
|
||||
b.WriteString(fmt.Sprintf("*Method:* `%s`\n", event.Method))
|
||||
b.WriteString(fmt.Sprintf("*Content-Type:* `%s`\n", event.ContentType))
|
||||
b.WriteString(fmt.Sprintf("*Timestamp:* `%s`\n", event.CreatedAt.UTC().Format(time.RFC3339)))
|
||||
b.WriteString(fmt.Sprintf("*Body Size:* %d bytes\n", len(event.Body)))
|
||||
|
||||
if event.Body == "" {
|
||||
b.WriteString("\n_(empty body)_\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Try to pretty-print as JSON
|
||||
var parsed json.RawMessage
|
||||
if json.Unmarshal([]byte(event.Body), &parsed) == nil {
|
||||
var pretty bytes.Buffer
|
||||
if json.Indent(&pretty, parsed, "", " ") == nil {
|
||||
b.WriteString("\n```\n")
|
||||
prettyStr := pretty.String()
|
||||
// Truncate very large payloads to keep Slack messages reasonable
|
||||
const maxPayloadDisplay = 3500
|
||||
if len(prettyStr) > maxPayloadDisplay {
|
||||
b.WriteString(prettyStr[:maxPayloadDisplay])
|
||||
b.WriteString("\n... (truncated)")
|
||||
} else {
|
||||
b.WriteString(prettyStr)
|
||||
}
|
||||
b.WriteString("\n```\n")
|
||||
return b.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Not JSON — show raw body in a plain code block
|
||||
b.WriteString("\n```\n")
|
||||
bodyStr := event.Body
|
||||
const maxRawDisplay = 3500
|
||||
if len(bodyStr) > maxRawDisplay {
|
||||
b.WriteString(bodyStr[:maxRawDisplay])
|
||||
b.WriteString("\n... (truncated)")
|
||||
} else {
|
||||
b.WriteString(bodyStr)
|
||||
}
|
||||
b.WriteString("\n```\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// doHTTPRequest performs the outbound HTTP POST to a target URL.
|
||||
func (e *Engine) doHTTPRequest(cfg *HTTPTargetConfig, event *database.Event) (statusCode int, respBody string, durationMs int64, err error) {
|
||||
start := time.Now()
|
||||
|
||||
Reference in New Issue
Block a user