Files
nexarch/dms/internal/trigger/webhook.go
patrickandClaude Sonnet 5 2dc9b44e97
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
INT-20: trigger-/automatisierungssystem nach upload
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 12:53:59 +02:00

113 lines
3.3 KiB
Go

package trigger
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const webhookTimeout = 10 * time.Second
var httpClient = &http.Client{
Timeout: webhookTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
// ValidateWebhookURL verlangt http(s) und lehnt Loopback-, private und
// Link-Local-Ziele ab, sofern nicht ueber
// NEXARCH_DMS_APP_TRIGGER_WEBHOOK_ALLOW_PRIVATE=true ausdruecklich erlaubt
// (Entwicklungsbetrieb). Das ist eine Bestwissen-Pruefung anhand der im
// Hostnamen angegebenen IP; ein DNS-Rebinding zur Anfragezeit wird dadurch
// nicht verhindert (siehe Protokoll, Grenze wie bei F-08/F-22).
func ValidateWebhookURL(raw string) error {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return fmt.Errorf("ungueltige url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("nur http/https erlaubt")
}
if u.Host == "" {
return fmt.Errorf("host fehlt")
}
if allowPrivateTargets() {
return nil
}
host := u.Hostname()
if ip := net.ParseIP(host); ip != nil {
if isDisallowedIP(ip) {
return fmt.Errorf("ziel-ip %s ist ein lokales/privates netz, nicht erlaubt", ip)
}
return nil
}
// Hostname statt IP: bestmoegliche Pruefung zur Anlagezeit; siehe Doc-Kommentar oben.
ips, err := net.LookupIP(host)
if err != nil {
return nil // Auflösung schlaegt evtl. erst zur Sendezeit fehl; nicht blockieren.
}
for _, ip := range ips {
if isDisallowedIP(ip) {
return fmt.Errorf("host %s loest auf ein lokales/privates netz auf, nicht erlaubt", host)
}
}
return nil
}
func allowPrivateTargets() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv("NEXARCH_DMS_APP_TRIGGER_WEBHOOK_ALLOW_PRIVATE")), "true")
}
func isDisallowedIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()
}
// webhookPayload ist der JSON-Koerper der Anfrage.
type webhookPayload struct {
Event string `json:"event"`
DocumentID string `json:"document_id"`
TriggerID string `json:"trigger_id"`
OccurredAt string `json:"occurred_at"`
}
// sendWebhook signiert den Koerper mit dem entschluesselten Geheimnis (falls
// gesetzt) als Header X-Trigger-Signature (HMAC-SHA256, Hex) und sendet ihn.
// Ein Nicht-2xx-Status gilt als Fehlschlag.
func sendWebhook(ctx context.Context, targetURL string, secret string, payload webhookPayload) error {
if err := ValidateWebhookURL(targetURL); err != nil {
return fmt.Errorf("webhook-ziel abgelehnt: %w", err)
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
req.Header.Set("X-Trigger-Signature", hex.EncodeToString(mac.Sum(nil)))
}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("webhook: anfrage fehlgeschlagen: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webhook: unerwarteter status %d", resp.StatusCode)
}
return nil
}