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,149 @@
|
||||
# QA-04 – Prüfprotokoll: Sicherheits- & Penetrationsprüfung Core
|
||||
|
||||
Welle 6. Voraussetzung: RBAC-02, IAM-06, IAM-07, API-03, API-10, API-08,
|
||||
API-09, IAM-10, IAM-11, IAM-13 (alle Status "Fertig"). Branch:
|
||||
`feature/qa-04-sicherheits-penetrationspruefung-core`, alle 10 Vorbedingungen
|
||||
real gemergt (kein isolierter Cherry-Pick).
|
||||
|
||||
## 1. Gezielte Angriffsversuche und Ergebnis
|
||||
|
||||
Alle Tests in `internal/pentest/qa04_pentest_test.go`, echte Angriffe gegen
|
||||
die tatsächlichen Produktionspakete, nicht gegen Mocks.
|
||||
|
||||
### 1.1 SSO/OIDC gegen Token-Manipulation, Replay, Claims-Manipulation (Akzeptanzkriterium 1)
|
||||
|
||||
| Angriff | Test | Zielwert | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| `alg=none`-Angriff gegen externen OIDC-Verifier (IAM-06) | `TestPentest_OIDC_AlgNoneAttack` | abgewiesen | **bestanden** |
|
||||
| Signatur mit fremdem (Angreifer-)Schlüssel unter bekannter `kid` | `TestPentest_OIDC_ForeignKeySignature` | abgewiesen | **bestanden** |
|
||||
| Claims nachträglich manipuliert (Rechteausweitung `roles`), alte Signatur wiederverwendet | `TestPentest_OIDC_TamperedClaims` | abgewiesen | **bestanden** |
|
||||
| Replay eines gültigen ID-Tokens mit abweichendem Nonce | `TestPentest_OIDC_NonceReplay` | abgewiesen | **bestanden** |
|
||||
| Core als eigener IdP (IAM-13): ID-Token mit Angreiferschlüssel unter vorgetäuschter `kid` | `TestPentest_OIDC_IdPTokenManipulation` | abgewiesen | **bestanden** |
|
||||
|
||||
Ergebnis: `internal/oidc.Verifier` erzwingt Algorithmus (`RS256` bzw.
|
||||
`EdDSA`), prüft Signatur ausschließlich gegen den unter der `kid` tatsächlich
|
||||
registrierten Schlüssel, prüft Aussteller und Nonce. Kein Umgehungsweg
|
||||
gefunden.
|
||||
|
||||
### 1.2 Rate-Limiting/Account-Lockout im Mehrinstanz-Betrieb (Akzeptanzkriterium 2, Prüfung 1)
|
||||
|
||||
| Angriff | Test | Zielwert | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| Rate-Limit über 4 unabhängige "Instanzen" (eigene DB-Pools) parallel umgehen | `TestPentest_RateLimit_MultiInstanceBypassAttempt` | Gesamtsumme erlaubter Anfragen = konfiguriertes Limit, nicht mehr | **bestanden** – 200 Anfragen über 4 Instanzen, exakt 100 (=Limit) erlaubt |
|
||||
| Account-Lockout über 3 unabhängige Instanzen verteilte Brute-Force-Versuche umgehen | `TestPentest_Lockout_MultiInstanceBypassAttempt` | Konto wird trotz Verteilung gesperrt | **bestanden** – 12 Versuche über 3 Instanzen, Sperre nach Erreichen von `maxFailed=5` |
|
||||
|
||||
Ergebnis: beide Mechanismen nutzen ein atomares SQL-`UPSERT` gegen
|
||||
geteilten, externen Zustand (Postgres) statt In-Process-Zähler – exakt der
|
||||
in archivdms als Fehlerklasse dokumentierte Schwachpunkt wird hier vermieden.
|
||||
|
||||
### 1.3 Zentrale Policy-Durchsetzung, gezielter Umgehungsversuch je Modul (Akzeptanzkriterium 3, Prüfung 3)
|
||||
|
||||
| Angriff | Test | Zielwert | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| Rechteausweitung: Rolle `user`/`tenant_admin` versucht `platform.manage_tenants` (Identität/Mandanten-Modul) | `TestPentest_Policy_PrivilegeEscalationViaWrongRole` | `policy.ErrDenied` | **bestanden** |
|
||||
| Strukturelle Umgehung von `policy.Guard` (Durchsetzungsschicht selbst): Query darf bei verweigerter Autorisierung nie ausgeführt werden | `TestPentest_Policy_GuardBypassStructurallyImpossible` | Query-Zähler bleibt bei 0 trotz 10 Versuchen | **bestanden** |
|
||||
|
||||
Ergebnis: Default-Deny greift zuverlässig (keine Regel = kein Zugriff);
|
||||
`policy.Guard`/`GuardTenantScoped` lassen strukturell keinen Weg zu, die
|
||||
Query-Funktion ohne vorherige Autorisierung aufzurufen.
|
||||
|
||||
### 1.4 Master-Key- und Tenant-KEK-Verwaltung gegen Kompromittierung/Rotation (Akzeptanzkriterium 4)
|
||||
|
||||
| Angriff | Test | Zielwert | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| Kompromittierter ALTER Master-Key nach Rotation weiterverwenden | `TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation` | alter Key entschlüsselt nicht mehr, Plaintext-KEK unverändert | **bestanden** |
|
||||
| Tenant-KEK-Rotation eines Mandanten wirkt sich auf einen anderen Mandanten aus | `TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants` | Mandant B unberührt | **bestanden** |
|
||||
| Manipulierter, gespeicherter `wrapped_kek` (DB-Zugriff ohne Master-Key) wird beim Entschlüsseln akzeptiert | `TestPentest_KEK_TamperedWrappedKeyRejected` | `ErrUnwrapFailed` | **bestanden** |
|
||||
|
||||
Ergebnis: AES-256-GCM (authentifizierend) verhindert Akzeptanz manipulierter
|
||||
Chiffretexte; Master-Key-Rotation ändert ausschließlich die Verpackung, nie
|
||||
den Plaintext-Tenant-KEK; Tenant-KEK-Isolation (Modell C, Fortsetzung auf
|
||||
Schlüsselebene) hält auch unter gezielter Rotation eines einzelnen Mandanten.
|
||||
|
||||
## 2. Beim Merge/Pentest gefundene Fehler (nicht vorab bekannt)
|
||||
|
||||
### 2.1 `scripts/reset-test-env.sh`: fehlende Bereinigung von `tenant_keks` (Schweregrad **Hoch**, behoben)
|
||||
|
||||
Beim ersten vollen Testlauf über alle 29 Pakete schlug
|
||||
`TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation` mit 48
|
||||
unerwarteten `failedTenantIDs` in `RotateMasterKey` fehl. Ursache:
|
||||
`DROP TABLE tenants CASCADE` entfernt bei einer referenzierenden Tabelle
|
||||
(`tenant_keks.tenant_id REFERENCES tenants(id)`) nur die FK-Constraint,
|
||||
NICHT deren Zeilen. Da `reset-test-env.sh` `tenant_keks` (API-10-Migration
|
||||
`0006_tenant_keks.up.sql`) nie explizit droppte, überlebten verwaiste
|
||||
`tenant_keks`-Zeilen aus früheren `internal/kek`-Testläufen jeden Reset und
|
||||
verfälschten `RotateMasterKey`s ungefilterte `SELECT * FROM tenant_keks`.
|
||||
|
||||
Behoben: `reset-test-env.sh` droppt jetzt explizit alle Registry-Tabellen aus
|
||||
`migrations/*.up.sql` (`tenant_keks`, `rate_limit_counters`,
|
||||
`rate_limit_configs`, `policy_rule_changes`, `policy_rules`,
|
||||
`feature_flags`, `module_credentials`, `modules`), nicht nur die vier
|
||||
ursprünglich behandelten. Reiner Testinfrastruktur-Fix, keine
|
||||
Produktionsmigration verändert.
|
||||
|
||||
### 2.2 `internal/e2e`-Tests: `defer adminPool.Close()` lief vor `t.Cleanup`-Bereinigung (Schweregrad **Mittel**, behoben)
|
||||
|
||||
Voller Testlauf zeigte anschließend `internal/migrate`-Fehlschläge
|
||||
(„erwartet 3 Ergebnisse, habe 15" bzw. 12, mit Zeilen wie `e2e_tenant_a`).
|
||||
Ursache: `TestE2E_TenantOnboardingLoginFlow` und `TestE2E_CrossTenantIsolation`
|
||||
schlossen den `adminPool` per `defer adminPool.Close()`, ihre
|
||||
`t.Cleanup`-Funktion (welche die angelegten Testmandanten wieder löscht)
|
||||
lief aber – wie in Go üblich – ERST NACH allen `defer`-Aufrufen der
|
||||
Testfunktion, also gegen einen bereits geschlossenen Pool. Der Fehler wurde
|
||||
durch `_, _ = adminPool.Exec(...)` verschluckt; die Zeilen blieben dauerhaft
|
||||
in der geteilten `tenants`-Tabelle stehen und verfälschten
|
||||
`internal/migrate`s ungefilterte `registry.List()`-Abfrage im selben
|
||||
Prozess/derselben Datenbank.
|
||||
|
||||
Behoben: `adminPool.Close()` in beiden Tests über `t.Cleanup` statt `defer`
|
||||
registriert (vor der Lösch-Cleanup, sodass LIFO die richtige Reihenfolge
|
||||
erzwingt – Pool bleibt bis nach dem Löschen offen).
|
||||
|
||||
### 2.3 `internal/kek/kek_test.go`: derselbe defer/`t.Cleanup`-Fehler, zusätzlich fehlende Zeilen-Bereinigung (Schweregrad **Mittel**, behoben)
|
||||
|
||||
Dieselbe Fehlerklasse wie 2.2, zusätzlich verschärft: `createTenant` legte
|
||||
Zeilen mit `db_dsn='unused'` an, ohne JEMALS eine eigene Aufräumfunktion zu
|
||||
registrieren – `setupTest`s `cleanup := func(){ pool.Close() }` schloss nur
|
||||
den Pool, löschte aber nie die angelegten `tenants`/`tenant_keks`-Zeilen.
|
||||
|
||||
Behoben: `createTenant` registriert jetzt eine eigene `t.Cleanup`, die
|
||||
Zeile+abhängigen `tenant_keks`-Eintrag löscht; `setupTest` schließt den Pool
|
||||
über `t.Cleanup` (nicht mehr über den zurückgegebenen `cleanup()`, den
|
||||
Aufrufer per `defer` aufriefen) – damit läuft die Lösch-Cleanup (später
|
||||
registriert) vor dem Pool-Schließen (früher registriert).
|
||||
|
||||
### 2.4 `internal/migrate/migrate_test.go`: hartkodiertes Testschema ohne Lifecycle-Spalten (Schweregrad **Mittel**, behoben)
|
||||
|
||||
`setupOrchestratorTest` legte `tenants` über ein eigenes,
|
||||
hartkodiertes `CREATE TABLE IF NOT EXISTS` ohne die Spalten
|
||||
`previous_status`/`deletion_scheduled_at` an, die TEN-04
|
||||
(`internal/tenant/registry.go`, `GetBySlug`/`List`) inzwischen mitliest.
|
||||
Lief das Paket ISOLIERT (Tabelle wird von diesem Test selbst frisch
|
||||
angelegt), fiel der Fehler nicht auf; erst im vollen Testlauf – bei dem die
|
||||
Tabelle bereits über die echten Registry-Migrationen existiert – griff
|
||||
`CREATE TABLE IF NOT EXISTS` nicht mehr und der Fehler
|
||||
`column "previous_status" does not exist` trat zutage.
|
||||
|
||||
Behoben: fehlende Spalten im Testschema ergänzt (kein Produktionscode
|
||||
betroffen).
|
||||
|
||||
## 3. Build/Test-Ergebnis auf 192.168.1.131 (`/root/nexarch-code-qa04`)
|
||||
|
||||
```
|
||||
go mod tidy -> clean
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
go test ./... -p 1 -count=1 -> 29/29 Pakete ok, 0 Fehlschläge
|
||||
```
|
||||
|
||||
Enthält insbesondere `internal/pentest` (12 neue Angriffstests, alle
|
||||
bestanden) sowie die bereits bestehenden 124 Tests aus QA-02/QA-03 und alle
|
||||
Tests der 10 frisch gemergten Vorbedingungs-Branches.
|
||||
|
||||
## 4. Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle vier Akzeptanzkriterien durch gezielte, dokumentierte
|
||||
Angriffsversuche mit Ergebnisprotokoll erfüllt; alle drei Pflichtprüfungen
|
||||
durchgeführt und protokolliert (Abschnitte 1.2–1.4). Vier real gefundene
|
||||
Fehler (Abschnitt 2, Schweregrad Hoch/Mittel) behoben – ausschließlich
|
||||
Testinfrastruktur, kein Produktionscode verändert.
|
||||
@@ -25,7 +25,11 @@ func TestE2E_CrossTenantIsolation(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("admin pool: %v", err)
|
||||
}
|
||||
defer adminPool.Close()
|
||||
// Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
|
||||
// erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle
|
||||
// Cleanups hinweg), daher muss diese Registrierung vor der DELETE-
|
||||
// Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist.
|
||||
t.Cleanup(func() { adminPool.Close() })
|
||||
|
||||
applyRegistrySchema(t, ctx, adminPool, root)
|
||||
|
||||
|
||||
@@ -30,7 +30,11 @@ func TestE2E_TenantOnboardingLoginFlow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("admin pool: %v", err)
|
||||
}
|
||||
defer adminPool.Close()
|
||||
// Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
|
||||
// erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle
|
||||
// Cleanups hinweg), daher muss diese Registrierung vor der DELETE-
|
||||
// Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist.
|
||||
t.Cleanup(func() { adminPool.Close() })
|
||||
|
||||
applyRegistrySchema(t, ctx, adminPool, root)
|
||||
|
||||
|
||||
@@ -59,7 +59,14 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() { pool.Close() }
|
||||
// Als t.Cleanup statt nur per defer im Aufrufer zurueckgegeben: falls ein
|
||||
// Aufrufer "defer cleanup()" nutzt, liefe das Schliessen VOR den
|
||||
// t.Cleanup-Aufraeumfunktionen von createTenant (die den Pool noch
|
||||
// brauchen), da t.Cleanup grundsaetzlich erst nach allen defers der
|
||||
// Testfunktion ausgefuehrt wird. Registrierung hier stellt sicher, dass
|
||||
// das Schliessen selbst als letzte Cleanup-Funktion (LIFO) laeuft.
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
cleanup := func() {}
|
||||
return NewStore(pool), pool, cleanup
|
||||
}
|
||||
|
||||
@@ -81,6 +88,15 @@ func createTenant(t *testing.T, pool *pgxpool.Pool, slug string) string {
|
||||
if err != nil {
|
||||
t.Fatalf("tenant anlegen: %v", err)
|
||||
}
|
||||
// Aufraeumen nach Testende - ohne dies bleiben Registry-Zeilen mit
|
||||
// db_dsn='unused' dauerhaft in der geteilten tenants-Tabelle stehen und
|
||||
// verfaelschen registry.List()-Ergebnisse in anderen Paketen (z.B.
|
||||
// internal/migrate), die im selben Testlauf gegen dieselbe physische
|
||||
// Postgres-Instanz laufen.
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,9 @@ func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tena
|
||||
db_name TEXT NOT NULL UNIQUE,
|
||||
db_dsn TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
previous_status TEXT,
|
||||
deletion_scheduled_at TIMESTAMPTZ
|
||||
)`); err != nil {
|
||||
t.Fatalf("registry-schema: %v", err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,10 +13,25 @@ ROLE="nexarch_test"
|
||||
|
||||
export PGPASSWORD="$PASS"
|
||||
|
||||
# Alle Registry-Tabellen, die von migrations/*.up.sql angelegt werden -
|
||||
# inklusive der Tabellen mit FK-Bezug auf tenants (tenant_keks). "DROP TABLE
|
||||
# tenants CASCADE" entfernt lediglich die FK-CONSTRAINT auf einer
|
||||
# referenzierenden Tabelle, NICHT deren Zeilen - ohne den expliziten Drop
|
||||
# hier ueberleben verwaiste tenant_keks-Zeilen jeden Reset und verfaelschen
|
||||
# spaetere Testlaeufe (QA-04-Befund: internal/pentest kollidierte mit
|
||||
# Altzeilen aus fruehreren internal/kek-Testlaeufen).
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_keks CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings_history CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS superadmins CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS rate_limit_counters CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS rate_limit_configs CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS policy_rule_changes CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS policy_rules CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS feature_flags CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS module_credentials CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS modules CASCADE;"
|
||||
|
||||
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||
for db in $dbs; do
|
||||
|
||||
Reference in New Issue
Block a user