R67: apply allowedRolesAndGroups gate to SSE/WS bypass

This commit is contained in:
2026-08-13 16:56:15 +01:00
parent fe5f517df5
commit 2941260a81
3 changed files with 261 additions and 112 deletions
+215 -101
View File
@@ -1,6 +1,8 @@
package traefikoidc
import (
"crypto/rand"
"crypto/rsa"
"net/http"
"net/http/httptest"
"testing"
@@ -56,14 +58,14 @@ func TestServeHTTP_ExcludedURLs(t *testing.T) {
})
oidc := &TraefikOidc{
excludedURLs: tt.excludedURLs,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
excludedURLs: tt.excludedURLs,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", // Required for initialization check
issuerURL: "https://provider.example.com", // Required for initialization check
}
close(oidc.initComplete)
@@ -88,13 +90,13 @@ func TestServeHTTP_EventStream(t *testing.T) {
newOidc := func(next http.Handler) *TraefikOidc {
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
issuerURL: "https://provider.example.com",
}
close(oidc.initComplete)
return oidc
@@ -171,13 +173,13 @@ func TestServeHTTP_WebSocketUpgrade(t *testing.T) {
newOidc := func(next http.Handler) *TraefikOidc {
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
issuerURL: "https://provider.example.com",
}
close(oidc.initComplete)
return oidc
@@ -269,10 +271,10 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
shortTimeout := 100 * time.Millisecond
oidc := &TraefikOidc{
logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close this to simulate timeout
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close this to simulate timeout
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
}
@@ -304,15 +306,15 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
t.Run("successful initialization", func(t *testing.T) {
oidc := &TraefikOidc{
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
}
// Close init channel to signal completion
@@ -334,19 +336,19 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
func TestServeHTTP_CallbackAndLogout(t *testing.T) {
t.Run("callback path triggers callback handler", func(t *testing.T) {
oidc := &TraefikOidc{
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
tokenURL: "https://provider.example.com/token",
clientID: "test-client",
audience: "test-client",
clientSecret: "test-secret",
tokenHTTPClient: http.DefaultClient,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
tokenURL: "https://provider.example.com/token",
clientID: "test-client",
audience: "test-client",
clientSecret: "test-secret",
tokenHTTPClient: http.DefaultClient,
}
close(oidc.initComplete)
@@ -364,16 +366,16 @@ func TestServeHTTP_CallbackAndLogout(t *testing.T) {
t.Run("logout path triggers logout handler", func(t *testing.T) {
oidc := &TraefikOidc{
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
endSessionURL: "https://provider.example.com/logout",
postLogoutRedirectURI: "https://example.com",
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
endSessionURL: "https://provider.example.com/logout",
postLogoutRedirectURI: "https://example.com",
}
close(oidc.initComplete)
@@ -736,14 +738,14 @@ func TestMinimalHeaders(t *testing.T) {
sessionManager := createTestSessionManager(t)
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
minimalHeaders: tt.minimalHeaders,
issuerURL: "https://provider.example.com",
minimalHeaders: tt.minimalHeaders,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
return map[string]interface{}{
"email": "[email protected]",
@@ -813,14 +815,14 @@ func TestMinimalHeaders_TokenHeaderNotSet(t *testing.T) {
sessionManager := createTestSessionManager(t)
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
minimalHeaders: true, // Enable minimal headers
issuerURL: "https://provider.example.com",
minimalHeaders: true, // Enable minimal headers
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
return map[string]interface{}{
"email": "[email protected]",
@@ -899,14 +901,14 @@ func TestStripAuthCookies(t *testing.T) {
cookiePrefix := sessionManager.GetCookiePrefix()
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
stripAuthCookies: tt.stripAuthCookies,
issuerURL: "https://provider.example.com",
stripAuthCookies: tt.stripAuthCookies,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
return map[string]interface{}{
"email": "[email protected]",
@@ -983,14 +985,14 @@ func TestStripAuthCookies_NoCookies(t *testing.T) {
sessionManager := createTestSessionManager(t)
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "[email protected]"}, nil
},
@@ -1030,14 +1032,14 @@ func TestStripAuthCookies_OnlyOIDCCookies(t *testing.T) {
cookiePrefix := sessionManager.GetCookiePrefix()
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "[email protected]"}, nil
},
@@ -1081,14 +1083,14 @@ func TestStripAuthCookies_OnlyAppCookies(t *testing.T) {
sessionManager := createTestSessionManager(t)
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "[email protected]"}, nil
},
@@ -1144,14 +1146,14 @@ func TestStripAuthCookies_CustomPrefix(t *testing.T) {
customPrefix := sm.GetCookiePrefix()
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sm,
firstRequestStarted: 1,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sm,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "[email protected]"}, nil
},
@@ -1203,3 +1205,115 @@ func TestStripAuthCookies_CustomPrefix(t *testing.T) {
t.Error("expected my_app cookie to pass through")
}
}
// TestServeHTTP_EventStream_AppliesRolesGate verifies that the SSE /
// WebSocket streaming bypass enforces the allowedRolesAndGroups gate the
// same way forwardAuthorized does. Regression for a role-gate bypass: an
// authenticated user without any permitted role could reach the backend
// just by setting Accept: text/event-stream.
func TestServeHTTP_EventStream_AppliesRolesGate(t *testing.T) {
sessionManager := createTestSessionManager(t)
// Craft an ID token whose groups/roles the IdP would present for this
// user. Signature is irrelevant: claims extraction only decodes the
// payload.
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate test key: %v", err)
}
idTokenWithNoRole, err := createTestJWT(key, "RS256", "test-key-id", map[string]interface{}{
"sub": "[email protected]",
"groups": []string{"readers"},
})
if err != nil {
t.Fatalf("failed to craft ID token: %v", err)
}
idTokenWithRole, err := createTestJWT(key, "RS256", "test-key-id", map[string]interface{}{
"sub": "[email protected]",
"groups": []string{"readers", "admins"},
})
if err != nil {
t.Fatalf("failed to craft ID token: %v", err)
}
newOidc := func(next http.Handler) *TraefikOidc {
return &TraefikOidc{
next: next,
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
extractClaimsFunc: extractClaims,
groupClaimName: "groups",
roleClaimName: "roles",
allowedRolesAndGroups: map[string]struct{}{"admins": {}},
}
}
buildAuthedReq := func(t *testing.T, idToken string) *http.Request {
req := httptest.NewRequest("GET", "/events", nil)
req.Header.Set("Accept", "text/event-stream")
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("failed to create test session: %v", err)
}
session.SetUserIdentifier("[email protected]")
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("failed to mark session authenticated: %v", err)
}
session.SetIDToken(idToken)
rec := httptest.NewRecorder()
if err := session.Save(req, rec); err != nil {
t.Fatalf("failed to save session: %v", err)
}
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
return req
}
t.Run("user_without_allowed_role_rejected", func(t *testing.T) {
req := buildAuthedReq(t, idTokenWithNoRole)
nextCalled := false
oidc := newOidc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
oidc.initComplete = make(chan struct{})
close(oidc.initComplete)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusForbidden {
t.Fatalf("expected 403 for SSE request with no allowed role, got %d", rw.Code)
}
if nextCalled {
t.Error("backend must NOT be called for a user with no allowed role")
}
})
t.Run("user_with_allowed_role_forwarded", func(t *testing.T) {
req := buildAuthedReq(t, idTokenWithRole)
nextCalled := false
oidc := newOidc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
oidc.initComplete = make(chan struct{})
close(oidc.initComplete)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusOK {
t.Fatalf("expected 200 for SSE request with allowed role, got %d", rw.Code)
}
if !nextCalled {
t.Error("backend must be called for a user with an allowed role")
}
})
}
+44 -9
View File
@@ -76,27 +76,58 @@ func (t *TraefikOidc) shouldBypassAuth(req *http.Request) (bool, string) {
// key, so the authenticated flag cannot be forged. We do NOT run full token
// signature verification here so that SSE/WS keeps working when the OIDC
// provider is briefly unavailable for JWK fetches.
func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) bool {
// The second return value (0 when forwarding) is the HTTP status to send
// when the request is rejected, letting the caller distinguish a 401
// (unauthenticated) from a 403 (authenticated but not permitted).
func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (bool, int) {
if t.sessionManager == nil {
return false
return false, http.StatusUnauthorized
}
session, err := t.sessionManager.GetSession(req)
if err != nil {
t.logger.Debugf("%s bypass: unable to load session: %v", reason, err)
return false
return false, http.StatusUnauthorized
}
defer session.returnToPoolSafely()
if !session.GetAuthenticated() {
t.logger.Debugf("%s bypass: rejecting request without authenticated session", reason)
return false
return false, http.StatusUnauthorized
}
userIdentifier := session.GetUserIdentifier()
if userIdentifier == "" {
t.logger.Debugf("%s bypass: rejecting request, session has no user identifier", reason)
return false
return false, http.StatusUnauthorized
}
// Enforce the allowedRolesAndGroups gate, mirroring forwardAuthorized:
// an authenticated user without any permitted role/group must not reach
// the SSE/WebSocket backend just by using the streaming bypass. Claims
// come from the session's ID token, same source the normal path uses.
if len(t.allowedRolesAndGroups) > 0 {
idClaims, claimsErr := session.GetIDTokenClaims(t.extractClaimsFunc)
if claimsErr != nil || idClaims == nil {
t.logger.Debugf("%s bypass: cannot read claims for role check (err=%v): %s", reason, claimsErr, userIdentifier)
return false, http.StatusForbidden
}
groups, roles, extErr := t.extractGroupsAndRolesFromClaims(idClaims)
if extErr != nil {
t.logger.Debugf("%s bypass: role extraction failed for %s: %v", reason, userIdentifier, extErr)
return false, http.StatusForbidden
}
allowed := false
for _, roleOrGroup := range append(groups, roles...) {
if _, ok := t.allowedRolesAndGroups[roleOrGroup]; ok {
allowed = true
break
}
}
if !allowed {
t.logger.Infof("User %s does not have any allowed roles or groups (bypass)", userIdentifier)
return false, http.StatusForbidden
}
}
// Sanitize the claim-derived identifier before it is injected as a
@@ -108,7 +139,7 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) b
safeIdentifier, ok := sanitizeHeaderClaimValue(userIdentifier, t.headerClaimMaxLen())
if !ok {
t.logger.Debugf("%s bypass: dropping unsafe user-identifier header: %s", reason, headerClaimValueReason(userIdentifier, t.headerClaimMaxLen()))
return true
return true, 0
}
req.Header.Set("X-Forwarded-User", safeIdentifier)
@@ -116,7 +147,7 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) b
req.Header.Set("X-Auth-Request-User", safeIdentifier)
}
t.logger.Debugf("%s bypass: forwarded user %s from session", reason, safeIdentifier)
return true
return true, 0
}
// ServeHTTP implements the main middleware logic for processing HTTP requests.
@@ -200,8 +231,12 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Otherwise an unauthenticated client could hit the backend
// just by setting Accept: text/event-stream or sending a
// WebSocket upgrade.
if !t.applyBypassUserHeaders(req, reason) {
t.sendErrorResponse(rw, req, "Authentication required", http.StatusUnauthorized)
if ok, status := t.applyBypassUserHeaders(req, reason); !ok {
msg := "Authentication required"
if status == http.StatusForbidden {
msg = "Access denied"
}
t.sendErrorResponse(rw, req, msg, status)
return
}
t.next.ServeHTTP(rw, req)
+2 -2
View File
@@ -42,7 +42,7 @@ func TestApplyBypassUserHeaders_SanitizesIdentifier(t *testing.T) {
logger: NewLogger("debug"),
}
if ok := oidc.applyBypassUserHeaders(req, "test"); !ok {
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
}
if v := req.Header.Get("X-Forwarded-User"); v != "" {
@@ -83,7 +83,7 @@ func TestApplyBypassUserHeaders_KeepsCleanIdentifier(t *testing.T) {
logger: NewLogger("debug"),
}
if ok := oidc.applyBypassUserHeaders(req, "test"); !ok {
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
}
if v := req.Header.Get("X-Forwarded-User"); v != "[email protected]" {