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()
-20
View File
@@ -49,10 +49,6 @@ 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"`
@@ -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:
-15
View File
@@ -161,16 +161,6 @@ type TraefikOidc struct {
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
@@ -188,11 +178,6 @@ type TraefikOidc struct {
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
+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"