mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-21 11:15:36 +00:00
1118e09fcd
clearSession invalidated the session-store record and zeroed both session/CSRF cookies on the client side, but never deleted the matching entry from the CSRF store. The orphaned record stuck around for up to 4 hours (CSRF TTL) after every logout. Not exploitable — the session itself was gone, so the stale CSRF entry could not be used — but the asymmetry was real: InvalidateUserSessions (password change) and InvalidateOldSessionFromRequest (re-login) both already called DeleteCSRFToken, and logout was the missing path. Add the same call in clearSession alongside InvalidateSession. Regression test TestClearSession_DeletesServerSideCSRFToken sets up a real session + CSRF token pair, calls clearSession with a request carrying the session cookie, and asserts the CSRF token no longer validates against the session ID afterward.
331 lines
12 KiB
Go
331 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
)
|
|
|
|
func newTestSAMLService(t *testing.T, providerID string, metadataXML string) *SAMLService {
|
|
t.Helper()
|
|
service, err := NewSAMLService(context.Background(), providerID, &config.SAMLProviderConfig{
|
|
IDPMetadataXML: metadataXML,
|
|
}, "https://pulse.example.com")
|
|
if err != nil {
|
|
t.Fatalf("NewSAMLService: %v", err)
|
|
}
|
|
return service
|
|
}
|
|
|
|
func TestHandleSAMLACS_ProcessResponseError(t *testing.T) {
|
|
router := newSAMLRouter(t, testSAMLProvider("okta", true))
|
|
router.samlManager.services["okta"] = &SAMLService{}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/saml/okta/acs", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.handleSAMLACS(rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("expected status %d, got %d", http.StatusFound, rec.Code)
|
|
}
|
|
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "saml_error=saml_validation_failed") {
|
|
t.Fatalf("expected validation failed redirect, got %q", loc)
|
|
}
|
|
}
|
|
|
|
func TestHandleSAMLMetadata_InvalidMethod(t *testing.T) {
|
|
router := newSAMLRouter(t, testSAMLProvider("okta", true))
|
|
req := httptest.NewRequest(http.MethodPost, "/api/saml/okta/metadata", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.handleSAMLMetadata(rec, req)
|
|
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleSAMLMetadata_InvalidProviderID(t *testing.T) {
|
|
router := newSAMLRouter(t, testSAMLProvider("okta", true))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/saml/invalid$id/metadata", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.handleSAMLMetadata(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetSAMLSessionInfo_NoCookie(t *testing.T) {
|
|
router := &Router{}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
|
|
if info := router.getSAMLSessionInfo(req); info != nil {
|
|
t.Fatalf("expected nil session info without cookie")
|
|
}
|
|
}
|
|
|
|
func TestGetSAMLSessionInfo_ReturnsInfo(t *testing.T) {
|
|
InitSessionStore(t.TempDir())
|
|
|
|
token := generateSessionToken()
|
|
GetSessionStore().CreateSAMLSession(token, time.Hour, "agent", "127.0.0.1", "user", &SAMLTokenInfo{
|
|
ProviderID: "okta",
|
|
NameID: "name-id",
|
|
SessionIndex: "sess-1",
|
|
})
|
|
|
|
router := &Router{}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: token})
|
|
|
|
info := router.getSAMLSessionInfo(req)
|
|
if info == nil {
|
|
t.Fatalf("expected session info")
|
|
}
|
|
if info.ProviderID != "okta" || info.NameID != "name-id" || info.SessionIndex != "sess-1" {
|
|
t.Fatalf("unexpected session info: %#v", info)
|
|
}
|
|
}
|
|
|
|
func TestClearSession(t *testing.T) {
|
|
router := &Router{}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.clearSession(rec, req)
|
|
|
|
cookies := rec.Result().Cookies()
|
|
// 3 cookies: pulse_session (unprefixed), __Host-pulse_session (prefixed), pulse_csrf
|
|
if len(cookies) != 3 {
|
|
t.Fatalf("expected 3 cookies (pulse_session + __Host-pulse_session + pulse_csrf), got %d", len(cookies))
|
|
}
|
|
var sessionCookie *http.Cookie
|
|
for _, c := range cookies {
|
|
if c.Name == "pulse_session" {
|
|
sessionCookie = c
|
|
break
|
|
}
|
|
}
|
|
if sessionCookie == nil {
|
|
t.Fatalf("expected pulse_session cookie to be cleared")
|
|
}
|
|
if sessionCookie.MaxAge != -1 {
|
|
t.Fatalf("expected MaxAge -1, got %d", sessionCookie.MaxAge)
|
|
}
|
|
if !sessionCookie.HttpOnly {
|
|
t.Fatalf("expected HttpOnly cookie")
|
|
}
|
|
}
|
|
|
|
// TestClearSession_DeletesServerSideCSRFToken regresses the missing CSRF-
|
|
// cleanup-on-logout symmetry: clearSession invalidated the session-store
|
|
// record and zeroed the cookies, but never deleted the CSRF entry bound to
|
|
// the session. The entry stuck around in the in-memory + on-disk CSRF store
|
|
// for up to 4 hours after logout — not exploitable (the session was gone),
|
|
// but the asymmetry was a real bug that accumulated dead records, and
|
|
// password-change / re-login paths already delete CSRF state on cleanup.
|
|
func TestClearSession_DeletesServerSideCSRFToken(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
InitSessionStore(dataDir)
|
|
InitCSRFStore(dataDir)
|
|
|
|
sessionToken := "test-clear-session-csrf-token-1234567890"
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "test-ua", "127.0.0.1", "testuser")
|
|
|
|
csrfToken := GetCSRFStore().GenerateCSRFToken(sessionToken)
|
|
if csrfToken == "" {
|
|
t.Fatal("expected non-empty CSRF token from GenerateCSRFToken")
|
|
}
|
|
if !GetCSRFStore().ValidateCSRFToken(sessionToken, csrfToken) {
|
|
t.Fatal("CSRF token should validate immediately after issuance")
|
|
}
|
|
|
|
router := &Router{}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/logout", nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.clearSession(rec, req)
|
|
|
|
if GetCSRFStore().ValidateCSRFToken(sessionToken, csrfToken) {
|
|
t.Fatal("CSRF token must be invalidated when clearSession runs against the bound session")
|
|
}
|
|
}
|
|
|
|
// TestHandleSAMLSLO_RejectsEmptyPayload regresses the unauthenticated force-
|
|
// logout DoS that previously existed on /api/saml/{id}/slo. A bare GET or
|
|
// POST with no SAMLResponse/SAMLRequest payload must not redirect to the
|
|
// "logout=success" page and must not touch session state.
|
|
func TestHandleSAMLSLO_RejectsEmptyPayload(t *testing.T) {
|
|
router := &Router{samlManager: NewSAMLServiceManager("https://pulse.example.com")}
|
|
metadataXML := `<?xml version="1.0"?>
|
|
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="idp">
|
|
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.com/sso"/>
|
|
</IDPSSODescriptor>
|
|
</EntityDescriptor>`
|
|
router.samlManager.services["okta"] = newTestSAMLService(t, "okta", metadataXML)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/saml/okta/slo", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.handleSAMLSLO(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 on empty SLO payload, got %d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "" {
|
|
t.Fatalf("must not redirect on empty SLO payload, got %q", loc)
|
|
}
|
|
}
|
|
|
|
// TestHandleSAMLSLO_RejectsInvalidLogoutResponse confirms a SAMLResponse
|
|
// parameter with a value the IdP cert cannot validate (gibberish) is
|
|
// rejected with 403, again without clearing session.
|
|
func TestHandleSAMLSLO_RejectsInvalidLogoutResponse(t *testing.T) {
|
|
router := &Router{samlManager: NewSAMLServiceManager("https://pulse.example.com")}
|
|
metadataXML := `<?xml version="1.0"?>
|
|
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="idp">
|
|
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.com/sso"/>
|
|
</IDPSSODescriptor>
|
|
</EntityDescriptor>`
|
|
router.samlManager.services["okta"] = newTestSAMLService(t, "okta", metadataXML)
|
|
|
|
// A garbage SAMLResponse value: not real base64-encoded XML, no signature.
|
|
req := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/api/saml/okta/slo?SAMLResponse=not-a-real-response",
|
|
nil,
|
|
)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.handleSAMLSLO(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 on invalid LogoutResponse, got %d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "" {
|
|
t.Fatalf("must not redirect on invalid LogoutResponse, got %q", loc)
|
|
}
|
|
}
|
|
|
|
// TestHandleSAMLSLO_RejectsIDPInitiatedLogoutRequest documents the explicit
|
|
// rejection of IdP-initiated SLO until proper request-binding is wired up.
|
|
// Today an unvalidated LogoutRequest must not be treated as a force-logout
|
|
// signal.
|
|
func TestHandleSAMLSLO_RejectsIDPInitiatedLogoutRequest(t *testing.T) {
|
|
router := &Router{samlManager: NewSAMLServiceManager("https://pulse.example.com")}
|
|
metadataXML := `<?xml version="1.0"?>
|
|
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="idp">
|
|
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.com/sso"/>
|
|
</IDPSSODescriptor>
|
|
</EntityDescriptor>`
|
|
router.samlManager.services["okta"] = newTestSAMLService(t, "okta", metadataXML)
|
|
|
|
req := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/api/saml/okta/slo?SAMLRequest=anything",
|
|
nil,
|
|
)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.handleSAMLSLO(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 on IdP-initiated SLO, got %d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleSAMLLogout_SLOUnavailable(t *testing.T) {
|
|
InitSessionStore(t.TempDir())
|
|
|
|
router := &Router{samlManager: NewSAMLServiceManager("https://pulse.example.com")}
|
|
metadataXML := `<?xml version="1.0"?>
|
|
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="idp">
|
|
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.com/sso"/>
|
|
</IDPSSODescriptor>
|
|
</EntityDescriptor>`
|
|
router.samlManager.services["okta"] = newTestSAMLService(t, "okta", metadataXML)
|
|
|
|
token := generateSessionToken()
|
|
GetSessionStore().CreateSAMLSession(token, time.Hour, "agent", "127.0.0.1", "user", &SAMLTokenInfo{
|
|
ProviderID: "okta",
|
|
NameID: "name-id",
|
|
SessionIndex: "sess-1",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/saml/okta/logout", nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: token})
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.handleSAMLLogout(rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("expected status %d, got %d", http.StatusFound, rec.Code)
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "/?logout=success" {
|
|
t.Fatalf("unexpected redirect location %q", loc)
|
|
}
|
|
}
|
|
|
|
func TestHandleSAMLLogout_SLOSuccess(t *testing.T) {
|
|
InitSessionStore(t.TempDir())
|
|
|
|
router := &Router{samlManager: NewSAMLServiceManager("https://pulse.example.com")}
|
|
metadataXML := `<?xml version="1.0"?>
|
|
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="idp">
|
|
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.com/sso"/>
|
|
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.com/slo"/>
|
|
</IDPSSODescriptor>
|
|
</EntityDescriptor>`
|
|
router.samlManager.services["okta"] = newTestSAMLService(t, "okta", metadataXML)
|
|
|
|
token := generateSessionToken()
|
|
GetSessionStore().CreateSAMLSession(token, time.Hour, "agent", "127.0.0.1", "user", &SAMLTokenInfo{
|
|
ProviderID: "okta",
|
|
NameID: "name-id",
|
|
SessionIndex: "sess-1",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/saml/okta/logout", nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: token})
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.handleSAMLLogout(rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("expected status %d, got %d", http.StatusFound, rec.Code)
|
|
}
|
|
loc := rec.Header().Get("Location")
|
|
if !strings.Contains(loc, "https://idp.example.com/slo") || !strings.Contains(loc, "SAMLRequest=") {
|
|
t.Fatalf("unexpected SLO redirect location %q", loc)
|
|
}
|
|
}
|
|
|
|
func TestExtractSAMLProviderID(t *testing.T) {
|
|
if got := extractSAMLProviderID("/api/saml/okta/login", "login"); got != "okta" {
|
|
t.Fatalf("expected okta, got %q", got)
|
|
}
|
|
if got := extractSAMLProviderID("/api/saml/okta/logout", "login"); got != "" {
|
|
t.Fatalf("expected empty provider, got %q", got)
|
|
}
|
|
if got := extractSAMLProviderID("/api/saml/okta/login/extra", "login"); got != "okta" {
|
|
t.Fatalf("expected okta for extra path, got %q", got)
|
|
}
|
|
if got := extractSAMLProviderID("/api/other/okta/login", "login"); got != "" {
|
|
t.Fatalf("expected empty provider for non-saml path, got %q", got)
|
|
}
|
|
}
|