Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] 473ae05982 chore(release): v1.0.29 2026-07-22 09:33:54 +00:00
13 changed files with 110 additions and 1091 deletions
-1
View File
@@ -100,7 +100,6 @@ testData:
# bearerEmitWWWAuthenticate: true # default; RFC 6750 hint on 401s
# bearerOverridesCookie: false # default; cookie wins when both are present
# requireTokenIntrospection: false # opt-in; calls RFC 7662 introspection per request
# introspectionURL: https://idp.example.com/oauth/introspect # manual override; set when discovery omits introspection_endpoint
# maxTokenAgeSeconds: 86400 # 24h cap on iat (rejects clock-skew/forever tokens)
# maxIdentifierLength: 256 # cap on the sanitised principal identifier
# bearerFailureThreshold: 20 # consecutive 401s/IP that trip the throttle
+1 -1
View File
@@ -42,7 +42,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v1.0.29
version: v0.7.10
```
Then attach the middleware in your dynamic configuration (see
+2 -9
View File
@@ -298,20 +298,13 @@ strictAudienceValidation: true
```
Look for `introspection_endpoint`
2. **Configure the endpoint manually:**
```yaml
introspectionURL: https://YOUR_DOMAIN/oauth/introspect
```
Set this when Auth0 omits `introspection_endpoint` from discovery. The
manual value takes precedence over the discovered value.
3. **Disable required introspection (less secure):**
2. **Disable required introspection (less secure):**
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: false # Falls back to ID token
```
4. **Use JWT access tokens instead** (recommended)
3. **Use JWT access tokens instead** (recommended)
---
+2 -3
View File
@@ -179,9 +179,8 @@ clientSecret: your-client-secret
| `forceHTTPS` | bool | `true` | Force HTTPS for redirect URIs (set `false` only for plaintext HTTP local dev) |
| `rateLimit` | int | `100` | Maximum requests per second |
| `excludedURLs` | []string | none | Paths that bypass authentication, matched at a path-segment or file-extension boundary |
| `revocationURL` | string | auto-discovered | Token revocation endpoint. Takes precedence over the discovered value. |
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint. Takes precedence over the discovered value. |
| `introspectionURL` | string | auto-discovered | RFC 7662 token introspection endpoint. Set this when your IdP omits `introspection_endpoint` from discovery. Takes precedence over the discovered value. |
| `revocationURL` | string | auto-discovered | Token revocation endpoint |
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint |
| `enablePKCE` | bool | `false` | Enable PKCE for authorization code flow |
| `minimalHeaders` | bool | `false` | Reduce forwarded headers |
| `clientAuthMethod` | string | `client_secret_post` | Client authentication method at token/revocation endpoints. One of `client_secret_post`, `client_secret_basic`, `private_key_jwt`. See [Client Authentication](#client-authentication). |
+2 -2
View File
@@ -51,7 +51,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v1.0.29
version: v0.8.0
log:
level: INFO
@@ -263,7 +263,7 @@ services:
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc"
- "--experimental.plugins.traefikoidc.version=v1.0.29"
- "--experimental.plugins.traefikoidc.version=v0.8.0"
ports:
- "80:80"
- "443:443"
+1 -1
View File
@@ -6,7 +6,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v1.0.29
version: v0.8.0
# Dynamic configuration (dynamic.yml or labels)
http:
-568
View File
@@ -1,568 +0,0 @@
package traefikoidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// issue152_regression_test.go — regression tests for issue #152.
//
// Three provider endpoints — token revocation, OIDC end-session (logout), and
// RFC 7662 token introspection — were documented Config fields
// (RevocationURL, OIDCEndSessionURL) or a brand-new one (IntrospectionURL)
// that operators can set when their IdP omits an endpoint from discovery, or
// to pin a non-standard one. RevocationURL and OIDCEndSessionURL were dead:
// Config.Validate() checked them but nothing ever copied them onto the live
// TraefikOidc fields consumers read, so setting them silently did nothing.
// IntrospectionURL did not exist at all.
//
// The fix adds three unexported write-once fields on TraefikOidc
// (configRevocationURL, configEndSessionURL, configIntrospectionURL),
// populated from Config at construction (main.go NewWithContext). The same
// three values also cold-start-seed the live fields (revocationURL,
// endSessionURL, introspectionURL) so a discovery failure does not lose an
// operator override. Inside updateMetadataEndpoints, after the SSRF sanitize
// pass, the introspection same-host pin, and the issuer pin — but before the
// metadataMu.Lock() that publishes the live fields — each non-empty config*
// field overwrites the corresponding metadata.* value, so a manual override
// wins over discovery on every refresh, not just at construction. An empty
// override leaves the discovered value alone. Manual overrides deliberately
// skip validateDiscoveredEndpoint and the introspection same-host pin: they
// sit at the same trust tier as providerURL itself, already gated by
// isValidSecureURL at config-validation time.
//
// These tests pin: default (no overrides) behavior is unchanged, overrides
// win over both absent and present discovered values, a cross-host
// introspection override survives the same-host pin that a discovered
// cross-host endpoint does not, overrides survive repeated metadata
// refreshes, NewWithContext wires the three config* fields and cold-start
// seeds correctly (including when discovery fails outright), the override
// is honored end-to-end by introspectToken and RevokeTokenWithProvider, the
// logout URL builder emits the override, and Config.Validate / New enforce
// the same "invalid configuration" + untyped-nil-handler contract (#151)
// for a bad IntrospectionURL that the sibling fields already had.
// issue152BaseConfig returns a complete, valid Config, following the
// minimal-valid-config pattern used elsewhere in this package (e.g.
// issue154BaseConfig in issue154_regression_test.go).
func issue152BaseConfig() *Config {
return &Config{
ProviderURL: "https://provider.example.com",
ClientID: "test-client",
ClientSecret: "test-secret",
CallbackURL: "/callback",
SessionEncryptionKey: "this-is-a-valid-session-key-32b!",
RateLimit: 100,
}
}
// issue152Endpoints reads the three metadataMu-guarded live endpoint fields
// under lock, so callers stay race-clean regardless of whether a background
// discovery goroutine may still touch them.
func issue152Endpoints(oidc *TraefikOidc) (revocation, endSession, introspection string) {
oidc.metadataMu.RLock()
defer oidc.metadataMu.RUnlock()
return oidc.revocationURL, oidc.endSessionURL, oidc.introspectionURL
}
// TestIssue152DefaultPreservation proves the fix is additive: with no
// operator overrides, updateMetadataEndpoints behaves exactly as before —
// discovered values become the live fields and the published snapshot.
func TestIssue152DefaultPreservation(t *testing.T) {
t.Run("discovered values populate live fields and snapshot", func(t *testing.T) {
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/revoke",
EndSessionURL: "https://provider.example.com/endsession",
IntrospectionURL: "https://provider.example.com/introspect",
RegistrationURL: "https://provider.example.com/register",
}
oidc.updateMetadataEndpoints(metadata)
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != metadata.RevokeURL {
t.Fatalf("revocationURL = %q, want discovered %q", revocation, metadata.RevokeURL)
}
if endSession != metadata.EndSessionURL {
t.Fatalf("endSessionURL = %q, want discovered %q", endSession, metadata.EndSessionURL)
}
if introspection != metadata.IntrospectionURL {
t.Fatalf("introspectionURL = %q, want discovered %q", introspection, metadata.IntrospectionURL)
}
snap := oidc.metadataSnap()
if snap == nil {
t.Fatal("metadataSnap() returned nil after updateMetadataEndpoints")
}
if snap.RevocationURL != metadata.RevokeURL || snap.EndSessionURL != metadata.EndSessionURL || snap.IntrospectionURL != metadata.IntrospectionURL {
t.Fatalf("metadataSnapshot = %+v, want it to match discovered metadata %+v", snap, metadata)
}
})
t.Run("discovery omits introspection and no override leaves it empty", func(t *testing.T) {
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// IntrospectionURL intentionally omitted.
}
oidc.updateMetadataEndpoints(metadata)
_, _, introspection := issue152Endpoints(oidc)
if introspection != "" {
t.Fatalf("introspectionURL = %q, want empty when discovery omits it and no override is set", introspection)
}
})
}
// TestIssue152OverridesWin proves a non-empty config* override always wins
// over discovery, whether discovery is silent on the endpoint (a) or
// actively supplies a different value (b), and that a cross-host
// introspection override survives the same-host pin that a discovered
// cross-host introspection endpoint does not (c).
func TestIssue152OverridesWin(t *testing.T) {
overrideRevocation := "https://override.example.com/revoke"
overrideEndSession := "https://override.example.com/endsession"
overrideIntrospection := "https://override.example.com/introspect"
newOverriddenOidc := func() *TraefikOidc {
return &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configRevocationURL: overrideRevocation,
configEndSessionURL: overrideEndSession,
configIntrospectionURL: overrideIntrospection,
}
}
t.Run("discovery omits endpoints, overrides fill them in", func(t *testing.T) {
oidc := newOverriddenOidc()
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
}
oidc.updateMetadataEndpoints(metadata)
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want overrides (%q, %q, %q)",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
snap := oidc.metadataSnap()
if snap == nil || snap.RevocationURL != overrideRevocation || snap.EndSessionURL != overrideEndSession || snap.IntrospectionURL != overrideIntrospection {
t.Fatalf("metadataSnapshot = %+v, want overrides", snap)
}
})
t.Run("discovery supplies different values, overrides win anyway", func(t *testing.T) {
oidc := newOverriddenOidc()
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/discovered-revoke",
EndSessionURL: "https://provider.example.com/discovered-endsession",
IntrospectionURL: "https://provider.example.com/discovered-introspect",
}
oidc.updateMetadataEndpoints(metadata)
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want overrides (%q, %q, %q) to beat discovered values",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
})
t.Run("cross-host introspection: discovered is zeroed, override survives", func(t *testing.T) {
// No override: a discovered introspection endpoint on a different host
// than providerURL must be zeroed by the same-host pin.
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
IntrospectionURL: "https://attacker.example.com/introspect",
}
oidc.updateMetadataEndpoints(metadata)
if _, _, introspection := issue152Endpoints(oidc); introspection != "" {
t.Fatalf("introspectionURL = %q, want empty: discovered cross-host introspection must be zeroed by the same-host pin", introspection)
}
// Same cross-host discovered value, but now with a cross-host override:
// the override must survive because it skips the same-host pin.
oidcWithOverride := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configIntrospectionURL: overrideIntrospection,
}
oidcWithOverride.updateMetadataEndpoints(metadata)
if _, _, introspection := issue152Endpoints(oidcWithOverride); introspection != overrideIntrospection {
t.Fatalf("introspectionURL = %q, want override %q to survive despite being cross-host", introspection, overrideIntrospection)
}
})
}
// TestIssue152RefreshSurvival proves overrides are re-applied inside
// updateMetadataEndpoints itself, so they survive every metadata refresh —
// not just the first call after construction.
func TestIssue152RefreshSurvival(t *testing.T) {
overrideRevocation := "https://override.example.com/revoke"
overrideEndSession := "https://override.example.com/endsession"
overrideIntrospection := "https://override.example.com/introspect"
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configRevocationURL: overrideRevocation,
configEndSessionURL: overrideEndSession,
configIntrospectionURL: overrideIntrospection,
}
firstRefresh := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/refresh1-revoke",
EndSessionURL: "https://provider.example.com/refresh1-endsession",
IntrospectionURL: "https://provider.example.com/refresh1-introspect",
}
oidc.updateMetadataEndpoints(firstRefresh)
if revocation, endSession, introspection := issue152Endpoints(oidc); revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("after 1st refresh: got (%q, %q, %q), want overrides to hold", revocation, endSession, introspection)
}
secondRefresh := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/refresh2-revoke",
EndSessionURL: "https://provider.example.com/refresh2-endsession",
IntrospectionURL: "https://provider.example.com/refresh2-introspect",
}
oidc.updateMetadataEndpoints(secondRefresh)
if revocation, endSession, introspection := issue152Endpoints(oidc); revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("after 2nd refresh: got (%q, %q, %q), want overrides to still hold", revocation, endSession, introspection)
}
}
// TestIssue152NewWithContextWiring proves NewWithContext wires
// Config.RevocationURL / OIDCEndSessionURL / IntrospectionURL onto the three
// unexported config* fields and cold-start-seeds the live fields from them
// (main.go, the configRevocationURL/configEndSessionURL/configIntrospectionURL
// and revocationURL/endSessionURL/introspectionURL initializers), both when
// discovery succeeds with different values (override must still win) and
// when discovery fails outright (the cold-start seed must not be lost).
func TestIssue152NewWithContextWiring(t *testing.T) {
overrideRevocation := "https://override.example.com/revoke"
overrideEndSession := "https://override.example.com/endsession"
overrideIntrospection := "https://override.example.com/introspect"
t.Run("discovery succeeds with different values, overrides still win", func(t *testing.T) {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/.well-known/openid-configuration") {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ProviderMetadata{
Issuer: srv.URL,
AuthURL: srv.URL + "/auth",
TokenURL: srv.URL + "/token",
JWKSURL: srv.URL + "/jwks",
RevokeURL: srv.URL + "/discovered-revoke",
EndSessionURL: srv.URL + "/discovered-endsession",
IntrospectionURL: srv.URL + "/discovered-introspect",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
cfg := issue152BaseConfig()
cfg.ProviderURL = srv.URL
cfg.RevocationURL = overrideRevocation
cfg.OIDCEndSessionURL = overrideEndSession
cfg.IntrospectionURL = overrideIntrospection
oidc, err := NewWithContext(context.Background(), cfg, nil, "issue152-wiring-success")
if err != nil {
t.Fatalf("NewWithContext must accept a valid config with overrides, got: %v", err)
}
defer func() { _ = oidc.Close() }()
<-oidc.initComplete // wait for the discovery goroutine so field reads below are race-safe.
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want overrides (%q, %q, %q) to win over successful discovery",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
})
t.Run("discovery fails outright, cold-start seed holds", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
cfg := issue152BaseConfig()
cfg.ProviderURL = srv.URL
cfg.RevocationURL = overrideRevocation
cfg.OIDCEndSessionURL = overrideEndSession
cfg.IntrospectionURL = overrideIntrospection
oidc, err := NewWithContext(context.Background(), cfg, nil, "issue152-wiring-coldstart")
if err != nil {
t.Fatalf("NewWithContext must accept a valid config even though discovery will fail, got: %v", err)
}
defer func() { _ = oidc.Close() }()
<-oidc.initComplete // discovery has failed and returned by now.
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want the constructor's cold-start seed (%q, %q, %q) to survive a failed discovery",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
})
}
// TestIssue152EndToEndIntrospection proves the override is honored one layer
// up from the field: with a discovery document that omits
// introspection_endpoint entirely, introspectToken (token_introspection.go)
// must still reach the manually-configured endpoint and return its result.
func TestIssue152EndToEndIntrospection(t *testing.T) {
var hits int32
var gotToken string
introspectionSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
if err := r.ParseForm(); err == nil {
gotToken = r.PostFormValue("token")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"active": true})
}))
defer introspectionSrv.Close()
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
clientSecret: "test-secret",
httpClient: http.DefaultClient,
configIntrospectionURL: introspectionSrv.URL,
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// IntrospectionURL intentionally omitted: the override is the only
// source of the endpoint.
}
oidc.updateMetadataEndpoints(metadata)
if _, _, introspection := issue152Endpoints(oidc); introspection != introspectionSrv.URL {
t.Fatalf("introspectionURL = %q, want override %q wired before calling introspectToken", introspection, introspectionSrv.URL)
}
resp, err := oidc.introspectToken("opaque-test-token")
if err != nil {
t.Fatalf("introspectToken must succeed against the manual override endpoint, got: %v", err)
}
if !resp.Active {
t.Fatal("introspectToken response Active = false, want true")
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("introspection endpoint hit count = %d, want 1", got)
}
if gotToken != "opaque-test-token" {
t.Fatalf("introspection request token = %q, want %q", gotToken, "opaque-test-token")
}
}
// TestIssue152SiblingRevocationAndLogout proves the two dead siblings this
// issue repaired are wired end-to-end: RevokeTokenWithProvider
// (token_manager.go) posts to a configRevocationURL override, and the
// end-session URL BuildLogoutURL (helpers.go) builds from is the
// configEndSessionURL override. The full handleLogout HTTP handler needs a
// live SessionManager and an authenticated cookie to reach BuildLogoutURL,
// which is disproportionate for this fix; instead this asserts the live
// field wiring plus a direct BuildLogoutURL call with that wired value,
// which is the same function handleLogout calls.
func TestIssue152SiblingRevocationAndLogout(t *testing.T) {
t.Run("RevokeTokenWithProvider posts to the override", func(t *testing.T) {
var hits int32
var gotToken string
revocationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
if err := r.ParseForm(); err == nil {
gotToken = r.PostFormValue("token")
}
w.WriteHeader(http.StatusOK)
}))
defer revocationSrv.Close()
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
clientSecret: "test-secret",
httpClient: http.DefaultClient,
configRevocationURL: revocationSrv.URL,
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// RevokeURL intentionally omitted.
}
oidc.updateMetadataEndpoints(metadata)
if err := oidc.RevokeTokenWithProvider("opaque-test-token", "access_token"); err != nil {
t.Fatalf("RevokeTokenWithProvider must succeed against the manual override endpoint, got: %v", err)
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("revocation endpoint hit count = %d, want 1", got)
}
if gotToken != "opaque-test-token" {
t.Fatalf("revocation request token = %q, want %q", gotToken, "opaque-test-token")
}
})
t.Run("logout URL builder emits the override", func(t *testing.T) {
overrideEndSession := "https://override.example.com/endsession"
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configEndSessionURL: overrideEndSession,
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// EndSessionURL intentionally omitted.
}
oidc.updateMetadataEndpoints(metadata)
_, endSession, _ := issue152Endpoints(oidc)
if endSession != overrideEndSession {
t.Fatalf("endSessionURL = %q, want override %q wired before building the logout URL", endSession, overrideEndSession)
}
logoutURL, err := BuildLogoutURL(endSession, "idtok123", "https://myapp.example.com/")
if err != nil {
t.Fatalf("BuildLogoutURL must succeed with the wired override, got: %v", err)
}
if !strings.HasPrefix(logoutURL, overrideEndSession+"?") {
t.Fatalf("logoutURL = %q, want prefix %q", logoutURL, overrideEndSession+"?")
}
if !strings.Contains(logoutURL, "id_token_hint=idtok123") {
t.Fatalf("logoutURL = %q, want it to contain the id_token_hint", logoutURL)
}
})
}
// TestIssue152ConfigValidation pins Config.Validate's IntrospectionURL check:
// empty is fine (feature is opt-in), a valid HTTPS URL is fine, plaintext
// HTTP is fine only for a loopback host (isValidSecureURL's RFC 8252-style
// allowance, mirrored from the ProviderURL/RevocationURL/OIDCEndSessionURL
// checks), and anything else — plaintext HTTP to a remote host, or a
// malformed URL — is rejected with the introspectionURL-specific message.
func TestIssue152ConfigValidation(t *testing.T) {
cases := []struct {
name string
value string
wantErr bool
errContains string
}{
{"valid HTTPS URL", "https://idp.example.com/introspect", false, ""},
{"empty is valid (opt-in feature)", "", false, ""},
{"plaintext HTTP allowed for loopback", "http://localhost:8080/introspect", false, ""},
{"plaintext HTTP rejected for remote host", "http://evil.example.com/x", true, "introspectionURL must be a valid HTTPS URL"},
{"malformed URL rejected", "://garbage", true, "introspectionURL must be a valid HTTPS URL"},
{"URL with no host rejected", "https:///nohost", true, "introspectionURL must be a valid HTTPS URL"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := issue152BaseConfig()
cfg.IntrospectionURL = tc.value
err := cfg.Validate()
if tc.wantErr {
if err == nil {
t.Fatalf("Validate() with IntrospectionURL=%q must fail", tc.value)
}
if !strings.Contains(err.Error(), tc.errContains) {
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tc.errContains)
}
return
}
if err != nil {
t.Fatalf("Validate() with IntrospectionURL=%q must succeed, got: %v", tc.value, err)
}
})
}
}
// TestIssue152InvalidIntrospectionURLNewContract proves an invalid
// IntrospectionURL is rejected by New() through the same fail-closed,
// untyped-nil-handler contract issue #151 established for every other
// Config.Validate failure: a concrete "invalid configuration" error, and h
// itself must be an untyped nil (not a typed-nil *TraefikOidc boxed into a
// non-nil http.Handler interface value).
func TestIssue152InvalidIntrospectionURLNewContract(t *testing.T) {
cfg := issue152BaseConfig()
cfg.IntrospectionURL = "http://evil.example.com/x" // plaintext HTTP to a remote host: rejected.
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "issue152-invalid-introspection")
if err == nil {
t.Fatal("New() must reject an invalid IntrospectionURL")
}
if !strings.Contains(err.Error(), "invalid configuration") {
t.Fatalf("New() error = %q, want it to contain %q", err.Error(), "invalid configuration")
}
if h != nil {
t.Fatalf("New() must return an untyped nil handler on error, got %T", h)
}
}
-326
View File
@@ -1,326 +0,0 @@
package traefikoidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// issue154_regression_test.go — regression tests for issue #154.
//
// A providerURL pointing at a localhost identity provider (local
// development, sidecar OIDC providers) caused an infinite redirect loop.
// validateHost unconditionally rejected loopback/localhost hosts, so
// buildURLWithParams returned "" for the discovered authorization endpoint,
// buildAuthURL returned "", and Traefik kept redirecting to an empty
// Location header forever.
//
// The fix adds an unexported `allowLoopbackHosts bool` field to TraefikOidc,
// derived at construction time from `isLoopbackProviderURL(config.ProviderURL)`
// (settings.go). validateHost now gates loopback IPs and localhost/127.x/::1
// literals on `!t.allowLoopbackHosts`, while link-local, multicast, and the
// cloud-metadata hostnames stay unconditionally blocked (SSRF surface must
// not widen). updateMetadataEndpoints recomputes the same derivation from
// t.providerURL directly (not the struct field), so it stays correct for
// TraefikOidc values test code builds as struct literals.
//
// These tests pin: the opt-in behaves correctly, the default (flag unset)
// still blocks loopback exactly as before the fix, the SSRF-relevant hosts
// stay blocked even with the opt-in active, allowPrivateIPAddresses remains
// an independent gate, the derivation helper is correct in isolation, the
// end-to-end buildAuthURL behavior for both flag states, that NewWithContext
// derives the field correctly from config.ProviderURL, and that the
// discovery layer (updateMetadataEndpoints) and the URL-validation layer
// (validateHost/buildAuthURL) agree on a loopback provider.
// issue154LoopbackHosts are host strings that represent localhost/loopback
// under every literal form validateHost must recognize: bare hostname,
// hostname:port, case variation, IPv4 loopback with and without port, an
// IPv6 loopback literal with port, and a non-canonical loopback IP
// (127.0.0.0/8 is entirely loopback, not just 127.0.0.1).
var issue154LoopbackHosts = []string{
"localhost",
"localhost:8080",
"LOCALHOST",
"127.0.0.1",
"127.0.0.1:8080",
"[::1]:8080",
"127.0.0.2:9000",
}
// TestIssue154ValidateHostLoopbackAllowed pins the opt-in: once a middleware
// instance has allowLoopbackHosts=true (as New/NewWithContext derive it from
// a loopback providerURL), every loopback host form validates cleanly.
func TestIssue154ValidateHostLoopbackAllowed(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.allowLoopbackHosts = true
for _, host := range issue154LoopbackHosts {
t.Run(host, func(t *testing.T) {
if err := middleware.validateHost(host); err != nil {
t.Fatalf("validateHost(%q) with allowLoopbackHosts=true must succeed, got: %v", host, err)
}
})
}
}
// TestIssue154ValidateHostLoopbackDefaultBlocked pins the default-preserving
// half of the fix: a middleware instance built without explicitly setting
// allowLoopbackHosts (its zero value, matching every TraefikOidc a
// non-loopback New()/NewWithContext call produces) must still reject every
// loopback host form, exactly as before the fix. This is asserted as
// "error present", not against exact error text: "[::1]:8080" alone
// (without a bracket-less counterpart) does not hit the pre-existing
// net.SplitHostPort quirk where a bare "[::1]" without a port fails with
// "invalid host format" instead of the loopback-specific message — but
// pinning only non-nil keeps this test robust to either error path.
func TestIssue154ValidateHostLoopbackDefaultBlocked(t *testing.T) {
middleware := createMinimalMiddleware() // allowLoopbackHosts left at zero value (false)
for _, host := range issue154LoopbackHosts {
t.Run(host, func(t *testing.T) {
if err := middleware.validateHost(host); err == nil {
t.Fatalf("validateHost(%q) with default allowLoopbackHosts must still be rejected", host)
}
})
}
}
// TestIssue154SecurityEnvelopeWithLoopbackAllowed is the highest-value test
// in this file: it proves that opting into loopback hosts does not widen the
// SSRF surface. Cloud metadata addresses, link-local, multicast, and
// unspecified addresses must stay blocked even with allowLoopbackHosts=true.
func TestIssue154SecurityEnvelopeWithLoopbackAllowed(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.allowLoopbackHosts = true
cases := []struct {
name string
host string
wantSubstr string
}{
{"AWS/GCP metadata IP", "169.254.169.254", "loopback/link-local"},
{"AWS/GCP metadata IP with port", "169.254.169.254:80", "loopback/link-local"},
{"GCP metadata hostname", "metadata.google.internal", "dangerous hostname"},
{"GCP metadata hostname with port", "metadata.google.internal:80", "dangerous hostname"},
{"IPv6 link-local", "[fe80::1]:80", "loopback/link-local"},
{"IPv4 link-local multicast", "224.0.0.1", "loopback/link-local"},
{"unspecified IPv4", "0.0.0.0", "unspecified or multicast"},
{"unspecified IPv6 with port", "[::]:80", "unspecified or multicast"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := middleware.validateHost(tc.host)
if err == nil {
t.Fatalf("validateHost(%q) must still be rejected with allowLoopbackHosts=true", tc.host)
}
if !strings.Contains(err.Error(), tc.wantSubstr) {
t.Fatalf("validateHost(%q) error = %q, want substring %q", tc.host, err.Error(), tc.wantSubstr)
}
})
}
}
// TestIssue154PrivateIPIndependence proves allowLoopbackHosts and
// allowPrivateIPAddresses are independent gates: opting a middleware into
// loopback hosts must not also open up RFC 1918 private ranges.
func TestIssue154PrivateIPIndependence(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.allowLoopbackHosts = true
middleware.allowPrivateIPAddresses = false
privateHosts := []string{"10.0.0.5", "192.168.1.10", "172.16.0.1"}
for _, host := range privateHosts {
t.Run(host, func(t *testing.T) {
err := middleware.validateHost(host)
if err == nil {
t.Fatalf("validateHost(%q) must be rejected when allowPrivateIPAddresses=false", host)
}
if !strings.Contains(err.Error(), "private/internal") {
t.Fatalf("validateHost(%q) error = %q, want substring %q", host, err.Error(), "private/internal")
}
})
}
}
// TestIssue154IsLoopbackProviderURL exercises the construction-time
// derivation helper in isolation, including malformed input that must not
// panic.
func TestIssue154IsLoopbackProviderURL(t *testing.T) {
cases := []struct {
name string
providerURL string
want bool
}{
{"http localhost with port", "http://localhost:8080", true},
{"http loopback IPv4 with port", "http://127.0.0.1:9000", true},
{"http loopback IPv6 with port", "http://[::1]:9000", true},
{"https localhost uppercase", "https://LOCALHOST:9000", true},
{"https real remote provider", "https://accounts.google.com", false},
{"https another remote provider", "https://idp.example.com", false},
{"empty string", "", false},
{"malformed URL must not panic", "://garbage", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := isLoopbackProviderURL(tc.providerURL)
if got != tc.want {
t.Fatalf("isLoopbackProviderURL(%q) = %v, want %v", tc.providerURL, got, tc.want)
}
})
}
}
// TestIssue154BuildAuthURLLoopbackProvider is the issue scenario reproduced
// at the unit level: a loopback authURL must produce a real redirect URL
// when allowLoopbackHosts is set, and the pre-fix behavior (empty string,
// the infinite-redirect-loop trigger) must be pinned as the default when it
// is not.
func TestIssue154BuildAuthURLLoopbackProvider(t *testing.T) {
t.Run("allowLoopbackHosts=true builds a real auth URL", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.allowLoopbackHosts = true
middleware.authURL = "http://localhost:8080/oauth/authorize"
middleware.issuerURL = "http://localhost:8080"
got := middleware.buildAuthURL("http://localhost:5555/callback", "state123", "nonce456", "")
if got == "" {
t.Fatal("buildAuthURL returned empty string for a loopback provider with allowLoopbackHosts=true")
}
if !strings.HasPrefix(got, "http://localhost:8080/oauth/authorize?") {
t.Fatalf("buildAuthURL = %q, want prefix %q", got, "http://localhost:8080/oauth/authorize?")
}
for _, want := range []string{"client_id=", "state=state123", "nonce=nonce456"} {
if !strings.Contains(got, want) {
t.Fatalf("buildAuthURL = %q, want it to contain %q", got, want)
}
}
})
t.Run("default allowLoopbackHosts=false pins pre-fix empty-string behavior", func(t *testing.T) {
middleware := createMinimalMiddleware() // allowLoopbackHosts left at zero value (false)
middleware.authURL = "http://localhost:8080/oauth/authorize"
middleware.issuerURL = "http://localhost:8080"
got := middleware.buildAuthURL("http://localhost:5555/callback", "state123", "nonce456", "")
if got != "" {
t.Fatalf("buildAuthURL = %q, want empty string when a loopback provider is not opted in", got)
}
})
}
// issue154BaseConfig returns a complete, valid Config with the given
// providerURL, following the minimal-valid-config pattern used elsewhere in
// this package (e.g. TestRank2And6_InvalidConfigFailsClosed in
// security_audit_fixes_test.go).
func issue154BaseConfig(providerURL string) *Config {
return &Config{
ProviderURL: providerURL,
ClientID: "test-client",
ClientSecret: "test-secret",
CallbackURL: "/callback",
SessionEncryptionKey: "this-is-a-valid-session-key-32b!",
RateLimit: 100,
}
}
// TestIssue154NewWithContextDerivation proves NewWithContext derives
// allowLoopbackHosts from config.ProviderURL at construction time (main.go,
// the `allowLoopbackHosts: isLoopbackProviderURL(config.ProviderURL)` field
// initializer). The field is set synchronously before the metadata-discovery
// goroutine starts, so this needs no live discovery endpoint: the ports used
// below (a loopback port nothing listens on, and an RFC 2606 .invalid host)
// only need to fail fast once the background goroutine attempts discovery —
// Close() cancels that goroutine's context regardless of outcome.
func TestIssue154NewWithContextDerivation(t *testing.T) {
t.Run("loopback providerURL derives allowLoopbackHosts=true", func(t *testing.T) {
// A real httptest server (loopback by construction) so the
// background discovery goroutine NewWithContext starts completes
// quickly and Close() below does not have to wait out its 10s
// goroutine-shutdown timeout for an unreachable host. Mirrors
// TestInitializeMetadata's server setup in main_initialization_test.go.
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/.well-known/openid-configuration") {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ProviderMetadata{
Issuer: srv.URL,
AuthURL: srv.URL + "/auth",
TokenURL: srv.URL + "/token",
JWKSURL: srv.URL + "/jwks",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
cfg := issue154BaseConfig(srv.URL)
oidc, err := NewWithContext(context.Background(), cfg, nil, "issue154-loopback")
if err != nil {
t.Fatalf("NewWithContext must accept a valid loopback config, got: %v", err)
}
defer func() { _ = oidc.Close() }()
if !oidc.allowLoopbackHosts {
t.Fatal("expected allowLoopbackHosts=true for a loopback providerURL")
}
})
t.Run("non-loopback providerURL derives allowLoopbackHosts=false", func(t *testing.T) {
cfg := issue154BaseConfig("https://provider.invalid")
oidc, err := NewWithContext(context.Background(), cfg, nil, "issue154-remote")
if err != nil {
t.Fatalf("NewWithContext must accept a valid remote config, got: %v", err)
}
defer func() { _ = oidc.Close() }()
if oidc.allowLoopbackHosts {
t.Fatal("expected allowLoopbackHosts=false for a non-loopback providerURL")
}
})
}
// TestIssue154DiscoveryAndURLValidationAgree catches layer drift between the
// discovery-endpoint sanitizer (updateMetadataEndpoints, which recomputes
// isLoopbackProviderURL(t.providerURL) itself per its doc comment, since
// test helpers build &TraefikOidc{} literals without going through
// NewWithContext) and the outbound URL validator (validateHost, gated on the
// allowLoopbackHosts field). Both layers must agree that a loopback provider
// is usable end-to-end: discovery must accept the loopback authURL, and
// buildAuthURL must build a real URL once allowLoopbackHosts is set to
// mirror what NewWithContext would have derived.
func TestIssue154DiscoveryAndURLValidationAgree(t *testing.T) {
oidc := &TraefikOidc{
providerURL: "http://localhost:8080",
logger: NewLogger("debug"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "http://localhost:8080",
AuthURL: "http://localhost:8080/oauth/authorize",
TokenURL: "http://localhost:8080/oauth/token",
JWKSURL: "http://localhost:8080/jwks",
}
oidc.updateMetadataEndpoints(metadata)
if oidc.authURL == "" {
t.Fatal("updateMetadataEndpoints rejected a loopback authURL for a loopback providerURL (discovery layer drift)")
}
// The URL-validation layer must independently agree once
// allowLoopbackHosts mirrors what NewWithContext would have derived from
// this same loopback providerURL.
oidc.allowLoopbackHosts = true
if got := oidc.buildAuthURL("http://localhost:5555/callback", "state", "nonce", ""); got == "" {
t.Fatal("buildAuthURL returned empty for the discovered loopback authURL with allowLoopbackHosts=true (validation layer drift)")
}
}
+5 -35
View File
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"fmt"
"net/http"
"net/url"
"os"
"runtime"
"strings"
@@ -271,21 +272,6 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
allowOpaqueTokens: config.AllowOpaqueTokens,
requireTokenIntrospection: config.RequireTokenIntrospection,
disableReplayDetection: config.DisableReplayDetection,
// Operator-configured endpoint overrides (issue #152). Applied inside
// updateMetadataEndpoints so the override survives every metadata
// refresh, not just construction.
configRevocationURL: config.RevocationURL,
configEndSessionURL: config.OIDCEndSessionURL,
configIntrospectionURL: config.IntrospectionURL,
// Cold-start seed: discovery failure must not lose operator-configured
// endpoints; updateMetadataEndpoints re-applies these on every refresh.
// Seeding introspectionURL here does not populate metadataSnapshot
// (only updateMetadataEndpoints does); all three consumers
// (token_introspection.go, token_manager.go, helpers.go) read these
// metadataMu-guarded fields directly, not the snapshot.
revocationURL: config.RevocationURL,
endSessionURL: config.OIDCEndSessionURL,
introspectionURL: config.IntrospectionURL,
scopes: func() []string {
userProvidedScopes := deduplicateScopes(config.Scopes)
@@ -328,7 +314,6 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering
dcrConfig: config.DynamicClientRegistration,
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
allowLoopbackHosts: isLoopbackProviderURL(config.ProviderURL),
minimalHeaders: config.MinimalHeaders,
stripAuthCookies: config.StripAuthCookies,
enableBackchannelLogout: config.EnableBackchannelLogout,
@@ -577,11 +562,10 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
// discovered endpoint pointed at a blocked address before the plugin issues
// outbound requests to it, so it can never be used to reach the cloud
// metadata service or an internal host.
// Recomputed from t.providerURL (not the allowLoopbackHosts field) so this
// stays correct for TraefikOidc values built outside New() (test helpers
// construct &TraefikOidc{providerURL: ...} directly, leaving
// allowLoopbackHosts at its zero value).
allowLoopback := isLoopbackProviderURL(t.providerURL)
allowLoopback := false
if pu, err := url.Parse(t.providerURL); err == nil {
allowLoopback = isLoopbackHost(pu.Hostname())
}
sanitize := func(name, raw string) string {
if err := t.validateDiscoveredEndpoint(raw, allowLoopback); err != nil {
t.logger.Errorf("Ignoring discovered %s endpoint %q: %v", name, raw, err)
@@ -617,20 +601,6 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
t.logger.Errorf("Ignoring discovered issuer %q: host does not match configured providerURL", discoveredIssuer)
discoveredIssuer = ""
}
// Operator-configured endpoint overrides take precedence over discovered
// values. They deliberately skip validateDiscoveredEndpoint and the
// sameHost pin: those defend against a poisoned discovery document, while
// operator config sits at the same trust tier as providerURL itself and is
// already gated by isValidSecureURL at config time.
if t.configRevocationURL != "" {
metadata.RevokeURL = t.configRevocationURL
}
if t.configEndSessionURL != "" {
metadata.EndSessionURL = t.configEndSessionURL
}
if t.configIntrospectionURL != "" {
metadata.IntrospectionURL = t.configIntrospectionURL
}
t.metadataMu.Lock()
+6 -26
View File
@@ -49,16 +49,12 @@ type Config struct {
RoleClaimName string `json:"roleClaimName,omitempty"`
CookieDomain string `json:"cookieDomain"`
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
// IntrospectionURL overrides the RFC 7662 token introspection endpoint.
// Set it when the IdP omits introspection_endpoint from discovery. It
// takes precedence over the discovered value.
IntrospectionURL string `json:"introspectionURL"`
Scopes []string `json:"scopes"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedUsers []string `json:"allowedUsers"`
Headers []TemplatedHeader `json:"headers"`
Scopes []string `json:"scopes"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedUsers []string `json:"allowedUsers"`
Headers []TemplatedHeader `json:"headers"`
// AllowedClaims extends the built-in claims whitelist that header value
// templates may emit. Add the exact claim name (e.g. "employee_id") to allow
// {{.Claims.employee_id}} / {{get .Claims "employee_id"}}. Applies to both
@@ -487,10 +483,6 @@ func (c *Config) Validate() error {
if c.OIDCEndSessionURL != "" && !isValidSecureURL(c.OIDCEndSessionURL) {
return fmt.Errorf("oidcEndSessionURL must be a valid HTTPS URL")
}
// Validate introspection URL if set
if c.IntrospectionURL != "" && !isValidSecureURL(c.IntrospectionURL) {
return fmt.Errorf("introspectionURL must be a valid HTTPS URL")
}
// Validate post-logout redirect URI if set
if c.PostLogoutRedirectURI != "" && c.PostLogoutRedirectURI != "/" {
@@ -679,18 +671,6 @@ func isLoopbackHost(host string) bool {
return false
}
// isLoopbackProviderURL reports whether providerURL's host is loopback
// (localhost or a loopback IP literal). Used at construction time to derive
// allowLoopbackHosts, so local-development providers are not blocked by
// validateHost's outbound URL checks.
func isLoopbackProviderURL(providerURL string) bool {
pu, err := url.Parse(providerURL)
if err != nil {
return false
}
return isLoopbackHost(pu.Hostname())
}
// isValidLogLevel checks if the provided log level string is one of the supported values ("debug", "info", "error").
//
// Parameters:
+83 -98
View File
@@ -91,115 +91,100 @@ type TraefikOidc struct {
// Hot-path readers (middleware.ServeHTTP, token verification) load it
// directly; less-frequent paths still acquire metadataMu.RLock and
// read the individual fields below.
metadataSnapshot atomic.Value
metadataSnapshot atomic.Value
// lastMetadataRetryNano is the UnixNano timestamp of the last metadata
// recovery attempt. Stored atomically so the hot ServeHTTP path can
// throttle retries without acquiring metadataRetryMutex on every request.
lastMetadataRetryNano int64
lastMetadataRetryNano int64
// firstRequestStarted is 0 until the very first non-health request fires
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the
// firstRequestMutex + firstRequestReceived combo which previously took
// a write lock on every non-health request forever.
firstRequestStarted int32
firstRequestStarted int32
// metadataRefreshStartedAtomic is the CAS-only variant of the old
// metadataRefreshStarted bool. Both flags live under the same atomic so
// concurrent first-request goroutines race exactly once.
metadataRefreshStartedAtomic int32
jwkCache JWKCacheInterface
jwtVerifier JWTVerifier
ctx context.Context
tokenVerifier TokenVerifier
next http.Handler
tokenExchanger TokenExchanger
tokenBlacklist CacheInterface
tokenTypeCache CacheInterface
introspectionCache CacheInterface
initComplete chan struct{}
limiter *rate.Limiter
headerTemplates map[string]*template.Template
sessionManager *SessionManager
tokenCleanupStopChan chan struct{}
excludedURLs map[string]struct{}
extractClaimsFunc func(tokenString string) (map[string]any, error)
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
metadataCache *MetadataCache
allowedRolesAndGroups map[string]struct{}
allowedUsers map[string]struct{}
allowedUserDomains map[string]struct{}
tokenCache *TokenCache
httpClient *http.Client
tokenHTTPClient *http.Client
logger *Logger
metadataRefreshStopChan chan struct{}
cancelFunc context.CancelFunc
errorRecoveryManager *ErrorRecoveryManager
tokenResilienceManager *TokenResilienceManager
refreshCoordinator *RefreshCoordinator
goroutineWG *sync.WaitGroup
dcrConfig *DynamicClientRegistrationConfig
dynamicClientRegistrar *DynamicClientRegistrar
scopeFilter *ScopeFilter
securityHeadersApplier func(http.ResponseWriter, *http.Request)
userIdentifierClaim string
revocationURL string
name string
redirURLPath string
logoutURLPath string
tokenURL string
authURL string
endSessionURL string
postLogoutRedirectURI string
jwksURL string
issuerURL string
groupClaimName string
introspectionURL string
providerURL string
roleClaimName string
audience string
clientID string
clientSecret string
clientAuthMethod string
clientAssertion *ClientAssertionSigner
registrationURL string
// configRevocationURL, configEndSessionURL, and configIntrospectionURL
// hold operator-configured endpoint overrides (Config.RevocationURL,
// Config.OIDCEndSessionURL, Config.IntrospectionURL). They are set once at
// construction and never mutated afterwards, so they are read lock-free,
// the same contract as providerURL, which updateMetadataEndpoints already
// reads unlocked. A manual override always wins over discovery; an empty
// string means use the discovered value.
configRevocationURL string
configEndSessionURL string
configIntrospectionURL string
backchannelLogoutPath string
frontchannelLogoutPath string
scopesSupported []string
scopes []string
extraAuthParams map[string]string
refreshGracePeriod time.Duration
maxRefreshTokenAge time.Duration
metadataMu sync.RWMutex
shutdownOnce sync.Once
sessionInvalidationCache CacheInterface
refreshResultCache CacheInterface
minimalHeaders bool
stripAuthCookies bool
enableBackchannelLogout bool
enableFrontchannelLogout bool
requireTokenIntrospection bool
allowPrivateIPAddresses bool
// allowLoopbackHosts permits loopback/localhost hosts in outbound URL
// validation (validateHost). Derived at construction time from a loopback
// providerURL (local development), never operator-set directly. Mirrors
// the allowLoopback derivation in updateMetadataEndpoints.
allowLoopbackHosts bool
disableReplayDetection bool
allowOpaqueTokens bool
strictAudienceValidation bool
overrideScopes bool
enablePKCE bool
forceHTTPS bool
suppressDiagnosticLogs bool
jwkCache JWKCacheInterface
jwtVerifier JWTVerifier
ctx context.Context
tokenVerifier TokenVerifier
next http.Handler
tokenExchanger TokenExchanger
tokenBlacklist CacheInterface
tokenTypeCache CacheInterface
introspectionCache CacheInterface
initComplete chan struct{}
limiter *rate.Limiter
headerTemplates map[string]*template.Template
sessionManager *SessionManager
tokenCleanupStopChan chan struct{}
excludedURLs map[string]struct{}
extractClaimsFunc func(tokenString string) (map[string]any, error)
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
metadataCache *MetadataCache
allowedRolesAndGroups map[string]struct{}
allowedUsers map[string]struct{}
allowedUserDomains map[string]struct{}
tokenCache *TokenCache
httpClient *http.Client
tokenHTTPClient *http.Client
logger *Logger
metadataRefreshStopChan chan struct{}
cancelFunc context.CancelFunc
errorRecoveryManager *ErrorRecoveryManager
tokenResilienceManager *TokenResilienceManager
refreshCoordinator *RefreshCoordinator
goroutineWG *sync.WaitGroup
dcrConfig *DynamicClientRegistrationConfig
dynamicClientRegistrar *DynamicClientRegistrar
scopeFilter *ScopeFilter
securityHeadersApplier func(http.ResponseWriter, *http.Request)
userIdentifierClaim string
revocationURL string
name string
redirURLPath string
logoutURLPath string
tokenURL string
authURL string
endSessionURL string
postLogoutRedirectURI string
jwksURL string
issuerURL string
groupClaimName string
introspectionURL string
providerURL string
roleClaimName string
audience string
clientID string
clientSecret string
clientAuthMethod string
clientAssertion *ClientAssertionSigner
registrationURL string
backchannelLogoutPath string
frontchannelLogoutPath string
scopesSupported []string
scopes []string
extraAuthParams map[string]string
refreshGracePeriod time.Duration
maxRefreshTokenAge time.Duration
metadataMu sync.RWMutex
shutdownOnce sync.Once
sessionInvalidationCache CacheInterface
refreshResultCache CacheInterface
minimalHeaders bool
stripAuthCookies bool
enableBackchannelLogout bool
enableFrontchannelLogout bool
requireTokenIntrospection bool
allowPrivateIPAddresses bool
disableReplayDetection bool
allowOpaqueTokens bool
strictAudienceValidation bool
overrideScopes bool
enablePKCE bool
forceHTTPS bool
suppressDiagnosticLogs bool
// Bearer-auth runtime state (populated only when EnableBearerAuth=true).
bearerIdentifierClaim string
+7 -20
View File
@@ -390,15 +390,8 @@ func (t *TraefikOidc) validateHost(host string) error {
ip := net.ParseIP(hostname)
if ip != nil {
// Loopback addresses are blocked by default, but allowed when the
// plugin was constructed with a loopback providerURL (local
// development) — see allowLoopbackHosts.
if ip.IsLoopback() && !t.allowLoopbackHosts {
return fmt.Errorf("access to loopback/link-local IP addresses is not allowed: %s", ip.String())
}
// Link-local and multicast addresses are always blocked.
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
// Always block loopback, link-local, and multicast addresses
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return fmt.Errorf("access to loopback/link-local IP addresses is not allowed: %s", ip.String())
}
@@ -412,22 +405,16 @@ func (t *TraefikOidc) validateHost(host string) error {
}
}
// Always blocked regardless of allowLoopbackHosts: these have no IP-branch
// backstop (net.ParseIP returns nil for hostnames), so bypassing them
// would open an SSRF path to the cloud metadata service.
alwaysDangerousHosts := map[string]bool{
dangerousHosts := map[string]bool{
"localhost": true,
"127.0.0.1": true,
"::1": true,
"0.0.0.0": true,
"169.254.169.254": true,
"metadata.google.internal": true,
}
if alwaysDangerousHosts[strings.ToLower(hostname)] {
return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname)
}
// localhost/loopback literals are blocked unless allowLoopbackHosts
// permits them (see allowLoopbackHosts doc comment on TraefikOidc).
if !t.allowLoopbackHosts && isLoopbackHost(hostname) {
if dangerousHosts[strings.ToLower(hostname)] {
return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname)
}
+1 -1
View File
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
// not the interpreted plugin). This build-stamped constant is therefore the
// single source of truth for the version reported by anonymous usage telemetry.
const traefikoidcPluginVersion = "1.0.32"
const traefikoidcPluginVersion = "1.0.29"