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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
113 lines
3.3 KiB
Go
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
|
|
}
|