QA-15 F-08/F-23: Archive /register mit Dienstauthentifizierung und callback_url-Pruefung, Service-Token verpflichtend
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
Archive-Schnittstellen-Vertragstests / contract-tests (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
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (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 01:07:18 +02:00
1 parent 765b3537d6
commit f6ca199261
13 files changed
+749 -37

No files matched your search

+22 -3
View File
@@ -42,15 +42,34 @@ func main() {
verifier := tenantregistryclient.New(tenantRegistryURL, tenantRegistryToken)
serviceToken := os.Getenv("NEXARCH_MODULEADAPTER_SERVICE_TOKEN")
allowUnauthenticated := os.Getenv("NEXARCH_MODULEADAPTER_ALLOW_UNAUTHENTICATED") == "true"
switch {
case serviceToken != "":
case allowUnauthenticated:
log.Print("moduleadapter-api: WARNUNG: NEXARCH_MODULEADAPTER_ALLOW_UNAUTHENTICATED=true, /register und /legal-hold laufen ohne Dienstauthentifizierung")
default:
log.Print("moduleadapter-api: NEXARCH_MODULEADAPTER_SERVICE_TOKEN nicht gesetzt, /register und /legal-hold antworten 503")
}
callbackPolicy, err := moduleadapter.CallbackPolicyFromEnv(os.Getenv)
if err != nil {
log.Fatalf("callback-policy: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/register", moduleadapter.RegisterHandler(pool, verifier))
mux.HandleFunc("/register", moduleadapter.RegisterHandler(pool, verifier, moduleadapter.RegisterConfig{
ServiceToken: serviceToken,
AllowUnauthenticated: allowUnauthenticated,
Callback: callbackPolicy,
}))
instanceTenant := os.Getenv("NEXARCH_MODULEADAPTER_TENANT_SLUG")
if instanceTenant == "" {
log.Print("moduleadapter-api: NEXARCH_MODULEADAPTER_TENANT_SLUG nicht gesetzt, /legal-hold antwortet 503")
}
mux.HandleFunc("/legal-hold", legalholdapi.Handler(pool, verifier, legalholdapi.Config{
InstanceTenant: instanceTenant,
ServiceToken: os.Getenv("NEXARCH_MODULEADAPTER_SERVICE_TOKEN"),
InstanceTenant: instanceTenant,
ServiceToken: serviceToken,
AllowUnauthenticated: allowUnauthenticated,
}))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -55,7 +56,12 @@ func (alwaysValidVerifier) VerifyTenant(_ context.Context, _ string) (bool, erro
// die DMS/Mail beim Registrieren als Rückgabewert erwarten dürfen).
func TestContract_RET05_RegisterResponse(t *testing.T) {
pool := setupModuleAdapterTest(t)
server := httptest.NewServer(moduleadapter.RegisterHandler(pool, alwaysValidVerifier{}))
server := httptest.NewServer(moduleadapter.RegisterHandler(pool, alwaysValidVerifier{}, moduleadapter.RegisterConfig{
AllowUnauthenticated: true,
Callback: moduleadapter.CallbackPolicy{Resolve: func(context.Context, string) ([]net.IP, error) {
return []net.IP{net.ParseIP("93.184.216.34")}, nil
}},
}))
defer server.Close()
body, _ := json.Marshal(map[string]string{
+6 -1
View File
@@ -13,6 +13,7 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -58,7 +59,11 @@ type HTTPFetcher struct {
func (f HTTPFetcher) FetchMetrics(ctx context.Context, moduleName, tenantSlug, baseURL string) (ModuleMetrics, error) {
client := f.Client
if client == nil {
client = http.DefaultClient
policy, err := moduleadapter.CallbackPolicyFromEnv(os.Getenv)
if err != nil {
return ModuleMetrics{}, fmt.Errorf("kennzahlen: callback-policy: %w", err)
}
client = moduleadapter.NewCallbackClient(policy)
}
u, err := url.Parse(baseURL)
if err != nil {
+19 -9
View File
@@ -4,7 +4,6 @@ package legalholdapi
import (
"context"
"crypto/subtle"
"encoding/json"
"log"
"net/http"
@@ -12,6 +11,8 @@ import (
"unicode/utf8"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/svcauth"
)
const (
@@ -29,11 +30,13 @@ type TenantVerifier interface {
}
// Config bindet die Instanz an genau einen Mandanten: Ihre Datenbank
// gehört diesem Mandanten (Modell C). ServiceToken ist optional; leer
// bedeutet, dass nur der Tenant-Kontext geprüft wird.
// gehört diesem Mandanten (Modell C). ServiceToken ist Pflicht (QA-15 F-23):
// ohne Token antwortet die Abfrage 503, ausser AllowUnauthenticated ist
// ausdruecklich gesetzt (Uebergang; dann prueft nur der Tenant-Kontext).
type Config struct {
InstanceTenant string
ServiceToken string
InstanceTenant string
ServiceToken string
AllowUnauthenticated bool
}
type response struct {
@@ -57,10 +60,17 @@ func Handler(pool *pgxpool.Pool, verifier TenantVerifier, cfg Config) http.Handl
http.Error(w, "sperrabfrage nicht konfiguriert", http.StatusServiceUnavailable)
return
}
if cfg.ServiceToken != "" &&
subtle.ConstantTimeCompare([]byte(r.Header.Get(HeaderServiceToken)), []byte(cfg.ServiceToken)) != 1 {
log.Printf("legalholdapi: anfrage ohne gueltiges service-token abgelehnt")
http.Error(w, "nicht autorisiert", http.StatusUnauthorized)
switch {
case cfg.ServiceToken != "":
if !svcauth.Equal(r.Header.Get(HeaderServiceToken), cfg.ServiceToken) {
log.Printf("legalholdapi: anfrage ohne gueltiges service-token abgelehnt")
http.Error(w, "nicht autorisiert", http.StatusUnauthorized)
return
}
case cfg.AllowUnauthenticated:
default:
log.Printf("legalholdapi: kein service-token konfiguriert, anfrage abgelehnt")
http.Error(w, "sperrabfrage nicht konfiguriert", http.StatusServiceUnavailable)
return
}
@@ -127,7 +127,7 @@ func decode(t *testing.T, body string) response {
// Pruefung 1 / AK1: gesperrt, freigegeben, nie gesperrt, unbekannt.
func TestHandler_HoldStates(t *testing.T) {
pool := poolFor(t, "TEST_TENANT_DSN")
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"})
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true})
held := seedObject(t, pool, "document", "doc-held")
seedHold(t, pool, held, "Rechtsstreit Az. 12/26", false)
@@ -161,7 +161,7 @@ func TestHandler_HoldStates(t *testing.T) {
// Sperre wirkt nur auf den passenden object_type.
func TestHandler_ObjectTypeIsPartOfIdentity(t *testing.T) {
pool := poolFor(t, "TEST_TENANT_DSN")
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"})
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true})
id := seedObject(t, pool, "document", "gemeinsame-ref")
seedHold(t, pool, id, "grund", false)
seedObject(t, pool, "message", "gemeinsame-ref")
@@ -194,15 +194,16 @@ func TestHandler_FailClosed(t *testing.T) {
header http.Header
want int
}{
{"kein tenant-header", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "", "document", "doc-held", nil, 400},
{"ungueltiger tenant-header", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant a\x7f", "document", "doc-held", nil, 400},
{"fremder tenant", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-b", "document", "doc-held", nil, 403},
{"unbekannter tenant im register", Handler(pool, fakeVerifier{valid: map[string]bool{}}, Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "doc-held", nil, 403},
{"register nicht erreichbar", Handler(pool, fakeVerifier{err: errors.New("down")}, Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "doc-held", nil, 503},
{"datenbank nicht erreichbar", Handler(closed, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "doc-held", nil, 503},
{"kein tenant-header", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true}), "", "document", "doc-held", nil, 400},
{"ungueltiger tenant-header", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true}), "mandant a\x7f", "document", "doc-held", nil, 400},
{"fremder tenant", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true}), "mandant-b", "document", "doc-held", nil, 403},
{"unbekannter tenant im register", Handler(pool, fakeVerifier{valid: map[string]bool{}}, Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true}), "mandant-a", "document", "doc-held", nil, 403},
{"register nicht erreichbar", Handler(pool, fakeVerifier{err: errors.New("down")}, Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true}), "mandant-a", "document", "doc-held", nil, 503},
{"datenbank nicht erreichbar", Handler(closed, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true}), "mandant-a", "document", "doc-held", nil, 503},
{"instanz ohne tenant-konfiguration", Handler(pool, okVerifier(), Config{}), "mandant-a", "document", "doc-held", nil, 503},
{"fehlender parameter", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "", nil, 400},
{"zu langer parameter", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", strings.Repeat("x", maxParamLen+1), nil, 400},
{"kein service-token konfiguriert", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"}), "mandant-a", "document", "doc-held", nil, 503},
{"fehlender parameter", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true}), "mandant-a", "document", "", nil, 400},
{"zu langer parameter", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true}), "mandant-a", "document", strings.Repeat("x", maxParamLen+1), nil, 400},
{"service-token fehlt", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", ServiceToken: "geheim"}), "mandant-a", "document", "doc-held", nil, 401},
{"service-token falsch", Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", ServiceToken: "geheim"}), "mandant-a", "document", "doc-held", tok("falsch"), 401},
}
@@ -225,7 +226,7 @@ func TestHandler_FailClosed(t *testing.T) {
func TestHandler_OnlyGET(t *testing.T) {
pool := poolFor(t, "TEST_TENANT_DSN")
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"})
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true})
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(m, "/legal-hold?object_type=document&object_reference=x", nil)
req.Header.Set(HeaderTenant, "mandant-a")
@@ -240,7 +241,7 @@ func TestHandler_OnlyGET(t *testing.T) {
// AK3: die Abfrage aendert keine Sperre und keinen Objektstatus.
func TestHandler_IsReadOnly(t *testing.T) {
pool := poolFor(t, "TEST_TENANT_DSN")
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a"})
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true})
id := seedObject(t, pool, "document", "doc-held")
seedHold(t, pool, id, "grund", false)
@@ -270,8 +271,8 @@ func TestHandler_IsReadOnly(t *testing.T) {
func TestHandler_TenantIsolationWithTwoDatabases(t *testing.T) {
poolA := poolFor(t, "TEST_TENANT_DSN")
poolB := poolFor(t, "TEST_TENANT_DSN_B")
hA := Handler(poolA, okVerifier(), Config{InstanceTenant: "mandant-a"})
hB := Handler(poolB, okVerifier(), Config{InstanceTenant: "mandant-b"})
hA := Handler(poolA, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true})
hB := Handler(poolB, okVerifier(), Config{InstanceTenant: "mandant-b", AllowUnauthenticated: true})
idA := seedObject(t, poolA, "document", "doc-1")
seedHold(t, poolA, idA, "nur in A", false)
@@ -0,0 +1,279 @@
package moduleadapter
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
maxCallbackURLLen = 2048
callbackDialTimeout = 5 * time.Second
callbackTimeout = 10 * time.Second
)
// CallbackPolicy legt fest, welche Rueckruf-Ziele ein Modul registrieren darf
// und welche Ziele Archive spaeter anrufen darf (QA-15 F-08, CWE-918).
// Oeffentliche Ziele ueber https sind ohne Konfiguration erlaubt. Loopback und
// private Netze sind nur ueber die Allowlist erreichbar; Link-Local
// (169.254.0.0/16, fe80::/10, u. a. Cloud-Metadaten), unspezifizierte,
// Multicast- und Broadcast-Adressen sind nie erlaubt.
type CallbackPolicy struct {
AllowHTTP bool
// Allow: IP-Adressen, CIDR-Netze oder Rechnernamen, die auch dann
// angerufen werden duerfen, wenn sie in ein nicht oeffentliches Netz
// aufloesen (Link-Local bleibt gesperrt).
Allow []string
// Resolve ist fuer Tests austauschbar.
Resolve func(ctx context.Context, host string) ([]net.IP, error)
}
// CallbackPolicyFromEnv liest NEXARCH_MODULEADAPTER_CALLBACK_ALLOW_HTTP
// ("true") und NEXARCH_MODULEADAPTER_CALLBACK_ALLOWED_TARGETS
// (kommagetrennt: IP, CIDR oder Rechnername).
func CallbackPolicyFromEnv(getenv func(string) string) (CallbackPolicy, error) {
p := CallbackPolicy{AllowHTTP: getenv("NEXARCH_MODULEADAPTER_CALLBACK_ALLOW_HTTP") == "true"}
for _, e := range strings.Split(getenv("NEXARCH_MODULEADAPTER_CALLBACK_ALLOWED_TARGETS"), ",") {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if _, _, err := net.ParseCIDR(e); err != nil && net.ParseIP(e) == nil && !validHostname(e) {
return CallbackPolicy{}, fmt.Errorf("NEXARCH_MODULEADAPTER_CALLBACK_ALLOWED_TARGETS: %q ist weder IP, CIDR noch Rechnername", e)
}
p.Allow = append(p.Allow, e)
}
return p, nil
}
func validHostname(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, "..")
}
// alwaysDenied: nie anrufbar, auch nicht ueber die Allowlist.
func alwaysDenied(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 nonPublicNets = mustCIDRs("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 mustCIDRs(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 nonPublic(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() {
return true
}
for _, n := range nonPublicNets {
if n.Contains(ip) {
return true
}
}
return false
}
func (p CallbackPolicy) 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
}
// checkIP entscheidet fuer eine aufgeloeste Adresse; host ist der urspruengliche
// Rechnername bzw. die Adresse aus der URL.
func (p CallbackPolicy) checkIP(ip net.IP, host string) error {
if alwaysDenied(ip) {
return fmt.Errorf("ziel-adresse %s ist nie zulaessig (link-local, unspezifiziert, multicast oder broadcast)", ip)
}
if nonPublic(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 CallbackPolicy) 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
}
// ErrCallbackURL kennzeichnet eine abgelehnte Rueckruf-Adresse.
var ErrCallbackURL = errors.New("callback_url nicht zulaessig")
// ValidateCallbackURL prueft eine zu registrierende Rueckruf-Adresse: nur
// http/https (http nur per Policy), kein Benutzerteil, gueltiger Port, keine
// Steuerzeichen, und das Ziel muss die Policy erfuellen (Rechnernamen werden
// aufgeloest, jede Adresse muss zulaessig sein; nicht aufloesbar heisst
// abgelehnt).
func (p CallbackPolicy) ValidateCallbackURL(ctx context.Context, raw string) error {
reject := func(format string, a ...any) error {
return fmt.Errorf("%w: %s", ErrCallbackURL, fmt.Sprintf(format, a...))
}
if raw == "" || len(raw) > maxCallbackURLLen {
return reject("leer oder laenger als %d zeichen", maxCallbackURLLen)
}
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 !validHostname(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, callbackDialTimeout)
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
}
// NewCallbackClient liefert einen HTTP-Client fuer Rueckrufe an registrierte
// Module: Zeitlimit, keine Weiterleitungen, und die Zieladresse wird erst beim
// Verbindungsaufbau aufgeloest, gegen die Policy geprueft und dann direkt
// angewaehlt (kein DNS-Rebinding zwischen Pruefung und Aufruf).
func NewCallbackClient(p CallbackPolicy) *http.Client {
dialer := &net.Dialer{Timeout: callbackDialTimeout}
return &http.Client{
Timeout: callbackTimeout,
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: callbackDialTimeout,
ResponseHeaderTimeout: callbackTimeout,
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
},
}
}
@@ -0,0 +1,158 @@
package moduleadapter
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func fixedResolver(ips ...string) func(context.Context, string) ([]net.IP, error) {
return func(context.Context, string) ([]net.IP, error) {
out := make([]net.IP, 0, len(ips))
for _, s := range ips {
out = append(out, net.ParseIP(s))
}
return out, nil
}
}
func TestValidateCallbackURL(t *testing.T) {
ctx := context.Background()
public := CallbackPolicy{Resolve: fixedResolver("93.184.216.34")}
cases := []struct {
name string
policy CallbackPolicy
url string
ok bool
}{
{"https oeffentlich", public, "https://dms.example.test/original", true},
{"https mit port und pfad", public, "https://dms.example.test:8443/a/b?x=1", true},
{"oeffentliche ip", public, "https://93.184.216.34/x", true},
{"http ohne freigabe", public, "http://dms.example.test/x", false},
{"http mit freigabe", CallbackPolicy{AllowHTTP: true, Resolve: fixedResolver("93.184.216.34")}, "http://dms.example.test/x", true},
{"loopback ohne allowlist", public, "https://127.0.0.1/x", false},
{"loopback ipv6", public, "https://[::1]:8600/x", false},
{"loopback mit allowlist", CallbackPolicy{AllowHTTP: true, Allow: []string{"127.0.0.1"}}, "http://127.0.0.1:8600/x", true},
{"privat ohne allowlist", public, "https://10.1.2.3/x", false},
{"privat mit cidr", CallbackPolicy{Allow: []string{"10.0.0.0/8"}}, "https://10.1.2.3/x", true},
{"privat ausserhalb der cidr", CallbackPolicy{Allow: []string{"10.0.0.0/8"}}, "https://192.168.0.1/x", false},
{"ula ipv6", public, "https://[fd00::1]/x", false},
{"cgnat", public, "https://100.64.0.1/x", false},
{"cloud-metadaten", public, "https://169.254.169.254/latest", false},
{"cloud-metadaten trotz allowlist", CallbackPolicy{Allow: []string{"169.254.0.0/16", "169.254.169.254"}}, "https://169.254.169.254/latest", false},
{"ipv6 link-local", public, "https://[fe80::1]/x", false},
{"ipv4-mapped loopback", public, "https://[::ffff:127.0.0.1]/x", false},
{"unspezifiziert", public, "https://0.0.0.0/x", false},
{"multicast", public, "https://224.0.0.1/x", false},
{"broadcast", public, "https://255.255.255.255/x", false},
{"dns-rebinding: name loest privat auf", CallbackPolicy{Resolve: fixedResolver("93.184.216.34", "127.0.0.1")}, "https://boese.example.test/x", false},
{"name loest auf metadaten-adresse auf", CallbackPolicy{Resolve: fixedResolver("169.254.169.254")}, "https://boese.example.test/x", false},
{"name loest privat auf, name freigegeben", CallbackPolicy{Allow: []string{"dms.intern.example"}, Resolve: fixedResolver("10.0.0.7")}, "https://dms.intern.example/x", true},
{"name nicht aufloesbar", CallbackPolicy{Resolve: func(context.Context, string) ([]net.IP, error) { return nil, errors.New("nxdomain") }}, "https://nirgendwo.example.test/x", false},
{"name ohne adressen", CallbackPolicy{Resolve: fixedResolver()}, "https://leer.example.test/x", false},
{"localhost", public, "https://localhost/x", false},
{"subdomain von localhost", public, "https://a.localhost/x", false},
{"lokaler rechnername", public, "https://drucker.local/x", false},
{"schema file", public, "file:///etc/passwd", false},
{"schema ftp", public, "ftp://dms.example.test/x", false},
{"schema gross", public, "HTTPS://dms.example.test/x", true},
{"benutzerangabe", public, "https://u:p@dms.example.test/x", false},
{"nur benutzer", public, "https://u@dms.example.test/x", false},
{"kein host", public, "https:///x", false},
{"leer", public, "", false},
{"steuerzeichen", public, "https://dms.example.test/x\r\nHost: a", false},
{"zeilenumbruch", public, "https://dms.example.test/x\nX: y", false},
{"leerzeichen", public, "https://dms.example.test/a b", false},
{"port null", public, "https://dms.example.test:0/x", false},
{"port zu gross", public, "https://dms.example.test:70000/x", false},
{"zu lang", public, "https://dms.example.test/" + strings.Repeat("a", 2100), false},
{"unzulaessiger rechnername", public, "https://dms_example.test/x", false},
}
for _, c := range cases {
err := c.policy.ValidateCallbackURL(ctx, c.url)
if (err == nil) != c.ok {
t.Errorf("%s: %q -> err=%v, erwartet ok=%v", c.name, c.url, err, c.ok)
}
if err != nil && !errors.Is(err, ErrCallbackURL) {
t.Errorf("%s: fehler nicht als ErrCallbackURL erkennbar: %v", c.name, err)
}
}
}
func TestCallbackPolicyFromEnv(t *testing.T) {
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
p, err := CallbackPolicyFromEnv(env(map[string]string{
"NEXARCH_MODULEADAPTER_CALLBACK_ALLOW_HTTP": "true",
"NEXARCH_MODULEADAPTER_CALLBACK_ALLOWED_TARGETS": " 127.0.0.1 , 10.0.0.0/8,dms.intern.example ,",
}))
if err != nil || !p.AllowHTTP || len(p.Allow) != 3 {
t.Fatalf("policy = %+v err = %v", p, err)
}
if p, err = CallbackPolicyFromEnv(env(nil)); err != nil || p.AllowHTTP || len(p.Allow) != 0 {
t.Fatalf("standard = %+v err = %v", p, err)
}
for _, bad := range []string{"10.0.0.0/99", "a b", "x;y", "http://x"} {
_, err := CallbackPolicyFromEnv(env(map[string]string{"NEXARCH_MODULEADAPTER_CALLBACK_ALLOWED_TARGETS": bad}))
if err == nil {
t.Errorf("%q wurde akzeptiert", bad)
}
}
}
func TestCallbackClient_BlocksNonPublicTargetsAtDialTime(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }))
defer srv.Close()
blocked := NewCallbackClient(CallbackPolicy{})
if resp, err := blocked.Get(srv.URL); err == nil {
_ = resp.Body.Close()
t.Fatal("loopback-ziel ohne allowlist wurde angerufen")
}
allowed := NewCallbackClient(CallbackPolicy{Allow: []string{"127.0.0.1"}})
resp, err := allowed.Get(srv.URL)
if err != nil {
t.Fatalf("freigegebenes ziel nicht erreichbar: %v", err)
}
_ = resp.Body.Close()
// DNS-Rebinding: der Name loest zur Aufrufzeit auf Loopback auf, obwohl er bei der Registrierung oeffentlich war.
rebind := NewCallbackClient(CallbackPolicy{Resolve: fixedResolver("127.0.0.1")})
port := srv.Listener.Addr().(*net.TCPAddr).Port
if resp, err := rebind.Get("http://harmlos.example.test:" + strconv.Itoa(port) + "/"); err == nil {
_ = resp.Body.Close()
t.Fatal("rebinding auf loopback wurde nicht erkannt")
}
}
func TestCallbackClient_DoesNotFollowRedirects(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("weiterleitungsziel wurde aufgerufen") }))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer redirector.Close()
client := NewCallbackClient(CallbackPolicy{Allow: []string{"127.0.0.1"}})
resp, err := client.Get(redirector.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusFound {
t.Fatalf("status = %d, weiterleitung haette nicht gefolgt werden duerfen (302 erwartet)", resp.StatusCode)
}
}
func TestCallbackClient_LinkLocalNeverDialledEvenIfAllowed(t *testing.T) {
client := NewCallbackClient(CallbackPolicy{Allow: []string{"169.254.0.0/16"}})
if resp, err := client.Get("http://169.254.169.254/latest/meta-data"); err == nil {
_ = resp.Body.Close()
t.Fatal("link-local ziel wurde angerufen")
}
}
+46 -3
View File
@@ -6,8 +6,29 @@ import (
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/svcauth"
)
const (
// HeaderServiceToken ist der Header, mit dem sich ein Modul beim
// Registrieren als Dienst ausweist (wie bei /legal-hold, RET-13).
HeaderServiceToken = "X-Service-Token"
maxRegisterBodyBytes = 64 << 10
)
// RegisterConfig steuert die Absicherung von POST /register (QA-15 F-08).
// ServiceToken: ist es gesetzt, verlangt /register den Header X-Service-Token.
// Ohne Token antwortet /register 503 (fail closed), ausser
// AllowUnauthenticated ist ausdruecklich gesetzt (Uebergang, mit Warnlog).
// Callback legt fest, welche Rueckruf-Adressen registriert werden duerfen.
type RegisterConfig struct {
ServiceToken string
AllowUnauthenticated bool
Callback CallbackPolicy
}
type registerRequest struct {
TenantSlug string `json:"tenant_slug"`
ModuleName string `json:"module_name"`
@@ -22,15 +43,30 @@ type registerRequest struct {
// retention_class, callback_url}. RET-12: verifiziert tenant_slug gegen
// verifier (Core-Tenant-Registry), bevor die Registrierung uebernommen
// wird - unabhaengig davon, was DMS/Mail selbst behaupten.
func RegisterHandler(pool *pgxpool.Pool, verifier TenantVerifier) http.HandlerFunc {
func RegisterHandler(pool *pgxpool.Pool, verifier TenantVerifier, cfg RegisterConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
switch {
case cfg.ServiceToken != "":
if !svcauth.Equal(r.Header.Get(HeaderServiceToken), cfg.ServiceToken) {
log.Printf("moduleadapter: registrierung ohne gueltiges service-token abgelehnt")
http.Error(w, "nicht autorisiert", http.StatusUnauthorized)
return
}
case cfg.AllowUnauthenticated:
log.Printf("moduleadapter: WARNUNG: registrierung ohne dienstauthentifizierung angenommen (NEXARCH_MODULEADAPTER_ALLOW_UNAUTHENTICATED)")
default:
log.Printf("moduleadapter: registrierung abgelehnt, kein service-token konfiguriert")
http.Error(w, "registrierung nicht konfiguriert", http.StatusServiceUnavailable)
return
}
var req registerRequest
r.Body = http.MaxBytesReader(w, r.Body, maxRegisterBodyBytes)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
http.Error(w, "ungültiger request-body", http.StatusBadRequest)
return
}
if req.TenantSlug == "" || req.ModuleName == "" || req.ObjectType == "" || req.RetentionClass == "" || req.CallbackURL == "" {
@@ -38,6 +74,12 @@ func RegisterHandler(pool *pgxpool.Pool, verifier TenantVerifier) http.HandlerFu
return
}
if err := cfg.Callback.ValidateCallbackURL(r.Context(), req.CallbackURL); err != nil {
log.Printf("moduleadapter: registrierung abgelehnt modul=%s objekttyp=%s: %v", req.ModuleName, req.ObjectType, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
valid, err := verifier.VerifyTenant(r.Context(), req.TenantSlug)
if err != nil {
log.Printf("moduleadapter: tenant-verifikation fehlgeschlagen fuer modul=%s tenant=%s objekttyp=%s: %v", req.ModuleName, req.TenantSlug, req.ObjectType, err)
@@ -52,7 +94,8 @@ func RegisterHandler(pool *pgxpool.Pool, verifier TenantVerifier) http.HandlerFu
reg, err := Register(r.Context(), pool, req.TenantSlug, req.ModuleName, req.ObjectType, req.RetentionClass, req.CallbackURL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Printf("moduleadapter: registrierung speichern: %v", err)
http.Error(w, "interner fehler", http.StatusInternalServerError)
return
}
+123 -5
View File
@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -30,7 +32,7 @@ func (f fakeVerifier) VerifyTenant(_ context.Context, tenantSlug string) (bool,
func TestRegisterHandler_RealHTTPRoundTrip(t *testing.T) {
pool := requireTestPool(t)
verifier := fakeVerifier{validTenants: map[string]bool{"mandant-a": true}}
server := httptest.NewServer(RegisterHandler(pool, verifier))
server := httptest.NewServer(RegisterHandler(pool, verifier, testRegisterConfig()))
defer server.Close()
post := func(body registerRequest) (int, Registration) {
@@ -69,7 +71,7 @@ func TestRegisterHandler_RealHTTPRoundTrip(t *testing.T) {
func TestRegisterHandler_RejectsMissingFields(t *testing.T) {
pool := requireTestPool(t)
verifier := fakeVerifier{validTenants: map[string]bool{"mandant-a": true}}
server := httptest.NewServer(RegisterHandler(pool, verifier))
server := httptest.NewServer(RegisterHandler(pool, verifier, testRegisterConfig()))
defer server.Close()
data, _ := json.Marshal(registerRequest{TenantSlug: "mandant-a", ModuleName: "dms"})
@@ -90,7 +92,7 @@ func TestRegisterHandler_RejectsMissingFields(t *testing.T) {
func TestRegisterHandler_AcceptsValidTenantContext(t *testing.T) {
pool := requireTestPool(t)
verifier := fakeVerifier{validTenants: map[string]bool{"mandant-a": true}}
server := httptest.NewServer(RegisterHandler(pool, verifier))
server := httptest.NewServer(RegisterHandler(pool, verifier, testRegisterConfig()))
defer server.Close()
data, _ := json.Marshal(registerRequest{TenantSlug: "mandant-a", ModuleName: "dms", ObjectType: "document", RetentionClass: "klasse-standard", CallbackURL: "https://dms.example.test/callback"})
@@ -118,7 +120,7 @@ func TestRegisterHandler_AcceptsValidTenantContext(t *testing.T) {
func TestRegisterHandler_RejectsUnknownTenantContext(t *testing.T) {
pool := requireTestPool(t)
verifier := fakeVerifier{validTenants: map[string]bool{"mandant-a": true}}
server := httptest.NewServer(RegisterHandler(pool, verifier))
server := httptest.NewServer(RegisterHandler(pool, verifier, testRegisterConfig()))
defer server.Close()
data, _ := json.Marshal(registerRequest{TenantSlug: "fremder-mandant", ModuleName: "dms", ObjectType: "document", RetentionClass: "klasse-standard", CallbackURL: "https://dms.example.test/callback"})
@@ -146,7 +148,7 @@ func TestRegisterHandler_RejectsUnknownTenantContext(t *testing.T) {
func TestRegisterHandler_TenantVerificationErrorFailsClosed(t *testing.T) {
pool := requireTestPool(t)
verifier := fakeVerifier{err: http.ErrServerClosed}
server := httptest.NewServer(RegisterHandler(pool, verifier))
server := httptest.NewServer(RegisterHandler(pool, verifier, testRegisterConfig()))
defer server.Close()
data, _ := json.Marshal(registerRequest{TenantSlug: "mandant-a", ModuleName: "dms", ObjectType: "document", RetentionClass: "klasse-standard", CallbackURL: "https://dms.example.test/callback"})
@@ -167,3 +169,119 @@ func TestRegisterHandler_TenantVerificationErrorFailsClosed(t *testing.T) {
t.Fatalf("registrierung trotz verifikationsfehler uebernommen: %+v", all)
}
}
// testRegisterConfig: Tests ohne DNS und ohne Service-Token; Rechnernamen
// loesen auf eine oeffentliche Adresse auf.
func testRegisterConfig() RegisterConfig {
return RegisterConfig{
AllowUnauthenticated: true,
Callback: CallbackPolicy{Resolve: func(context.Context, string) ([]net.IP, error) {
return []net.IP{net.ParseIP("93.184.216.34")}, nil
}},
}
}
func postRegister(t *testing.T, url string, header http.Header, body any) (int, string) {
t.Helper()
data, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
for k, v := range header {
req.Header[k] = v
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
return resp.StatusCode, buf.String()
}
func validRegisterBody() registerRequest {
return registerRequest{TenantSlug: "mandant-a", ModuleName: "dms", ObjectType: "document", RetentionClass: "klasse-original", CallbackURL: "https://dms.example.test/original"}
}
// QA-15 F-08: ohne Token (und ohne ausdrueckliche Freigabe) registriert /register nichts.
func TestRegisterHandler_RequiresServiceToken(t *testing.T) {
pool := requireTestPool(t)
verifier := fakeVerifier{validTenants: map[string]bool{"mandant-a": true}}
cfg := testRegisterConfig()
closedCfg := cfg
closedCfg.AllowUnauthenticated = false
closed := httptest.NewServer(RegisterHandler(pool, verifier, closedCfg))
defer closed.Close()
if code, _ := postRegister(t, closed.URL, nil, validRegisterBody()); code != http.StatusServiceUnavailable {
t.Errorf("ohne token-konfiguration: status = %d, want 503", code)
}
tokCfg := cfg
tokCfg.ServiceToken = "geheim"
tokCfg.AllowUnauthenticated = false
secured := httptest.NewServer(RegisterHandler(pool, verifier, tokCfg))
defer secured.Close()
for name, hdr := range map[string]http.Header{
"kein token": nil,
"falsches token": {HeaderServiceToken: {"falsch"}},
"praefix": {HeaderServiceToken: {"geheim-und-mehr"}},
"leer": {HeaderServiceToken: {""}},
} {
if code, _ := postRegister(t, secured.URL, hdr, validRegisterBody()); code != http.StatusUnauthorized {
t.Errorf("%s: status = %d, want 401", name, code)
}
}
regs, err := ListRegistrations(context.Background(), pool)
if err != nil || len(regs) != 0 {
t.Fatalf("abgelehnte anfragen haben registrierungen angelegt: %v %v", regs, err)
}
if code, _ := postRegister(t, secured.URL, http.Header{HeaderServiceToken: {"geheim"}}, validRegisterBody()); code != http.StatusOK {
t.Errorf("richtiges token: status = %d, want 200", code)
}
}
// QA-15 F-08 (SSRF): unzulaessige Rueckruf-Adressen werden mit 400 abgewiesen und nicht gespeichert.
func TestRegisterHandler_RejectsUnsafeCallbackURLs(t *testing.T) {
pool := requireTestPool(t)
verifier := fakeVerifier{validTenants: map[string]bool{"mandant-a": true}}
server := httptest.NewServer(RegisterHandler(pool, verifier, testRegisterConfig()))
defer server.Close()
for _, u := range []string{
"http://dms.example.test/x",
"https://127.0.0.1/x",
"https://[::1]/x",
"https://169.254.169.254/latest/meta-data",
"https://10.0.0.5/x",
"https://192.168.1.10:8443/x",
"file:///etc/passwd",
"gopher://dms.example.test/",
"https://user:pw@dms.example.test/x",
"https://localhost/x",
"https:///nohost",
"https://dms.example.test:99999/x",
"https://dms.example.test/x y",
} {
body := validRegisterBody()
body.CallbackURL = u
if code, _ := postRegister(t, server.URL, nil, body); code != http.StatusBadRequest {
t.Errorf("callback_url %q: status = %d, want 400", u, code)
}
}
if regs, _ := ListRegistrations(context.Background(), pool); len(regs) != 0 {
t.Fatalf("abgelehnte callback_urls wurden gespeichert: %v", regs)
}
}
func TestRegisterHandler_BodyLimit(t *testing.T) {
pool := requireTestPool(t)
verifier := fakeVerifier{validTenants: map[string]bool{"mandant-a": true}}
server := httptest.NewServer(RegisterHandler(pool, verifier, testRegisterConfig()))
defer server.Close()
big := validRegisterBody()
big.RetentionClass = strings.Repeat("x", maxRegisterBodyBytes+1)
if code, _ := postRegister(t, server.URL, nil, big); code != http.StatusBadRequest {
t.Errorf("uebergrosser body: status = %d, want 400", code)
}
}
+18
View File
@@ -0,0 +1,18 @@
// Package svcauth vergleicht Service-Token in konstanter Zeit (QA-15 F-23).
package svcauth
import (
"crypto/sha256"
"crypto/subtle"
)
// Equal vergleicht die SHA-256-Werte beider Eingaben in konstanter Zeit; anders
// als ein direkter ConstantTimeCompare verraet der Vergleich nicht die Laenge
// des erwarteten Tokens. Ein leeres erwartetes Token stimmt nie.
func Equal(got, want string) bool {
if want == "" {
return false
}
g, w := sha256.Sum256([]byte(got)), sha256.Sum256([]byte(want))
return subtle.ConstantTimeCompare(g[:], w[:]) == 1
}
+23
View File
@@ -0,0 +1,23 @@
package svcauth
import "testing"
func TestEqual(t *testing.T) {
cases := []struct {
got, want string
ok bool
}{
{"geheim", "geheim", true},
{"geheim ", "geheim", false},
{"", "geheim", false},
{"geheim", "", false},
{"", "", false},
{"g", "geheim", false},
{"geheimgeheim", "geheim", false},
}
for _, c := range cases {
if got := Equal(c.got, c.want); got != c.ok {
t.Errorf("Equal(%q,%q) = %v, erwartet %v", c.got, c.want, got, c.ok)
}
}
}
@@ -23,7 +23,7 @@ func TestArchiveContract_RegisterAndLegalHold(t *testing.T) {
c, _ := archiveClient(t)
ctx := context.Background()
if _, err := c.Register(ctx, "dms", "dms_document", "klasse-vertrag", "http://dms.example.test/cb"); err != nil {
if _, err := c.Register(ctx, "dms", "dms_document", "klasse-vertrag", "https://93.184.216.34/cb"); err != nil {
t.Fatalf("register gegen echtes Archive: %v", err)
}
+32
View File
@@ -16,7 +16,10 @@ fi
tenant=acme-contract
# LH_TOKEN gesetzt: Archive wird mit NEXARCH_MODULEADAPTER_SERVICE_TOKEN gestartet, der DMS-Client sendet X-Service-Token.
# Ohne LH_TOKEN laeuft Archive im Uebergangsmodus NEXARCH_MODULEADAPTER_ALLOW_UNAUTHENTICATED=true (QA-15 F-08/F-23);
# ohne beides antwortet Archive fail closed mit 503 (eigener Lauf unten).
token="${LH_TOKEN:-}"
allow_unauth=""; [[ -z "$token" ]] && allow_unauth=true
workdir=$(mktemp -d)
pids=()
dbname="lh_contract_$$"
@@ -71,6 +74,7 @@ NEXARCH_MODULEADAPTER_API_LISTEN_ADDR="127.0.0.1:$aport" \
NEXARCH_TENANT_REGISTRY_URL="http://127.0.0.1:$rport" \
NEXARCH_MODULEADAPTER_TENANT_SLUG="$tenant" \
NEXARCH_MODULEADAPTER_SERVICE_TOKEN="$token" \
NEXARCH_MODULEADAPTER_ALLOW_UNAUTHENTICATED="$allow_unauth" \
"$workdir/moduleadapter-api" > "$workdir/archive.log" 2>&1 & pids+=($!)
for _ in $(seq 1 50); do
@@ -99,6 +103,34 @@ check "richtiger tenant" 200 "$(status "${tok[@]}" -H "X-Nexarch-Tenant:
grep -q '"on_hold":true' "$workdir/body" || { echo "FAIL on_hold:true fehlt"; fail=1; }
check "POST ist nicht erlaubt" 405 "$(status "${tok[@]}" -X POST -H "X-Nexarch-Tenant: $tenant" "$url")"
# QA-15 F-08: /register verlangt Dienstauthentifizierung und weist unsichere Rueckruf-Adressen ab
reg_body() { printf '{"tenant_slug":"%s","module_name":"contract-dms","object_type":"contract-doc","retention_class":"klasse","callback_url":"%s"}' "$tenant" "$1"; }
reg() { curl -s -o "$workdir/body" -w '%{http_code}' -X POST -H 'Content-Type: application/json' "${tok[@]}" -d "$(reg_body "$1")" "http://127.0.0.1:$aport/register"; }
if [[ -n "$token" ]]; then
check "register ohne service-token" 401 "$(curl -s -o "$workdir/body" -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$(reg_body https://93.184.216.34/x)" "http://127.0.0.1:$aport/register")"
check "register falscher service-token" 401 "$(curl -s -o "$workdir/body" -w '%{http_code}' -X POST -H 'Content-Type: application/json' -H 'X-Service-Token: falsch' -d "$(reg_body https://93.184.216.34/x)" "http://127.0.0.1:$aport/register")"
fi
check "register: metadaten-adresse" 400 "$(reg https://169.254.169.254/latest)"
check "register: loopback ohne freigabe" 400 "$(reg https://127.0.0.1/x)"
check "register: privates netz" 400 "$(reg https://10.0.0.5/x)"
check "register: http ohne freigabe" 400 "$(reg http://93.184.216.34/x)"
check "register: file-schema" 400 "$(reg file:///etc/passwd)"
check "register: oeffentliches ziel" 200 "$(reg https://93.184.216.34/x)"
# QA-15 F-08/F-23: ohne Token und ohne ausdrueckliche Freigabe antwortet Archive fail closed
bport=$(freeport)
NEXARCH_MODULEADAPTER_TENANT_DSN="$test_dsn" \
NEXARCH_MODULEADAPTER_API_LISTEN_ADDR="127.0.0.1:$bport" \
NEXARCH_TENANT_REGISTRY_URL="http://127.0.0.1:$rport" \
NEXARCH_MODULEADAPTER_TENANT_SLUG="$tenant" \
"$workdir/moduleadapter-api" > "$workdir/archive-closed.log" 2>&1 & closed_pid=$!
for _ in $(seq 1 50); do curl -fsS "http://127.0.0.1:$bport/healthz" >/dev/null 2>&1 && break; sleep 0.2; done
check "ohne token-konfiguration: legal-hold" 503 "$(status -H "X-Nexarch-Tenant: $tenant" "http://127.0.0.1:$bport/legal-hold?object_type=document&object_reference=contract-held")"
if grep -q on_hold "$workdir/body"; then echo "FAIL fail-closed-Antwort enthaelt on_hold"; fail=1; fi
check "ohne token-konfiguration: register" 503 "$(curl -s -o "$workdir/body" -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$(reg_body https://93.184.216.34/x)" "http://127.0.0.1:$bport/register")"
kill "$closed_pid" 2>/dev/null || true
wait "$closed_pid" 2>/dev/null || true
(cd "$root/dms" && TEST_ARCHIVE_URL="http://127.0.0.1:$aport" TEST_ARCHIVE_TENANT="$tenant" TEST_ARCHIVE_TOKEN="$token" \
go test -count=1 -v -run 'ArchiveContract' ./internal/retentionclient/) || fail=1