OPS-10: F-24 einheitliche Lauschadressen (127.0.0.1), F-22 Webhook-Ziel-Pruefung (SSRF) und verschluesseltes Signaturgeheimnis
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (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
Core-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-22 10:21:25 +02:00
1 parent 2c09253823
commit 8327a25278
15 files changed
+491 -39

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ func main() {
}
addr := os.Getenv("NEXARCH_RESTORETEST_METRICS_LISTEN_ADDR")
if addr == "" {
addr = ":8091"
addr = "127.0.0.1:8091"
}
mux := http.NewServeMux()
+1 -1
View File
@@ -25,7 +25,7 @@ func main() {
}
addr := os.Getenv("NEXARCH_SCRUB_METRICS_LISTEN_ADDR")
if addr == "" {
addr = ":8090"
addr = "127.0.0.1:8090"
}
ctx := context.Background()
+1 -1
View File
@@ -39,7 +39,7 @@ func main() {
}
addr := os.Getenv("NEXARCH_AUDITLOG_LISTEN_ADDR")
if addr == "" {
addr = ":8083"
addr = "127.0.0.1:8083"
}
ctx := context.Background()
+1 -1
View File
@@ -22,7 +22,7 @@ func main() {
}
addr := os.Getenv("NEXARCH_METRICS_LISTEN_ADDR")
if addr == "" {
addr = ":8085"
addr = "127.0.0.1:8085"
}
ctx := context.Background()
+7 -2
View File
@@ -8,6 +8,7 @@ package main
import (
"log"
"net/http"
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
"gitea.perlbach24.de/scripte/nexarch/internal/secheaders"
@@ -36,6 +37,10 @@ func main() {
})
mux.HandleFunc("/unprotected", servePage)
log.Println("secheaders-devserver listening on :8086")
log.Fatal(httpharden.ListenAndServe(":8086", mux))
addr := os.Getenv("NEXARCH_SECHEADERS_LISTEN_ADDR")
if addr == "" {
addr = "127.0.0.1:8086"
}
log.Printf("secheaders-devserver listening on %s", addr)
log.Fatal(httpharden.ListenAndServe(addr, mux))
}
+1 -1
View File
@@ -23,7 +23,7 @@ func main() {
}
addr := os.Getenv("NEXARCH_STATUSPAGE_LISTEN_ADDR")
if addr == "" {
addr = ":8084"
addr = "127.0.0.1:8084"
}
intervalSeconds := 10
if v := os.Getenv("NEXARCH_STATUSPAGE_POLL_INTERVAL_SECONDS"); v != "" {
+1 -1
View File
@@ -40,7 +40,7 @@ import (
func main() {
addr := os.Getenv("NEXARCH_DMS_APP_LISTEN_ADDR")
if addr == "" {
addr = ":8600"
addr = "127.0.0.1:8600"
}
logger := logging.NewDefault()
+1 -1
View File
@@ -44,7 +44,7 @@ func main() {
metricsAddr := os.Getenv("NEXARCH_DMS_WORKER_METRICS_ADDR")
if metricsAddr == "" {
metricsAddr = ":8601"
metricsAddr = "127.0.0.1:8601"
}
go func() {
if err := httpharden.ListenAndServe(metricsAddr, metricsReg.Handler()); err != nil {
+2 -2
View File
@@ -8,7 +8,7 @@ Aus dem Code abgeleitet (`cmd/app`, `cmd/worker`, `cmd/migrate`, `internal/route
| Variable | Standard | Wirkung |
|---|---|---|
| `NEXARCH_DMS_APP_LISTEN_ADDR` | `:8600` | Lauschadresse |
| `NEXARCH_DMS_APP_LISTEN_ADDR` | `127.0.0.1:8600` | Lauschadresse (Reverse-Proxy erforderlich fuer externen Zugriff, F-24) |
| `NEXARCH_DMS_APP_ROUTE_PREFIX` | `/api/dms/v1` | Präfix aller fachlichen Routen (FDN-14); `/healthz` und `/metrics` bleiben außerhalb |
| `NEXARCH_DMS_APP_TENANT_DSN` | Pflicht für Fachfunktionen | Tenant-Datenbank; ohne sie: keine API-Routen, kein Guard, keine Retention-Anbindung, keine Suche |
| `NEXARCH_DMS_APP_TENANT_SLUG` | Pflicht | Mandantenkennung |
@@ -57,7 +57,7 @@ Aus dem Code abgeleitet (`cmd/app`, `cmd/worker`, `cmd/migrate`, `internal/route
| Variable | Standard | Wirkung |
|---|---|---|
| `NEXARCH_DMS_WORKER_METRICS_ADDR` | `:8601` | Metrik-Endpunkt |
| `NEXARCH_DMS_WORKER_METRICS_ADDR` | `127.0.0.1:8601` | Metrik-Endpunkt (F-24) |
| `NEXARCH_DMS_WORKER_TENANT_DSN` | Pflicht | Datenbank; ohne sie keine Requeue-Verarbeitung |
| `NEXARCH_DMS_WORKER_TENANT_SLUG` | Pflicht für Fachjobs | Mandantenkennung |
| `NEXARCH_DMS_WORKER_STORAGE_DIR` / `_STORAGE_SIGNING_SECRET` / `_STORAGE_PUBLIC_BASE_URL` | Pflicht für Fachjobs | Storage wie in der App (Secret) |
+47 -12
View File
@@ -7,9 +7,12 @@ import (
"encoding/hex"
"fmt"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5"
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
)
// DefaultMaxAttempts ist die konfigurierbare Obergrenze, ab der eine
@@ -26,6 +29,7 @@ const DefaultBaseBackoff = 2 * time.Second
type Dispatcher struct {
pool pgxIface
client *http.Client
secretKey []byte
MaxAttempts int
BaseBackoff time.Duration
}
@@ -38,11 +42,25 @@ type pgxIface interface {
Begin(ctx context.Context) (pgx.Tx, error)
}
func NewDispatcher(pool pgxIface, client *http.Client) *Dispatcher {
if client == nil {
client = &http.Client{Timeout: 5 * time.Second}
// NewDispatcher erzeugt einen Dispatcher. secretKey entschluesselt die
// gespeicherten Abonnement-Geheimnisse fuer die HMAC-Signierung (QA-15
// F-22) und MUSS zum secretKey passen, mit dem die Abonnements ueber
// Store.Subscribe angelegt wurden. client wird — falls nil — mit
// NewTargetClient(TargetPolicy{}) erzeugt (SSRF-sicherer Standard, keine
// Weiterleitungen, DNS-Rebinding-sicher); ein von aussen uebergebener
// client (z. B. in Tests gegen httptest) umgeht diese Pruefung bewusst.
func NewDispatcher(pool pgxIface, secretKey []byte, client *http.Client) (*Dispatcher, error) {
if len(secretKey) != kek.MasterKeySize {
return nil, ErrSecretEncryptionKeyRequired
}
return &Dispatcher{pool: pool, client: client, MaxAttempts: DefaultMaxAttempts, BaseBackoff: DefaultBaseBackoff}
if client == nil {
policy, err := TargetPolicyFromEnv(os.Getenv)
if err != nil {
return nil, err
}
client = NewTargetClient(policy)
}
return &Dispatcher{pool: pool, client: client, secretKey: secretKey, MaxAttempts: DefaultMaxAttempts, BaseBackoff: DefaultBaseBackoff}, nil
}
// backoffFor berechnet die Wartezeit vor dem naechsten Versuch: exponentiell
@@ -63,7 +81,7 @@ func (d *Dispatcher) ProcessDue(ctx context.Context) (int, error) {
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, `
SELECT wd.id, wd.payload, wd.attempt, ws.target_url, ws.secret
SELECT wd.id, wd.payload, wd.attempt, ws.target_url, ws.secret_ciphertext
FROM webhook_deliveries wd
JOIN webhook_subscriptions ws ON ws.id = wd.subscription_id
WHERE wd.status = $1 AND wd.next_attempt_at <= now()
@@ -73,28 +91,45 @@ func (d *Dispatcher) ProcessDue(ctx context.Context) (int, error) {
return 0, fmt.Errorf("faellige zustellungen abfragen: %w", err)
}
var deliveries []delivery
type rawDelivery struct {
del delivery
ciphertext []byte
}
var raw []rawDelivery
for rows.Next() {
var del delivery
if err := rows.Scan(&del.ID, &del.Payload, &del.Attempt, &del.TargetURL, &del.Secret); err != nil {
var r rawDelivery
if err := rows.Scan(&r.del.ID, &r.del.Payload, &r.del.Attempt, &r.del.TargetURL, &r.ciphertext); err != nil {
rows.Close()
return 0, fmt.Errorf("zustellung lesen: %w", err)
}
deliveries = append(deliveries, del)
raw = append(raw, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
for _, del := range deliveries {
d.attemptOne(ctx, tx, del)
// Ein Entschluesselungsfehler bei GENAU EINEM Abonnement (z. B. nach
// einem Schluesselwechsel ohne Neuverpackung) darf die anderen
// faelligen Zustellungen in diesem Batch nicht verhindern — dieselbe
// Isolationsregel wie fuer Zustellfehler (siehe attemptOne).
for _, r := range raw {
secret, err := kek.DecryptWithTenantKEK(d.secretKey, r.ciphertext)
if err != nil {
_, _ = tx.Exec(ctx, `
UPDATE webhook_deliveries SET status = $2, last_error = $3
WHERE id = $1
`, r.del.ID, StatusFailed, fmt.Sprintf("geheimnis entschluesseln: %v", err))
continue
}
r.del.Secret = string(secret)
d.attemptOne(ctx, tx, r.del)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("transaktion committen: %w", err)
}
return len(deliveries), nil
return len(raw), nil
}
// attemptOne fuehrt GENAU EINEN Zustellversuch aus und aktualisiert den
+271
View File
@@ -0,0 +1,271 @@
package webhook
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// TargetPolicy prueft Webhook-Ziel-URLs gegen SSRF (QA-15 F-22, CWE-918) —
// dieselbe Vorgehensweise wie Archive-Rueckrufe (RET-14,
// archive/internal/moduleadapter/callbackpolicy.go): oeffentliche https-Ziele
// sind ohne Konfiguration erlaubt, Loopback/private Netze nur ueber die
// Allowlist, Link-Local/unspezifiziert/Multicast/Broadcast nie. Beide
// Module sind eigene Go-Module (kein gemeinsames internal/-Paket moeglich),
// daher eine eigene, aequivalente Implementierung statt Import.
type TargetPolicy struct {
AllowHTTP bool
Allow []string
Resolve func(ctx context.Context, host string) ([]net.IP, error)
}
const (
maxTargetURLLen = 2048
targetDialTimeout = 5 * time.Second
targetTimeout = 10 * time.Second
)
// TargetPolicyFromEnv liest NEXARCH_WEBHOOK_TARGET_ALLOW_HTTP ("true") und
// NEXARCH_WEBHOOK_TARGET_ALLOWED_TARGETS (kommagetrennt: IP, CIDR oder
// Rechnername).
func TargetPolicyFromEnv(getenv func(string) string) (TargetPolicy, error) {
p := TargetPolicy{AllowHTTP: getenv("NEXARCH_WEBHOOK_TARGET_ALLOW_HTTP") == "true"}
for _, e := range strings.Split(getenv("NEXARCH_WEBHOOK_TARGET_ALLOWED_TARGETS"), ",") {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if _, _, err := net.ParseCIDR(e); err != nil && net.ParseIP(e) == nil && !validTargetHostname(e) {
return TargetPolicy{}, fmt.Errorf("NEXARCH_WEBHOOK_TARGET_ALLOWED_TARGETS: %q ist weder IP, CIDR noch Rechnername", e)
}
p.Allow = append(p.Allow, e)
}
return p, nil
}
func validTargetHostname(h string) bool {
if h == "" || len(h) > 253 {
return false
}
for _, r := range h {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '.':
default:
return false
}
}
return !strings.HasPrefix(h, ".") && !strings.HasSuffix(h, ".") && !strings.Contains(h, "..")
}
func targetAlwaysDenied(ip net.IP) bool {
if v4 := ip.To4(); v4 != nil {
ip = v4
}
return ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.Equal(net.IPv4bcast)
}
var targetNonPublicNets = mustTargetCIDRs("100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15", "240.0.0.0/4", "0.0.0.0/8")
func mustTargetCIDRs(list ...string) []*net.IPNet {
var out []*net.IPNet
for _, c := range list {
_, n, err := net.ParseCIDR(c)
if err != nil {
panic(err)
}
out = append(out, n)
}
return out
}
func targetNonPublic(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() {
return true
}
for _, n := range targetNonPublicNets {
if n.Contains(ip) {
return true
}
}
return false
}
func (p TargetPolicy) allowed(ip net.IP, host string) bool {
for _, e := range p.Allow {
if _, n, err := net.ParseCIDR(e); err == nil {
if n.Contains(ip) {
return true
}
continue
}
if a := net.ParseIP(e); a != nil {
if a.Equal(ip) {
return true
}
continue
}
if strings.EqualFold(e, host) {
return true
}
}
return false
}
func (p TargetPolicy) checkIP(ip net.IP, host string) error {
if targetAlwaysDenied(ip) {
return fmt.Errorf("ziel-adresse %s ist nie zulaessig (link-local, unspezifiziert, multicast oder broadcast)", ip)
}
if targetNonPublic(ip) && !p.allowed(ip, host) {
return fmt.Errorf("ziel-adresse %s liegt in einem nicht oeffentlichen netz und ist nicht freigegeben", ip)
}
return nil
}
func (p TargetPolicy) resolve(ctx context.Context, host string) ([]net.IP, error) {
if p.Resolve != nil {
return p.Resolve(ctx, host)
}
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
ips := make([]net.IP, 0, len(addrs))
for _, a := range addrs {
ips = append(ips, a.IP)
}
return ips, nil
}
// ErrTargetURL kennzeichnet eine abgelehnte Webhook-Ziel-Adresse.
var ErrTargetURL = errors.New("target_url nicht zulaessig")
// ValidateTargetURL prueft eine zu registrierende Webhook-Ziel-Adresse: nur
// http/https (http nur per Policy), kein Benutzerteil, gueltiger Port, keine
// Steuerzeichen, das Ziel muss die Policy erfuellen.
func (p TargetPolicy) ValidateTargetURL(ctx context.Context, raw string) error {
reject := func(format string, a ...any) error {
return fmt.Errorf("%w: %s", ErrTargetURL, fmt.Sprintf(format, a...))
}
if raw == "" || len(raw) > maxTargetURLLen {
return reject("leer oder laenger als %d zeichen", maxTargetURLLen)
}
for _, r := range raw {
if r <= 0x20 || r == 0x7f {
return reject("enthaelt leerzeichen oder steuerzeichen")
}
}
u, err := url.Parse(raw)
if err != nil {
return reject("nicht lesbar")
}
switch strings.ToLower(u.Scheme) {
case "https":
case "http":
if !p.AllowHTTP {
return reject("schema http ist nicht freigegeben")
}
default:
return reject("schema %q nicht erlaubt", u.Scheme)
}
if u.User != nil {
return reject("benutzerangaben in der adresse sind nicht erlaubt")
}
host := u.Hostname()
if host == "" {
return reject("rechnername fehlt")
}
if port := u.Port(); port != "" {
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return reject("port ungueltig")
}
}
if ip := net.ParseIP(host); ip != nil {
if err := p.checkIP(ip, host); err != nil {
return reject("%v", err)
}
return nil
}
if !validTargetHostname(host) {
return reject("rechnername ungueltig")
}
lower := strings.ToLower(host)
named := false
for _, e := range p.Allow {
if strings.EqualFold(e, host) {
named = true
break
}
}
if !named && (lower == "localhost" || strings.HasSuffix(lower, ".localhost") || strings.HasSuffix(lower, ".internal") || strings.HasSuffix(lower, ".local")) {
return reject("rechnername %q ist nicht freigegeben", host)
}
rctx, cancel := context.WithTimeout(ctx, targetDialTimeout)
defer cancel()
ips, err := p.resolve(rctx, host)
if err != nil || len(ips) == 0 {
return reject("rechnername %q nicht aufloesbar", host)
}
for _, ip := range ips {
if err := p.checkIP(ip, host); err != nil {
return reject("%v", err)
}
}
return nil
}
// NewTargetClient liefert einen HTTP-Client fuer Webhook-Zustellungen: kein
// gemeinsamer internal/httpclient (anderes Go-Modul), aber dasselbe Muster —
// Zeitlimit, keine Weiterleitungen, Zieladresse wird erst beim
// Verbindungsaufbau aufgeloest, gegen die Policy geprueft und direkt
// angewaehlt (kein DNS-Rebinding zwischen Pruefung und Aufruf).
func NewTargetClient(p TargetPolicy) *http.Client {
dialer := &net.Dialer{Timeout: targetDialTimeout}
return &http.Client{
Timeout: targetTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
var ips []net.IP
if ip := net.ParseIP(host); ip != nil {
ips = []net.IP{ip}
} else if ips, err = p.resolve(ctx, host); err != nil {
return nil, err
}
var lastErr error
for _, ip := range ips {
if err := p.checkIP(ip, host); err != nil {
lastErr = err
continue
}
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = fmt.Errorf("keine adresse fuer %q", host)
}
return nil, lastErr
},
TLSHandshakeTimeout: targetDialTimeout,
ResponseHeaderTimeout: targetTimeout,
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
},
}
}
+38 -10
View File
@@ -15,10 +15,13 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
)
const (
@@ -40,26 +43,51 @@ type Subscription struct {
CreatedAt time.Time
}
// Store persistiert Abonnements und Zustellversuche.
// ErrSecretEncryptionKeyRequired wird geliefert, wenn Store ohne
// Verschluesselungsschluessel fuer Abonnement-Geheimnisse erzeugt wird
// (QA-15 F-22: kein Klartext-Fallback, fail closed statt eines stillen
// unverschluesselten Betriebs).
var ErrSecretEncryptionKeyRequired = errors.New("webhook: verschluesselungsschluessel fuer geheimnisse fehlt")
// Store persistiert Abonnements und Zustellversuche. secretKey verschluesselt
// jedes Abonnement-Geheimnis (AES-256-GCM ueber internal/kek, derselbe
// Envelope-Mechanismus wie API-16/FDN-09) — das Geheimnis dient der
// Signaturpruefung durch den Empfaenger und ist ebenso schuetzenswert wie ein
// Passwort (QA-15 F-22).
type Store struct {
pool *pgxpool.Pool
pool *pgxpool.Pool
secretKey []byte
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
// NewStore erzeugt einen Store. secretKey MUSS gesetzt sein (32 Byte,
// AES-256) — ohne ihn koennen weder neue Abonnements angelegt noch
// bestehende Geheimnisse fuer die Zustellung gelesen werden.
func NewStore(pool *pgxpool.Pool, secretKey []byte) (*Store, error) {
if len(secretKey) != kek.MasterKeySize {
return nil, ErrSecretEncryptionKeyRequired
}
return &Store{pool: pool, secretKey: secretKey}, nil
}
// Subscribe registriert einen Abonnenten fuer einen Ereignistyp. secret wird
// spaeter zur HMAC-Signierung jeder Zustellung an diesen Abonnenten
// verwendet (Akzeptanzkriterium 3).
func (s *Store) Subscribe(ctx context.Context, eventType, targetURL, secret string) (Subscription, error) {
// Subscribe registriert einen Abonnenten fuer einen Ereignistyp. target_url
// wird gegen SSRF geprueft (QA-15 F-22, siehe TargetPolicy), secret wird vor
// dem Speichern verschluesselt und spaeter zur HMAC-Signierung jeder
// Zustellung an diesen Abonnenten verwendet (Akzeptanzkriterium 3).
func (s *Store) Subscribe(ctx context.Context, policy TargetPolicy, eventType, targetURL, secret string) (Subscription, error) {
if err := policy.ValidateTargetURL(ctx, targetURL); err != nil {
return Subscription{}, err
}
ciphertext, err := kek.EncryptWithTenantKEK(s.secretKey, []byte(secret))
if err != nil {
return Subscription{}, fmt.Errorf("geheimnis verschluesseln: %w", err)
}
var sub Subscription
sub.EventType, sub.TargetURL, sub.Secret = eventType, targetURL, secret
row := s.pool.QueryRow(ctx, `
INSERT INTO webhook_subscriptions (event_type, target_url, secret)
INSERT INTO webhook_subscriptions (event_type, target_url, secret_ciphertext)
VALUES ($1, $2, $3)
RETURNING id, created_at
`, eventType, targetURL, secret)
`, eventType, targetURL, ciphertext)
if err := row.Scan(&sub.ID, &sub.CreatedAt); err != nil {
return Subscription{}, fmt.Errorf("abonnement anlegen: %w", err)
}
+107 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -16,6 +17,15 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
)
// testSecretKey ist ein fester 32-Byte-Testschluessel — kein Betriebsgeheimnis
// (nur synthetische Testdaten, siehe DSGVO-Vorgabe).
var testSecretKey = []byte("0123456789abcdef0123456789abcdef"[:32])
// loopbackPolicy erlaubt genau das, was httptest.Server liefert (http auf
// 127.0.0.1) — die Zielprüfung selbst wird in TestValidateTargetURL_*
// eigenstaendig geprueft (QA-15 F-22).
var loopbackPolicy = TargetPolicy{AllowHTTP: true, Allow: []string{"127.0.0.1"}}
func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
@@ -28,7 +38,7 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_type TEXT NOT NULL,
target_url TEXT NOT NULL, secret TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
target_url TEXT NOT NULL, secret_ciphertext BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), subscription_id UUID NOT NULL REFERENCES webhook_subscriptions(id),
@@ -40,8 +50,12 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
t.Fatalf("schema: %v", err)
}
store, err := NewStore(pool, testSecretKey)
if err != nil {
t.Fatalf("newstore: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), pool, cleanup
return store, pool, cleanup
}
func uniqueEventType(prefix string) string {
@@ -67,7 +81,7 @@ func TestEnqueueAndProcessDue_DeliversSuccessfully(t *testing.T) {
defer target.Close()
eventType := uniqueEventType("dms.file.created")
sub, err := store.Subscribe(ctx, eventType, target.URL, "geheimes-secret")
sub, err := store.Subscribe(ctx, loopbackPolicy, eventType, target.URL, "geheimes-secret")
if err != nil {
t.Fatalf("subscribe: %v", err)
}
@@ -80,7 +94,10 @@ func TestEnqueueAndProcessDue_DeliversSuccessfully(t *testing.T) {
t.Fatalf("erwartet 1 eingereihte zustellung, habe %d", n)
}
dispatcher := NewDispatcher(pool, target.Client())
dispatcher, err := NewDispatcher(pool, testSecretKey, target.Client())
if err != nil {
t.Fatalf("newdispatcher: %v", err)
}
processed, err := dispatcher.ProcessDue(ctx)
if err != nil {
t.Fatalf("processdue: %v", err)
@@ -131,7 +148,7 @@ func TestProcessDue_RetriesWithBackoffThenMarksFailed(t *testing.T) {
defer target.Close()
eventType := uniqueEventType("mail.send.failed")
sub, err := store.Subscribe(ctx, eventType, target.URL, "secret")
sub, err := store.Subscribe(ctx, loopbackPolicy, eventType, target.URL, "secret")
if err != nil {
t.Fatalf("subscribe: %v", err)
}
@@ -139,7 +156,10 @@ func TestProcessDue_RetriesWithBackoffThenMarksFailed(t *testing.T) {
t.Fatalf("enqueue: %v", err)
}
dispatcher := NewDispatcher(pool, target.Client())
dispatcher, err := NewDispatcher(pool, testSecretKey, target.Client())
if err != nil {
t.Fatalf("newdispatcher: %v", err)
}
dispatcher.MaxAttempts = 2
dispatcher.BaseBackoff = 250 * time.Millisecond
@@ -202,3 +222,84 @@ func TestVerifySignature_DetectsTamperedPayload(t *testing.T) {
t.Fatal("erwartet ungueltige signatur bei falschem secret")
}
}
// QA-15 F-22: das gespeicherte Geheimnis liegt nur verschluesselt in der
// Datenbank — eine Rohabfrage der Spalte darf niemals den Klartext liefern.
func TestSubscribe_SecretIsEncryptedAtRest(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
sub, err := store.Subscribe(ctx, loopbackPolicy, uniqueEventType("dms.encrypted"), "http://127.0.0.1:1/webhook", "ganz-geheimer-wert")
if err != nil {
t.Fatalf("subscribe: %v", err)
}
var raw []byte
if err := pool.QueryRow(ctx, `SELECT secret_ciphertext FROM webhook_subscriptions WHERE id = $1`, sub.ID).Scan(&raw); err != nil {
t.Fatalf("ciphertext lesen: %v", err)
}
if len(raw) == 0 {
t.Fatal("secret_ciphertext ist leer")
}
if bytesContain(raw, "ganz-geheimer-wert") {
t.Fatal("secret_ciphertext enthaelt den klartext des geheimnisses")
}
}
func bytesContain(haystack []byte, needle string) bool {
return len(needle) > 0 && string(haystack) != "" &&
(func() bool {
n := []byte(needle)
for i := 0; i+len(n) <= len(haystack); i++ {
match := true
for j := range n {
if haystack[i+j] != n[j] {
match = false
break
}
}
if match {
return true
}
}
return false
})()
}
// QA-15 F-22 (CWE-918): Subscribe lehnt Ziel-Adressen ab, die kein
// oeffentliches Ziel sind und nicht per Allowlist freigegeben wurden.
func TestSubscribe_RejectsSSRFTargets(t *testing.T) {
store, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
cases := []struct {
name string
url string
}{
{"loopback ohne allowlist", "http://127.0.0.1:9999/hook"},
{"link-local metadaten", "http://169.254.169.254/latest/meta-data"},
{"privates netz", "http://10.0.0.5/hook"},
{"unspezifiziert", "http://0.0.0.0/hook"},
{"falsches schema", "ftp://example.test/hook"},
{"http ohne freigabe", "http://example.test/hook"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := store.Subscribe(ctx, TargetPolicy{}, uniqueEventType("dms.ssrf"), c.url, "secret"); err == nil {
t.Fatalf("erwartet ablehnung fuer %q, subscribe ist ohne fehler durchgelaufen", c.url)
}
})
}
}
// Public-https-Ziel ist ohne jede Konfiguration erlaubt (Regelfall).
func TestValidateTargetURL_AllowsPublicHTTPS(t *testing.T) {
p := TargetPolicy{Resolve: func(ctx context.Context, host string) ([]net.IP, error) {
return []net.IP{net.ParseIP("203.0.113.10")}, nil
}}
if err := p.ValidateTargetURL(context.Background(), "https://example.test/hook"); err != nil {
t.Fatalf("erwartet erfolg fuer oeffentliches https-ziel, habe: %v", err)
}
}
@@ -0,0 +1,3 @@
ALTER TABLE webhook_subscriptions
DROP COLUMN secret_ciphertext,
ADD COLUMN secret TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,9 @@
-- QA-15 F-22: das Signatur-Geheimnis eines Webhook-Abonnements lag als
-- Klartext in der Registry-Datenbank (`secret`). internal/webhook ist noch
-- in keinem Dienst verdrahtet (API-07 baute nur die Engine) — es gibt keine
-- Bestandsdaten zu migrieren. `secret` wird ersetzt durch
-- `secret_ciphertext` (AES-256-GCM ueber internal/kek, dasselbe Verfahren
-- wie API-16/FDN-09).
ALTER TABLE webhook_subscriptions
DROP COLUMN secret,
ADD COLUMN secret_ciphertext BYTEA NOT NULL;