diff --git a/archive/cmd/moduleadapter-api/main.go b/archive/cmd/moduleadapter-api/main.go index 51a8370e..a28d08ba 100644 --- a/archive/cmd/moduleadapter-api/main.go +++ b/archive/cmd/moduleadapter-api/main.go @@ -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) }) diff --git a/archive/internal/contracttest/contracttest_test.go b/archive/internal/contracttest/contracttest_test.go index e92a56dd..5dfd7b84 100644 --- a/archive/internal/contracttest/contracttest_test.go +++ b/archive/internal/contracttest/contracttest_test.go @@ -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{ diff --git a/archive/internal/kennzahlen/kennzahlen.go b/archive/internal/kennzahlen/kennzahlen.go index 96163363..7a519615 100644 --- a/archive/internal/kennzahlen/kennzahlen.go +++ b/archive/internal/kennzahlen/kennzahlen.go @@ -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 { diff --git a/archive/internal/legalholdapi/legalholdapi.go b/archive/internal/legalholdapi/legalholdapi.go index 81468be9..7c7b5e77 100644 --- a/archive/internal/legalholdapi/legalholdapi.go +++ b/archive/internal/legalholdapi/legalholdapi.go @@ -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 } diff --git a/archive/internal/legalholdapi/legalholdapi_test.go b/archive/internal/legalholdapi/legalholdapi_test.go index f74fa05d..fc09ac9c 100644 --- a/archive/internal/legalholdapi/legalholdapi_test.go +++ b/archive/internal/legalholdapi/legalholdapi_test.go @@ -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) diff --git a/archive/internal/moduleadapter/callbackpolicy.go b/archive/internal/moduleadapter/callbackpolicy.go new file mode 100644 index 00000000..68d39366 --- /dev/null +++ b/archive/internal/moduleadapter/callbackpolicy.go @@ -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, + }, + } +} diff --git a/archive/internal/moduleadapter/callbackpolicy_test.go b/archive/internal/moduleadapter/callbackpolicy_test.go new file mode 100644 index 00000000..1c917e85 --- /dev/null +++ b/archive/internal/moduleadapter/callbackpolicy_test.go @@ -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") + } +} diff --git a/archive/internal/moduleadapter/handler.go b/archive/internal/moduleadapter/handler.go index 5c4d6ef4..9fad7550 100644 --- a/archive/internal/moduleadapter/handler.go +++ b/archive/internal/moduleadapter/handler.go @@ -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 } diff --git a/archive/internal/moduleadapter/handler_test.go b/archive/internal/moduleadapter/handler_test.go index 0f92b0c4..bd965940 100644 --- a/archive/internal/moduleadapter/handler_test.go +++ b/archive/internal/moduleadapter/handler_test.go @@ -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) + } +} diff --git a/archive/internal/svcauth/svcauth.go b/archive/internal/svcauth/svcauth.go new file mode 100644 index 00000000..4f803353 --- /dev/null +++ b/archive/internal/svcauth/svcauth.go @@ -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 +} diff --git a/archive/internal/svcauth/svcauth_test.go b/archive/internal/svcauth/svcauth_test.go new file mode 100644 index 00000000..c06848c9 --- /dev/null +++ b/archive/internal/svcauth/svcauth_test.go @@ -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) + } + } +} diff --git a/dms/internal/retentionclient/contract_archive_test.go b/dms/internal/retentionclient/contract_archive_test.go index 22ee362f..2fb4c8cf 100644 --- a/dms/internal/retentionclient/contract_archive_test.go +++ b/dms/internal/retentionclient/contract_archive_test.go @@ -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) } diff --git a/scripts/legalhold-contract-test.sh b/scripts/legalhold-contract-test.sh index 57bec82e..735bde70 100755 --- a/scripts/legalhold-contract-test.sh +++ b/scripts/legalhold-contract-test.sh @@ -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