munin/internal/auth/auth_test.go

544 lines
17 KiB
Go

package auth
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
var testSigner = NewSigner([]byte("ein-test-secret-das-lang-genug-ist-fuer-tests"))
// ── Sitzungen ────────────────────────────────────────────────────────────────
func TestSessionRoundTrip(t *testing.T) {
cookie := testSigner.SignSession("key:abc123")
subject, ok := testSigner.VerifySession(cookie)
if !ok {
t.Fatal("frisch signierte Sitzung wurde als ungültig abgelehnt")
}
if subject != "key:abc123" {
t.Errorf("subject = %q, erwartet %q", subject, "key:abc123")
}
}
// TestSessionRejectsWrongSecret ist der Kern des Verfahrens: ohne das richtige
// Geheimnis darf sich niemand eine Sitzung ausstellen können.
func TestSessionRejectsWrongSecret(t *testing.T) {
cookie := testSigner.SignSession(SubjectPlatformAdmin)
fremder := NewSigner([]byte("ein-voellig-anderes-secret"))
if _, ok := fremder.VerifySession(cookie); ok {
t.Error("Sitzung wurde mit falschem Secret akzeptiert")
}
}
// TestSessionRejectsTampering prüft den naheliegendsten Angriff: den Cookie
// auslesen (er ist nur base64, nicht verschlüsselt), "key:xyz" durch "platform"
// ersetzen und wieder einsetzen.
func TestSessionRejectsTampering(t *testing.T) {
cookie := testSigner.SignSession("key:abc123")
_, signature, _ := strings.Cut(cookie, ".")
gefaelscht := base64.RawURLEncoding.EncodeToString(
fmt.Appendf(nil, "%s|%d", SubjectPlatformAdmin, time.Now().Add(time.Hour).Unix()),
)
if _, ok := testSigner.VerifySession(gefaelscht + "." + signature); ok {
t.Error("manipulierter Cookie wurde akzeptiert — die Signatur greift nicht")
}
}
// TestSessionRejectsExpired baut die Nutzlast von Hand mit einem Zeitstempel in
// der Vergangenheit und signiert sie korrekt — so wird wirklich nur die
// Ablaufprüfung getestet, ohne eine Stunde zu warten.
func TestSessionRejectsExpired(t *testing.T) {
payload := fmt.Sprintf("key:abc123|%d", time.Now().Add(-time.Minute).Unix())
cookie := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + testSigner.sign(payload)
if _, ok := testSigner.VerifySession(cookie); ok {
t.Error("abgelaufene Sitzung wurde akzeptiert")
}
}
// TestSessionRejectsMalformed wirft der Prüfung kaputte Eingaben vor die Füße.
// Wichtig ist vor allem, dass nichts davon zu einem Absturz führt.
func TestSessionRejectsMalformed(t *testing.T) {
cases := []string{
"",
"kein-punkt-drin",
".",
"nicht-base64!!!.abcdef",
base64.RawURLEncoding.EncodeToString([]byte("ohne-trenner")) + ".abcdef",
base64.RawURLEncoding.EncodeToString([]byte("key:a|keine-zahl")) + ".abcdef",
}
for _, cookie := range cases {
if _, ok := testSigner.VerifySession(cookie); ok {
t.Errorf("kaputter Cookie wurde akzeptiert: %q", cookie)
}
}
}
func TestKeyIDFromSubject(t *testing.T) {
if id, ok := KeyIDFromSubject("key:abc123"); !ok || id != "abc123" {
t.Errorf("KeyIDFromSubject = (%q, %v), erwartet (\"abc123\", true)", id, ok)
}
if _, ok := KeyIDFromSubject(SubjectPlatformAdmin); ok {
t.Error("Plattform-Admin hat fälschlich eine Key-ID geliefert")
}
}
// ── CSRF ─────────────────────────────────────────────────────────────────────
func TestCSRFToken(t *testing.T) {
token := testSigner.CSRFToken("key:abc123")
if !testSigner.ValidCSRFToken(token, "key:abc123") {
t.Error("gültiges CSRF-Token wurde abgelehnt")
}
if testSigner.ValidCSRFToken(token, "key:anderer") {
t.Error("CSRF-Token einer Sitzung galt auch für eine andere")
}
if testSigner.ValidCSRFToken("", "key:abc123") {
t.Error("leeres CSRF-Token wurde akzeptiert")
}
}
// TestTokenDomainSeparation prüft, dass ein CSRF-Token (das offen im HTML steht)
// nicht als Session-Signatur durchgeht.
func TestTokenDomainSeparation(t *testing.T) {
subject := "key:abc123"
csrf := testSigner.CSRFToken(subject)
if _, ok := testSigner.VerifySession(
base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." + csrf,
); ok {
t.Error("ein CSRF-Token wurde als gültige Session-Signatur akzeptiert")
}
}
// ── API-Keys ─────────────────────────────────────────────────────────────────
func TestGenerateKeyFormat(t *testing.T) {
key, err := GenerateKey()
if err != nil {
t.Fatalf("Key konnte nicht erzeugt werden: %v", err)
}
if !strings.HasPrefix(key, KeyPrefix) {
t.Errorf("Key %q hat nicht das Präfix %q", key, KeyPrefix)
}
if !LooksLikeKey(key) {
t.Errorf("selbst erzeugter Key besteht die Formprüfung nicht: %q", key)
}
}
// TestGenerateKeyIsUnique prüft, dass wirklich Zufall im Spiel ist.
func TestGenerateKeyIsUnique(t *testing.T) {
gesehen := make(map[string]bool)
for i := 0; i < 100; i++ {
key, err := GenerateKey()
if err != nil {
t.Fatal(err)
}
if gesehen[key] {
t.Fatal("zweimal derselbe Key erzeugt")
}
gesehen[key] = true
}
}
func TestHashKey(t *testing.T) {
key := "wk_" + strings.Repeat("a", 64)
if HashKey(key) != HashKey(key) {
t.Error("derselbe Key ergibt unterschiedliche Hashes")
}
if HashKey(key) == HashKey(key+"x") {
t.Error("unterschiedliche Keys ergeben denselben Hash")
}
// Der Hash darf den Key nicht enthalten — sonst wäre die Datenbank so gut
// wie eine Klartext-Ablage.
if strings.Contains(HashKey(key), key) {
t.Error("der Hash enthält den Key im Klartext")
}
}
func TestLooksLikeKey(t *testing.T) {
gueltig := KeyPrefix + strings.Repeat("ab", 32)
cases := map[string]bool{
gueltig: true,
"": false,
"wk_": false,
strings.Repeat("ab", 32): false, // ohne Präfix
KeyPrefix + "zz": false, // zu kurz
KeyPrefix + strings.Repeat("g", 64): false, // kein Hex
}
for key, erwartet := range cases {
if got := LooksLikeKey(key); got != erwartet {
t.Errorf("LooksLikeKey(%.20q…) = %v, erwartet %v", key, got, erwartet)
}
}
}
// TestWorkspaceAdminTokenIsNoKey: Die beiden Arten dürfen sich nie
// überschneiden. Ginge ein Workspace-Admin-Token als API-Key durch, stünde
// ihm die MCP-Schnittstelle offen — und umgekehrt käme ein Projekt-Key an
// die Workspace-Verwaltung.
func TestWorkspaceAdminTokenIsNoKey(t *testing.T) {
token, err := GenerateWorkspaceAdminToken()
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(token, WorkspaceAdminPrefix) || !LooksLikeWorkspaceAdminToken(token) {
t.Errorf("selbst erzeugtes Token besteht die Formprüfung nicht: %q", token)
}
if LooksLikeKey(token) {
t.Error("ein Workspace-Admin-Token geht als API-Key durch")
}
key, err := GenerateKey()
if err != nil {
t.Fatal(err)
}
if LooksLikeWorkspaceAdminToken(key) {
t.Error("ein API-Key geht als Workspace-Admin-Token durch")
}
}
// ── Rollen ───────────────────────────────────────────────────────────────────
func TestRolePermissions(t *testing.T) {
cases := []struct {
role Role
valid bool
write bool
manageKeys bool
}{
{RoleRead, true, false, false},
{RoleWrite, true, true, false},
{RoleAdmin, true, true, true},
{Role("unsinn"), false, false, false},
{Role(""), false, false, false},
}
for _, tc := range cases {
t.Run(string(tc.role), func(t *testing.T) {
if tc.role.Valid() != tc.valid {
t.Errorf("Valid() = %v, erwartet %v", tc.role.Valid(), tc.valid)
}
if tc.role.CanWrite() != tc.write {
t.Errorf("CanWrite() = %v, erwartet %v", tc.role.CanWrite(), tc.write)
}
if tc.role.CanManageKeys() != tc.manageKeys {
t.Errorf("CanManageKeys() = %v, erwartet %v", tc.role.CanManageKeys(), tc.manageKeys)
}
})
}
}
// ── Cookie-Policy ────────────────────────────────────────────────────────────
func TestCookiePolicySecure(t *testing.T) {
cases := []struct {
name string
env string
host string
erwartet bool
}{
{"Standard, echte Domain", "", "munin.intern", true},
{"Standard, localhost", "", "localhost:8080", false},
{"Standard, 127.0.0.1", "", "127.0.0.1:8080", false},
{"erzwungen an, localhost", "true", "localhost:8080", true},
{"erzwungen aus, echte Domain", "false", "munin.intern", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
policy := NewCookiePolicy(tc.env)
r := httptest.NewRequest(http.MethodGet, "http://"+tc.host+"/", nil)
r.Host = tc.host
if got := policy.Secure(r); got != tc.erwartet {
t.Errorf("Secure = %v, erwartet %v", got, tc.erwartet)
}
})
}
}
func TestCookiePolicyApplySetsFlags(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://munin.intern/", nil)
r.Host = "munin.intern"
cookie := &http.Cookie{Name: "test", Value: "wert"}
NewCookiePolicy("").Apply(cookie, r)
if !cookie.HttpOnly {
t.Error("HttpOnly fehlt")
}
if !cookie.Secure {
t.Error("Secure fehlt")
}
if cookie.SameSite != http.SameSiteLaxMode {
t.Errorf("SameSite = %v, erwartet Lax", cookie.SameSite)
}
if cookie.Path != "/" {
t.Errorf("Path = %q, erwartet \"/\"", cookie.Path)
}
}
// ── Rate-Limiting ────────────────────────────────────────────────────────────
func TestRateLimiterBlocksAfterMax(t *testing.T) {
limiter := NewRateLimiter()
const ip = "203.0.113.7"
for i := 0; i < rateLimitMax; i++ {
if allowed, _ := limiter.Allow(ip); !allowed {
t.Fatalf("bereits nach %d Versuchen gesperrt — zu früh", i)
}
limiter.RecordFailure(ip)
}
allowed, retryAfter := limiter.Allow(ip)
if allowed {
t.Errorf("nach %d Fehlversuchen immer noch erlaubt", rateLimitMax)
}
if retryAfter <= 0 {
t.Errorf("retryAfter = %v, erwartet einen positiven Wert", retryAfter)
}
}
func TestRateLimiterIsPerIP(t *testing.T) {
limiter := NewRateLimiter()
for i := 0; i < rateLimitMax; i++ {
limiter.RecordFailure("203.0.113.7")
}
if allowed, _ := limiter.Allow("198.51.100.4"); !allowed {
t.Error("eine andere IP wurde mitgesperrt")
}
}
func TestRateLimiterResetClearsCounter(t *testing.T) {
limiter := NewRateLimiter()
const ip = "203.0.113.7"
for i := 0; i < rateLimitMax; i++ {
limiter.RecordFailure(ip)
}
limiter.Reset(ip)
if allowed, _ := limiter.Allow(ip); !allowed {
t.Error("nach Reset immer noch gesperrt")
}
}
// ── Schreibbudget ────────────────────────────────────────────────────────────
func TestWriteBudgetAllowsUpToLimit(t *testing.T) {
budget := NewWriteBudget(3, time.Hour)
const projekt = "p1"
for i := 1; i <= 3; i++ {
ok, _, ueber := budget.Consume(projekt, 0)
if !ok {
t.Fatalf("Aufruf %d von 3 abgelehnt", i)
}
if ueber != 0 {
t.Errorf("Aufruf %d: ueberschreitung = %d, erwartet 0", i, ueber)
}
}
ok, retryAfter, ueber := budget.Consume(projekt, 0)
if ok {
t.Error("der vierte Aufruf wurde angenommen")
}
if retryAfter <= 0 {
t.Errorf("retryAfter = %v, erwartet einen positiven Wert", retryAfter)
}
// Genau 1 beim ersten abgelehnten Aufruf — daran hängt, dass die
// Überschreitung nur einmal je Fenster protokolliert wird.
if ueber != 1 {
t.Errorf("ueberschreitung = %d, erwartet 1 beim ersten abgelehnten Aufruf", ueber)
}
if _, _, ueber := budget.Consume(projekt, 0); ueber != 2 {
t.Errorf("ueberschreitung = %d, erwartet 2 beim zweiten abgelehnten Aufruf", ueber)
}
}
func TestWriteBudgetIsPerProject(t *testing.T) {
budget := NewWriteBudget(1, time.Hour)
budget.Consume("p1", 0)
if ok, _, _ := budget.Consume("p1", 0); ok {
t.Fatal("Vorbedingung verfehlt: p1 ist noch nicht am Limit")
}
if ok, _, _ := budget.Consume("p2", 0); !ok {
t.Error("ein anderes Projekt wurde mitgesperrt")
}
}
// TestWriteBudgetProjectLimitBeatsDefault prüft die Auflösung der Obergrenze:
// erst die eigene Angabe des Projekts, sonst der Standard.
func TestWriteBudgetProjectLimitBeatsDefault(t *testing.T) {
budget := NewWriteBudget(100, time.Hour)
budget.Consume("eng", 1)
if ok, _, _ := budget.Consume("eng", 1); ok {
t.Error("die eigene Obergrenze (1) wurde nicht angewandt")
}
// 0 heißt "keine eigene Angabe" — dann gilt der großzügige Standard.
if ok, _, _ := budget.Consume("standard", 0); !ok {
t.Error("bei projektLimit 0 wurde nicht der Standard verwendet")
}
}
// TestWriteBudgetZeroDefaultDisables prüft die Notbremse: WRITE_LIMIT_HOURLY=0
// schaltet die Begrenzung vollständig ab.
func TestWriteBudgetZeroDefaultDisables(t *testing.T) {
budget := NewWriteBudget(0, time.Hour)
for i := 0; i < 50; i++ {
if ok, _, _ := budget.Consume("p1", 0); !ok {
t.Fatalf("Aufruf %d abgelehnt, obwohl die Begrenzung aus ist", i+1)
}
}
}
func TestWriteBudgetRemainingCountsDown(t *testing.T) {
budget := NewWriteBudget(5, time.Hour)
if frei, grenze := budget.Remaining("p1", 0); frei != 5 || grenze != 5 {
t.Errorf("frei/grenze = %d/%d, erwartet 5/5 vor dem ersten Aufruf", frei, grenze)
}
budget.Consume("p1", 0)
budget.Consume("p1", 0)
if frei, _ := budget.Remaining("p1", 0); frei != 3 {
t.Errorf("frei = %d, erwartet 3 nach zwei Aufrufen", frei)
}
// Remaining selbst darf nichts verbrauchen.
if frei, _ := budget.Remaining("p1", 0); frei != 3 {
t.Errorf("frei = %d — Remaining hat mitgezählt", frei)
}
}
// TestWriteBudgetWindowExpires prüft, dass ein abgelaufenes Fenster den Zähler
// zurücksetzt statt ihn fortzuschreiben.
func TestWriteBudgetWindowExpires(t *testing.T) {
budget := NewWriteBudget(1, time.Millisecond)
budget.Consume("p1", 0)
if ok, _, _ := budget.Consume("p1", 0); ok {
t.Fatal("Vorbedingung verfehlt: p1 ist noch nicht am Limit")
}
time.Sleep(5 * time.Millisecond)
if ok, _, _ := budget.Consume("p1", 0); !ok {
t.Error("nach Ablauf des Fensters immer noch gesperrt")
}
}
// ── Client-IP / Trusted Proxy ────────────────────────────────────────────────
func TestClientIPIgnoresForwardedByDefault(t *testing.T) {
if err := SetTrustedProxies(""); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.0.0.5:1234"
r.Header.Set("X-Forwarded-For", "1.2.3.4")
// Ohne TRUSTED_PROXY ist der Header frei erfunden und muss ignoriert werden —
// sonst könnte sich jeder Angreifer bei jedem Versuch eine neue IP geben und
// das Rate-Limiting damit aushebeln.
if got := ClientIP(r); got != "10.0.0.5" {
t.Errorf("ClientIP = %q, erwartet %q", got, "10.0.0.5")
}
}
func TestClientIPUsesForwardedFromTrustedProxy(t *testing.T) {
if err := SetTrustedProxies("10.0.0.0/8"); err != nil {
t.Fatal(err)
}
defer SetTrustedProxies("")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.0.0.5:1234"
r.Header.Set("X-Forwarded-For", "1.2.3.4")
if got := ClientIP(r); got != "1.2.3.4" {
t.Errorf("ClientIP = %q, erwartet %q", got, "1.2.3.4")
}
}
// TestClientIPTakesRightmostUntrusted prüft den Fall, dass der Client selbst
// schon einen X-Forwarded-For mitschickt und der Proxy nur anhängt.
func TestClientIPTakesRightmostUntrusted(t *testing.T) {
if err := SetTrustedProxies("10.0.0.0/8"); err != nil {
t.Fatal(err)
}
defer SetTrustedProxies("")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.0.0.5:1234"
// "9.9.9.9" ist die Erfindung des Angreifers, "1.2.3.4" seine echte Adresse.
r.Header.Set("X-Forwarded-For", "9.9.9.9, 1.2.3.4")
if got := ClientIP(r); got != "1.2.3.4" {
t.Errorf("ClientIP = %q, erwartet %q — die erfundene IP wurde übernommen", got, "1.2.3.4")
}
}
func TestSetTrustedProxiesRejectsGarbage(t *testing.T) {
if err := SetTrustedProxies("keine-ip"); err == nil {
t.Error("unsinnige Angabe wurde akzeptiert")
}
SetTrustedProxies("")
}
func TestPlatformSubjectHinUndZurueck(t *testing.T) {
faelle := []struct{ fingerabdruck, workspaceID string }{
{"abc123", ""},
{"abc123", "ws-1"},
}
for _, f := range faelle {
subject := PlatformSubject(f.fingerabdruck, f.workspaceID)
fp, ws, ok := ParsePlatformSubject(subject)
if !ok || fp != f.fingerabdruck || ws != f.workspaceID {
t.Errorf("%q → (%q, %q, %v), erwartet (%q, %q, true)", subject, fp, ws, ok, f.fingerabdruck, f.workspaceID)
}
}
// Alte Formen ohne Fingerabdruck und fremde Subjects sind keine.
for _, subject := range []string{"platform", "platform:ws-1", "platform/", "platform/:ws-1", "key:ws:1"} {
if _, _, ok := ParsePlatformSubject(subject); ok {
t.Errorf("%q wurde als Subject des Plattform-Admins erkannt", subject)
}
}
}
func TestFingerprintHaengtVomGeheimnisAb(t *testing.T) {
a := NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert")
b := NewSigner([]byte("geheimnis-zwei-geheimnis-zwei-geheimnis")).Fingerprint("wert")
if a == b {
t.Error("gleicher Fingerabdruck trotz anderem SESSION_SECRET")
}
if a != NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert") {
t.Error("Fingerabdruck ist nicht stabil")
}
}