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
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:
1 parent
2c09253823
commit
8327a25278
15 files changed
+491
-39
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user