QA-04: sicherheits-penetrationspruefung-core
Voller Merge von RBAC-02/IAM-06/IAM-07/API-03/API-10/API-08/API-09/IAM-10/ IAM-11/IAM-13 plus echte Angriffstests (internal/pentest) gegen SSO/OIDC (alg=none, Fremdschluessel, Claims-Manipulation, Nonce-Replay), Rate- Limiting/Lockout im simulierten Mehrinstanz-Betrieb, zentrale Policy- Durchsetzung (Rechteausweitung, struktureller Guard-Bypass) und Master- Key-/Tenant-KEK-Rotation. 29/29 Pakete gruen auf 192.168.1.131. Vier real gefundene Testinfrastruktur-Fehler behoben: reset-test-env.sh liess tenant_keks (und weitere neuere Registry-Tabellen) beim Reset stehen (FK-CASCADE loescht nur die Constraint, keine Zeilen); zwei E2E-Tests und kek_test.go schlossen ihren adminPool per defer VOR ihrer t.Cleanup- Bereinigung (t.Cleanup laeuft immer nach allen defers); migrate_test.go hatte ein Testschema ohne die TEN-04-Lifecycle-Spalten. Alle vier Fixes betreffen ausschliesslich Testcode, kein Produktionscode geaendert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5e6cb4e2db
commit
9f641fec76
@@ -0,0 +1,732 @@
|
||||
// Package pentest enthaelt QA-04 (Sicherheits- & Penetrationspruefung Core):
|
||||
// gezielte Angriffsversuche gegen die sensibelsten Core-Bereiche - SSO/OIDC,
|
||||
// Rate-Limiting/Account-Lockout im Mehrinstanz-Betrieb, zentrale
|
||||
// Policy-Durchsetzung und Master-Key-/Tenant-KEK-Verwaltung - jeweils gegen
|
||||
// die echten Produktionspakete, nicht gegen Mocks.
|
||||
package pentest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/lockout"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/oidc"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/ratelimit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Testinfrastruktur (identisch zum Muster aus internal/e2e/helpers_test.go)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func requireAdminDSN(t *testing.T) string {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_ADMIN_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Pentest uebersprungen")
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
return filepath.Join(wd, "..", "..")
|
||||
}
|
||||
|
||||
func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
sqlBytes, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("migration %q lesen: %v", name, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710") {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("migration %q anwenden: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupRegistryPool(t *testing.T) (*pgxpool.Pool, string) {
|
||||
t.Helper()
|
||||
adminDSN := requireAdminDSN(t)
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("registry pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
applyAllUpSQL(t, ctx, pool, filepath.Join(repoRoot(t), "migrations"))
|
||||
return pool, adminDSN
|
||||
}
|
||||
|
||||
func uniqueSlug(prefix string) string {
|
||||
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Pruefung 2 / Akzeptanzkriterium 1: SSO/OIDC gegen Token-Manipulation,
|
||||
// Replay und Claims-Manipulation.
|
||||
// =======================================================================
|
||||
|
||||
// TestPentest_OIDC_AlgNoneAttack simuliert den klassischen "alg=none"-Angriff
|
||||
// gegen den EXTERNEN OIDC-Verifier (IAM-06, internal/oidc.Verifier): ein
|
||||
// Angreifer stellt sich selbst ein Token mit Signaturalgorithmus "none" aus,
|
||||
// in der Hoffnung, dass eine naive JWT-Bibliothek die fehlende Signatur
|
||||
// akzeptiert. internal/oidc.Verifier.Verify prueft explizit auf
|
||||
// *jwt.SigningMethodRSA - "none" muss zuverlaessig abgewiesen werden.
|
||||
func TestPentest_OIDC_AlgNoneAttack(t *testing.T) {
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa key: %v", err)
|
||||
}
|
||||
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test")
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "angreifer@example.test",
|
||||
"iss": "https://idp.example.test",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
unsigned := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
|
||||
unsigned.Header["kid"] = "kid-1"
|
||||
forged, err := unsigned.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatalf("alg=none token faelschen: %v", err)
|
||||
}
|
||||
|
||||
if _, err := verifier.Verify(forged); err == nil {
|
||||
t.Fatal("alg=none-token wurde akzeptiert - kritische luecke")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPentest_OIDC_ForeignKeySignature simuliert einen Angreifer, der ein
|
||||
// Token mit einem EIGENEN RSA-Schluessel signiert und die kid eines
|
||||
// bekannten, vertrauenswuerdigen Schluessels vorgibt (Schluessel-Confusion).
|
||||
// Verify darf das Token nur mit dem TATSAECHLICH registrierten Public Key
|
||||
// pruefen, niemals mit dem im Token mitgelieferten.
|
||||
func TestPentest_OIDC_ForeignKeySignature(t *testing.T) {
|
||||
trustedKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("trusted key: %v", err)
|
||||
}
|
||||
attackerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("attacker key: %v", err)
|
||||
}
|
||||
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"trusted-kid": &trustedKey.PublicKey}, "https://idp.example.test")
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "angreifer@example.test",
|
||||
"iss": "https://idp.example.test",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
forgedToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
forgedToken.Header["kid"] = "trusted-kid" // gibt vor, mit dem bekannten Schluessel signiert zu sein
|
||||
forged, err := forgedToken.SignedString(attackerKey)
|
||||
if err != nil {
|
||||
t.Fatalf("token mit fremdem schluessel signieren: %v", err)
|
||||
}
|
||||
|
||||
if _, err := verifier.Verify(forged); err == nil {
|
||||
t.Fatal("mit fremdem schluessel signiertes token wurde akzeptiert - kritische luecke")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPentest_OIDC_TamperedClaims prueft, dass eine nachtraegliche
|
||||
// Claims-Manipulation (z.B. sub oder roles aendern, ohne neu zu signieren)
|
||||
// zuverlaessig an der Signaturpruefung scheitert.
|
||||
func TestPentest_OIDC_TamperedClaims(t *testing.T) {
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa key: %v", err)
|
||||
}
|
||||
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test")
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "normaler-benutzer@example.test",
|
||||
"iss": "https://idp.example.test",
|
||||
"roles": []string{"user"},
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
valid := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
valid.Header["kid"] = "kid-1"
|
||||
signed, err := valid.SignedString(rsaKey)
|
||||
if err != nil {
|
||||
t.Fatalf("token signieren: %v", err)
|
||||
}
|
||||
|
||||
// Gegenprobe: das unveraenderte, echte Token wird akzeptiert.
|
||||
if _, err := verifier.Verify(signed); err != nil {
|
||||
t.Fatalf("unveraendertes, echtes token wurde abgewiesen: %v", err)
|
||||
}
|
||||
|
||||
// Claims-Manipulation: Payload-Segment durch eine Version mit
|
||||
// erhoehter Rolle ersetzen, Signatur-Segment unveraendert lassen.
|
||||
parts := strings.Split(signed, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("unerwartetes token-format: %d segmente", len(parts))
|
||||
}
|
||||
tamperedClaims := jwt.MapClaims{
|
||||
"sub": "normaler-benutzer@example.test",
|
||||
"iss": "https://idp.example.test",
|
||||
"roles": []string{"superadmin"}, // Rechteausweitung
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
tamperedHeader := jwt.NewWithClaims(jwt.SigningMethodRS256, tamperedClaims)
|
||||
tamperedHeader.Header["kid"] = "kid-1"
|
||||
tamperedUnsigned, err := tamperedHeader.SigningString()
|
||||
if err != nil {
|
||||
t.Fatalf("manipulierten payload kodieren: %v", err)
|
||||
}
|
||||
tamperedParts := strings.Split(tamperedUnsigned, ".")
|
||||
forged := tamperedParts[0] + "." + tamperedParts[1] + "." + parts[2] // fremder payload + ALTE signatur
|
||||
|
||||
if _, err := verifier.Verify(forged); err == nil {
|
||||
t.Fatal("token mit manipulierten claims (rechteausweitung) wurde akzeptiert - kritische luecke")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPentest_OIDC_NonceReplay ist Pruefung 2 (Replay-Schutz): ein gueltiges,
|
||||
// korrekt signiertes ID-Token eines FRUEHEREN Logins darf nicht fuer einen
|
||||
// neuen Login-Vorgang mit einem anderen erwarteten Nonce wiederverwendet
|
||||
// werden koennen.
|
||||
func TestPentest_OIDC_NonceReplay(t *testing.T) {
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa key: %v", err)
|
||||
}
|
||||
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test")
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "benutzer@example.test",
|
||||
"iss": "https://idp.example.test",
|
||||
"nonce": "nonce-aus-login-versuch-1",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
valid := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
valid.Header["kid"] = "kid-1"
|
||||
signed, err := valid.SignedString(rsaKey)
|
||||
if err != nil {
|
||||
t.Fatalf("token signieren: %v", err)
|
||||
}
|
||||
|
||||
// Der urspruengliche Login-Versuch (passender Nonce) ist gueltig.
|
||||
if _, err := verifier.VerifyWithNonce(signed, "nonce-aus-login-versuch-1"); err != nil {
|
||||
t.Fatalf("token mit korrektem nonce wurde abgewiesen: %v", err)
|
||||
}
|
||||
|
||||
// Ein Angreifer faengt das Token ab und spielt es bei einem NEUEN
|
||||
// Login-Versuch (mit eigenem, anderem erwarteten Nonce) erneut ein.
|
||||
if _, err := verifier.VerifyWithNonce(signed, "nonce-aus-login-versuch-2"); err == nil {
|
||||
t.Fatal("token-replay mit abweichendem nonce wurde akzeptiert - kritische luecke")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPentest_OIDC_IdPTokenManipulation prueft den Weg, bei dem NEXARCH
|
||||
// selbst als OIDC-Provider fuer Drittanwendungen auftritt (IAM-13,
|
||||
// internal/oidc.Handler.JWKS + issueIDToken, Ed25519/EdDSA). Ein Angreifer
|
||||
// versucht, ein selbst signiertes ID-Token gegen den echten, aus dem
|
||||
// JWKS-Endpunkt gelesenen Schluessel zu verifizieren.
|
||||
func TestPentest_OIDC_IdPTokenManipulation(t *testing.T) {
|
||||
genuinePub, genuinePriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("echtes schluesselpaar erzeugen: %v", err)
|
||||
}
|
||||
attackerPub, attackerPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("angreifer-schluesselpaar erzeugen: %v", err)
|
||||
}
|
||||
_ = attackerPub
|
||||
|
||||
claims := oidc.IDTokenClaims{
|
||||
Subject: "opfer-user-id",
|
||||
TenantSlug: "opfer-tenant",
|
||||
Scopes: []string{"openid"},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "https://core.nexarch.example",
|
||||
Audience: jwt.ClaimStrings{"drittanwendung-client-id"},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
},
|
||||
}
|
||||
forged := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||
forged.Header["kid"] = "genuine-kid" // gibt vor, mit dem echten Schluessel signiert zu sein
|
||||
forgedSigned, err := forged.SignedString(attackerPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("token mit angreiferschluessel signieren: %v", err)
|
||||
}
|
||||
|
||||
// Verifikation exakt wie eine Drittanwendung sie gegen den echten,
|
||||
// aus dem JWKS-Endpunkt bezogenen Schluessel durchfuehren wuerde.
|
||||
verify := func(tokenString string, kid2key map[string]ed25519.PublicKey) error {
|
||||
parsed, err := jwt.Parse(tokenString, func(tk *jwt.Token) (interface{}, error) {
|
||||
if _, ok := tk.Method.(*jwt.SigningMethodEd25519); !ok {
|
||||
return nil, fmt.Errorf("unerwarteter algorithmus: %v", tk.Header["alg"])
|
||||
}
|
||||
kid, _ := tk.Header["kid"].(string)
|
||||
pub, ok := kid2key[kid]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unbekannte kid %q", kid)
|
||||
}
|
||||
return pub, nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
return fmt.Errorf("ungueltig: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
trustedKeys := map[string]ed25519.PublicKey{"genuine-kid": genuinePub}
|
||||
|
||||
if err := verify(forgedSigned, trustedKeys); err == nil {
|
||||
t.Fatal("mit angreiferschluessel signiertes idp-token wurde akzeptiert - kritische luecke")
|
||||
}
|
||||
|
||||
// Gegenprobe: ein echtes, mit dem tatsaechlichen Schluessel signiertes
|
||||
// Token muss weiterhin akzeptiert werden (keine False-Positive-Abwehr).
|
||||
genuine := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||
genuine.Header["kid"] = "genuine-kid"
|
||||
genuineSigned, err := genuine.SignedString(genuinePriv)
|
||||
if err != nil {
|
||||
t.Fatalf("echtes token signieren: %v", err)
|
||||
}
|
||||
if err := verify(genuineSigned, trustedKeys); err != nil {
|
||||
t.Fatalf("echtes, korrekt signiertes idp-token wurde abgewiesen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Pruefung 1 / Akzeptanzkriterium 2: Rate-Limiting/Account-Lockout im
|
||||
// Mehrinstanz-Betrieb nachweislich nicht umgehbar.
|
||||
// =======================================================================
|
||||
|
||||
// TestPentest_RateLimit_MultiInstanceBypassAttempt simuliert einen
|
||||
// Angreifer, der versucht, das Rate-Limit zu umgehen, indem er seine
|
||||
// Anfragen ueber MEHRERE Core-Instanzen (hier: mehrere unabhaengige
|
||||
// pgxpool.Pool-Verbindungen zur selben Postgres-Instanz, wie es getrennte
|
||||
// Core-Prozesse waeren) verteilt, in der Annahme, jede Instanz fuehre einen
|
||||
// eigenen In-Process-Zaehler. Da ratelimit.Store atomar gegen dieselbe
|
||||
// geteilte Tabelle arbeitet (API-03), darf die Gesamtsumme der erlaubten
|
||||
// Anfragen ueber ALLE "Instanzen" hinweg das konfigurierte Limit nicht
|
||||
// uebersteigen.
|
||||
func TestPentest_RateLimit_MultiInstanceBypassAttempt(t *testing.T) {
|
||||
_, adminDSN := setupRegistryPool(t)
|
||||
ctx := context.Background()
|
||||
key := uniqueSlug("pentest-ratelimit")
|
||||
|
||||
const instanceCount = 4
|
||||
const requestsPerInstance = 50
|
||||
const limit = 100
|
||||
|
||||
// Eine eigene Konfiguration ueber EINE der "Instanzen" gesetzt - muss
|
||||
// fuer alle anderen sofort sichtbar sein (geteilter Zustand).
|
||||
configPool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("config pool: %v", err)
|
||||
}
|
||||
defer configPool.Close()
|
||||
if err := ratelimit.NewStore(configPool).SetLimit(ctx, key, limit, time.Minute); err != nil {
|
||||
t.Fatalf("limit setzen: %v", err)
|
||||
}
|
||||
|
||||
var allowed int64
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < instanceCount; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Jede "Instanz" hat einen EIGENEN Pool - simuliert getrennte
|
||||
// Prozesse, die sich keinen In-Memory-Zustand teilen.
|
||||
instancePool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Errorf("instanz-pool: %v", err)
|
||||
return
|
||||
}
|
||||
defer instancePool.Close()
|
||||
store := ratelimit.NewStore(instancePool)
|
||||
|
||||
for j := 0; j < requestsPerInstance; j++ {
|
||||
res, err := store.Allow(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("allow: %v", err)
|
||||
return
|
||||
}
|
||||
if res.Allowed {
|
||||
atomic.AddInt64(&allowed, 1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
totalRequests := int64(instanceCount * requestsPerInstance)
|
||||
if allowed > limit {
|
||||
t.Fatalf("mehrinstanz-umgehungsversuch erfolgreich: %d von %d anfragen ueber %d instanzen erlaubt, konfiguriertes limit war %d",
|
||||
allowed, totalRequests, instanceCount, limit)
|
||||
}
|
||||
if allowed != limit {
|
||||
t.Fatalf("erwartet exakt %d erlaubte anfragen (limit ausgeschoepft, nicht mehr/weniger), habe %d", limit, allowed)
|
||||
}
|
||||
t.Logf("mehrinstanz-umgehungsversuch protokolliert erfolglos: %d instanzen, %d anfragen gesamt, exakt %d (=limit) erlaubt", instanceCount, totalRequests, allowed)
|
||||
}
|
||||
|
||||
// TestPentest_Lockout_MultiInstanceBypassAttempt simuliert denselben
|
||||
// Umgehungsversuch gegen den Account-Lockout (IAM-07): ein Angreifer verteilt
|
||||
// Brute-Force-Loginversuche fuer EIN Konto ueber mehrere Instanzen, um zu
|
||||
// verhindern, dass eine einzelne Instanz die Schwelle erreicht.
|
||||
func TestPentest_Lockout_MultiInstanceBypassAttempt(t *testing.T) {
|
||||
registryPool, adminDSN := setupRegistryPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||
registry := tenant.NewRegistry(registryPool)
|
||||
provisioner := tenant.NewProvisioner(registryPool, registry, dsnTemplate)
|
||||
|
||||
slug := uniqueSlug("pentest_lockout")
|
||||
tn, err := provisioner.Provision(ctx, slug, "Pentest Lockout GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
||||
_, _ = registryPool.Exec(ctx, `DROP DATABASE IF EXISTS "tenant_`+slug+`"`)
|
||||
})
|
||||
|
||||
tenantPool, err := pgxpool.New(ctx, tn.DBDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("tenant pool: %v", err)
|
||||
}
|
||||
defer tenantPool.Close()
|
||||
applyAllUpSQL(t, ctx, tenantPool, filepath.Join(repoRoot(t), "migrations", "tenant"))
|
||||
|
||||
const email = "opfer@pentest.example"
|
||||
const maxFailed = lockout.DefaultMaxFailedAttempts
|
||||
const instanceCount = 3
|
||||
const attemptsPerInstance = 4 // 3*4=12 > maxFailed(5), muesste ohne umgehung frueh sperren
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var lockedAt []int // Reihenfolge, in der jede "Instanz" eine Sperre beobachtet hat
|
||||
for i := 0; i < instanceCount; i++ {
|
||||
wg.Add(1)
|
||||
instanceID := i
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
instancePool, err := pgxpool.New(ctx, tn.DBDSN)
|
||||
if err != nil {
|
||||
t.Errorf("instanz-pool: %v", err)
|
||||
return
|
||||
}
|
||||
defer instancePool.Close()
|
||||
store := lockout.NewStore(instancePool)
|
||||
|
||||
for j := 0; j < attemptsPerInstance; j++ {
|
||||
locked, _, err := store.RecordFailure(ctx, email)
|
||||
if err != nil {
|
||||
t.Errorf("fehlversuch erfassen: %v", err)
|
||||
return
|
||||
}
|
||||
if locked {
|
||||
mu.Lock()
|
||||
lockedAt = append(lockedAt, instanceID)
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(lockedAt) == 0 {
|
||||
t.Fatal("mehrinstanz-umgehungsversuch erfolgreich: konto wurde trotz insgesamt mehr als maxFailed fehlversuchen ueber mehrere instanzen NICHT gesperrt")
|
||||
}
|
||||
|
||||
locked, _, err := lockout.NewStore(tenantPool).IsLocked(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("sperrstatus pruefen: %v", err)
|
||||
}
|
||||
if !locked {
|
||||
t.Fatal("konto ist nach ueberschreiten der schwelle ueber mehrere instanzen hinweg nicht als gesperrt markiert")
|
||||
}
|
||||
t.Logf("mehrinstanz-umgehungsversuch protokolliert erfolglos: %d instanzen x %d versuche, konto nach ueberschreiten von maxFailed=%d gesperrt", instanceCount, attemptsPerInstance, maxFailed)
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Pruefung 3 / Akzeptanzkriterium 3: zentrale Policy-Durchsetzung gegen
|
||||
// mindestens einen gezielten Umgehungsversuch je Modul.
|
||||
// =======================================================================
|
||||
|
||||
// TestPentest_Policy_PrivilegeEscalationViaWrongRole ist der
|
||||
// Umgehungsversuch fuer den Identitaets-/Mandantenbereich (TEN/IAM-Module):
|
||||
// ein normaler Benutzer versucht, ueber den zentralen Enforcer (RBAC-02) ein
|
||||
// Recht zu nutzen, das ausschliesslich Superadmin vorbehalten ist
|
||||
// (PermManageTenants). Default-Deny muss greifen.
|
||||
func TestPentest_Policy_PrivilegeEscalationViaWrongRole(t *testing.T) {
|
||||
registryPool, _ := setupRegistryPool(t)
|
||||
ctx := context.Background()
|
||||
store := policy.NewStore(registryPool)
|
||||
enforcer := policy.NewEnforcer(store)
|
||||
|
||||
// Nur die Basisrechte gewaehren, wie es die reale Policy vorsieht -
|
||||
// PermManageTenants bewusst NICHT an user oder tenant_admin vergeben.
|
||||
if err := store.Grant(ctx, rbac.RoleUser, rbac.PermSelfRead, "pentest-setup"); err != nil {
|
||||
t.Fatalf("basisrecht gewaehren: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = store.Revoke(ctx, rbac.RoleUser, rbac.PermSelfRead, "pentest-cleanup")
|
||||
})
|
||||
|
||||
// Angriff: ein Benutzer mit Rolle "user" versucht, eine
|
||||
// mandantenverwaltende Aktion auszufuehren.
|
||||
err := enforcer.Authorize(ctx, rbac.RoleUser, rbac.PermManageTenants)
|
||||
if err == nil {
|
||||
t.Fatal("privilege-escalation erfolgreich: rolle 'user' durfte platform.manage_tenants ausfuehren")
|
||||
}
|
||||
if !errors.Is(err, policy.ErrDenied) {
|
||||
t.Fatalf("erwartete policy.ErrDenied, habe: %v", err)
|
||||
}
|
||||
|
||||
// Angriff Variante 2: dieselbe Aktion fuer tenant_admin, der ebenfalls
|
||||
// nicht ueber PermManageTenants verfuegt.
|
||||
if err := enforcer.Authorize(ctx, rbac.RoleTenantAdmin, rbac.PermManageTenants); !errors.Is(err, policy.ErrDenied) {
|
||||
t.Fatalf("privilege-escalation ueber tenant_admin: erwartete policy.ErrDenied, habe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPentest_Policy_GuardBypassStructurallyImpossible ist der
|
||||
// Umgehungsversuch gegen die Durchsetzungsschicht selbst (Akzeptanzkriterium
|
||||
// 1): policy.Guard ruft die uebergebene Query-Funktion NUR bei Erfolg von
|
||||
// Authorize auf. Dieser Test beweist das Verhalten aktiv, indem ein
|
||||
// Zaehler in der Query-Funktion inkrementiert wird - bei einer verweigerten
|
||||
// Autorisierung darf der Zaehler NIEMALS erhoeht werden, unabhaengig davon,
|
||||
// wie oft Guard aufgerufen wird.
|
||||
func TestPentest_Policy_GuardBypassStructurallyImpossible(t *testing.T) {
|
||||
registryPool, _ := setupRegistryPool(t)
|
||||
ctx := context.Background()
|
||||
store := policy.NewStore(registryPool)
|
||||
enforcer := policy.NewEnforcer(store)
|
||||
// Bewusst KEINE Regel gewaehrt - default-deny.
|
||||
|
||||
var dataAccessed int32
|
||||
query := func(ctx context.Context) (string, error) {
|
||||
atomic.AddInt32(&dataAccessed, 1)
|
||||
return "geheime mandantendaten", nil
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err := policy.Guard(ctx, enforcer, rbac.RoleUser, rbac.PermManageSettings, query)
|
||||
if err == nil {
|
||||
t.Fatal("guard hat query trotz fehlender berechtigung ausgefuehrt")
|
||||
}
|
||||
}
|
||||
|
||||
if atomic.LoadInt32(&dataAccessed) != 0 {
|
||||
t.Fatalf("umgehungsversuch erfolgreich: query wurde %d mal trotz verweigerter autorisierung ausgefuehrt", dataAccessed)
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Akzeptanzkriterium 4: Master-Key- und Tenant-KEK-Verwaltung (API-10)
|
||||
// gegen Schluesselkompromittierung und Rotation geprueft.
|
||||
// =======================================================================
|
||||
|
||||
// TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation prueft: nach
|
||||
// einer Master-Key-Rotation kann der ALTE (potenziell kompromittierte)
|
||||
// Master-Key die Tenant-KEKs nicht mehr entschluesseln - kompromittiert ein
|
||||
// Angreifer den alten Master-Key NACH der Rotation, ist der Zugriff bereits
|
||||
// verwehrt.
|
||||
func TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation(t *testing.T) {
|
||||
oldMasterKey := randomMasterKey(t)
|
||||
newMasterKey := randomMasterKey(t)
|
||||
|
||||
store, pool := setupKEKStore(t)
|
||||
tenantID := insertTenantRow(t, pool, uniqueSlug("pentest_kek"))
|
||||
|
||||
plainKEK, err := store.CreateForTenant(context.Background(), tenantID, oldMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("tenant-kek anlegen: %v", err)
|
||||
}
|
||||
|
||||
rotated, failed, err := store.RotateMasterKey(context.Background(), oldMasterKey, newMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("master-key-rotation: %v", err)
|
||||
}
|
||||
if rotated != 1 || len(failed) != 0 {
|
||||
t.Fatalf("rotation unerwartet: rotated=%d failed=%v", rotated, failed)
|
||||
}
|
||||
|
||||
// Angriff: der (kompromittierte) ALTE Master-Key wird nach der Rotation
|
||||
// verwendet, um den Tenant-KEK zu entschluesseln.
|
||||
if _, err := store.GetDecrypted(context.Background(), tenantID, oldMasterKey); err == nil {
|
||||
t.Fatal("kompromittierter alter master-key konnte tenant-kek nach rotation weiterhin entschluesseln - kritische luecke")
|
||||
}
|
||||
|
||||
// Gegenprobe: der neue Master-Key liefert weiterhin denselben Plaintext-
|
||||
// Tenant-KEK (Akzeptanzkriterium 3: Rotation aendert nur die Verpackung).
|
||||
decrypted, err := store.GetDecrypted(context.Background(), tenantID, newMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("neuer master-key konnte tenant-kek nicht entschluesseln: %v", err)
|
||||
}
|
||||
if string(decrypted) != string(plainKEK) {
|
||||
t.Fatal("tenant-kek-plaintext hat sich durch master-key-rotation veraendert - widerspricht akzeptanzkriterium 3")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants ist der
|
||||
// Umgehungs-/Kompromittierungstest fuer Tenant-KEK-Rotation: kompromittiert
|
||||
// ein Angreifer den Tenant-KEK von Mandant A und dieser wird rotiert, darf
|
||||
// das keinerlei Auswirkung auf Mandant B haben (Fortsetzung der physischen
|
||||
// Modell-C-Isolation auf Schluesselebene).
|
||||
func TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants(t *testing.T) {
|
||||
masterKey := randomMasterKey(t)
|
||||
store, pool := setupKEKStore(t)
|
||||
|
||||
tenantA := insertTenantRow(t, pool, uniqueSlug("pentest_kek_a"))
|
||||
tenantB := insertTenantRow(t, pool, uniqueSlug("pentest_kek_b"))
|
||||
|
||||
if _, err := store.CreateForTenant(context.Background(), tenantA, masterKey); err != nil {
|
||||
t.Fatalf("tenant a kek anlegen: %v", err)
|
||||
}
|
||||
kekBBefore, err := store.CreateForTenant(context.Background(), tenantB, masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("tenant b kek anlegen: %v", err)
|
||||
}
|
||||
|
||||
// Tenant As KEK wird rotiert (z.B. weil kompromittiert).
|
||||
if _, err := store.RotateTenantKEK(context.Background(), tenantA, masterKey); err != nil {
|
||||
t.Fatalf("tenant a rotation: %v", err)
|
||||
}
|
||||
|
||||
kekBAfter, err := store.GetDecrypted(context.Background(), tenantB, masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("tenant b kek nach fremder rotation nicht mehr lesbar: %v", err)
|
||||
}
|
||||
if string(kekBAfter) != string(kekBBefore) {
|
||||
t.Fatal("tenant bs kek hat sich durch rotation von tenant a veraendert - isolationsverletzung")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPentest_KEK_TamperedWrappedKeyRejected prueft, dass eine Manipulation
|
||||
// des in der Datenbank gespeicherten, verpackten Tenant-KEK (z.B. durch
|
||||
// einen Angreifer mit Datenbankzugriff, aber ohne Master-Key) beim
|
||||
// Entschluesseln zuverlaessig als ungueltig erkannt wird - AES-GCM ist
|
||||
// authentifizierend, ein manipulierter Chiffretext darf niemals einen
|
||||
// (falschen) Plaintext zurueckliefern.
|
||||
func TestPentest_KEK_TamperedWrappedKeyRejected(t *testing.T) {
|
||||
masterKey := randomMasterKey(t)
|
||||
store, pool := setupKEKStore(t)
|
||||
tenantID := insertTenantRow(t, pool, uniqueSlug("pentest_kek_tamper"))
|
||||
|
||||
if _, err := store.CreateForTenant(context.Background(), tenantID, masterKey); err != nil {
|
||||
t.Fatalf("tenant-kek anlegen: %v", err)
|
||||
}
|
||||
|
||||
// Angriff: ein Byte im gespeicherten, verpackten Schluessel wird
|
||||
// direkt in der Datenbank veraendert (kein Master-Key noetig fuer diesen
|
||||
// Schritt).
|
||||
tag, err := pool.Exec(context.Background(), `
|
||||
UPDATE tenant_keks SET wrapped_kek = set_byte(wrapped_kek, 20, get_byte(wrapped_kek, 20) # 255)
|
||||
WHERE tenant_id = $1
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
t.Fatalf("wrapped_kek manipulieren: %v", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
t.Fatalf("erwartet 1 manipulierte zeile, habe %d", tag.RowsAffected())
|
||||
}
|
||||
|
||||
if _, err := store.GetDecrypted(context.Background(), tenantID, masterKey); !errors.Is(err, kek.ErrUnwrapFailed) {
|
||||
t.Fatalf("manipulierter wrapped_kek wurde nicht als ErrUnwrapFailed erkannt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// KEK-Testhilfsfunktionen (eigenes Minimalschema wie in internal/kek/kek_test.go,
|
||||
// mit derselben Aufraeum-Disziplin - siehe QA-04-Befund zu
|
||||
// t.Cleanup/defer-Reihenfolge).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func randomMasterKey(t *testing.T) kek.MasterKey {
|
||||
t.Helper()
|
||||
buf := make([]byte, kek.MasterKeySize)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
t.Fatalf("master-key erzeugen: %v", err)
|
||||
}
|
||||
return kek.MasterKey(buf)
|
||||
}
|
||||
|
||||
func setupKEKStore(t *testing.T) (*kek.Store, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
adminDSN := requireAdminDSN(t)
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
// Ueber t.Cleanup registriert (nicht defer), damit spaeter via t.Cleanup
|
||||
// registrierte Aufraeumfunktionen (insertTenantRow) den Pool beim
|
||||
// Ausfuehren noch offen vorfinden - siehe QA-04-Befund in
|
||||
// internal/kek/kek_test.go zur defer/t.Cleanup-Reihenfolge.
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS tenant_keks (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ
|
||||
)`); err != nil {
|
||||
t.Fatalf("tenant_keks-schema: %v", err)
|
||||
}
|
||||
return kek.NewStore(pool), pool
|
||||
}
|
||||
|
||||
func insertTenantRow(t *testing.T, pool *pgxpool.Pool, slug string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
err := pool.QueryRow(context.Background(), `
|
||||
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id
|
||||
`, slug).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("tenant-zeile anlegen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM tenant_keks WHERE tenant_id = $1`, id)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE id = $1`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
Reference in New Issue
Block a user