mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
fix(oidcgate): security hardening — sanitize XFU, guardrails, validations
This commit is contained in:
@@ -82,6 +82,28 @@ func Load(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("config: missing required 'listen' (or OIDCGATE_LISTEN env var)")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.OIDC.CallbackURL, "/") {
|
||||
return nil, fmt.Errorf("config: callbackURL must be a path starting with '/', got %q", cfg.OIDC.CallbackURL)
|
||||
}
|
||||
if !strings.HasPrefix(cfg.OIDC.LogoutURL, "/") {
|
||||
return nil, fmt.Errorf("config: logoutURL must be a path starting with '/', got %q", cfg.OIDC.LogoutURL)
|
||||
}
|
||||
|
||||
reserved := []string{
|
||||
sentinelPath,
|
||||
cfg.AuthPath,
|
||||
cfg.StartPath,
|
||||
cfg.OIDC.CallbackURL,
|
||||
cfg.OIDC.LogoutURL,
|
||||
}
|
||||
for _, ex := range cfg.OIDC.ExcludedURLs {
|
||||
for _, r := range reserved {
|
||||
if r != "" && strings.HasPrefix(r, ex) {
|
||||
return nil, fmt.Errorf("config: excludedURL %q would bypass reserved oidcgate path %q", ex, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force standalone semantics: trust X-Forwarded-Uri.
|
||||
cfg.OIDC.TrustForwardedURI = true
|
||||
return cfg, nil
|
||||
|
||||
@@ -6,6 +6,27 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalYAML is a base config accepted by Load with no surprises.
|
||||
const minimalYAML = `
|
||||
listen: ":8080"
|
||||
providerURL: "https://idp.example"
|
||||
clientID: "abc"
|
||||
clientSecret: "secret"
|
||||
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
callbackURL: "/oauth2/callback"
|
||||
logoutURL: "/oauth2/logout"
|
||||
`
|
||||
|
||||
func writeConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoad_YAMLRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
@@ -135,3 +156,148 @@ redis:
|
||||
t.Errorf("redis address: want redis:6379, got %q", cfg.OIDC.Redis.Address)
|
||||
}
|
||||
}
|
||||
|
||||
// Fix 5: callbackURL / logoutURL must start with "/"
|
||||
func TestLoad_RejectsAbsoluteCallbackURL(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
listen: ":8080"
|
||||
providerURL: "https://idp.example"
|
||||
clientID: "abc"
|
||||
clientSecret: "secret"
|
||||
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
callbackURL: "https://app.example.com/oauth2/callback"
|
||||
logoutURL: "/oauth2/logout"
|
||||
`)
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("callbackURL with absolute URL must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_RejectsAbsoluteLogoutURL(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
listen: ":8080"
|
||||
providerURL: "https://idp.example"
|
||||
clientID: "abc"
|
||||
clientSecret: "secret"
|
||||
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
callbackURL: "/oauth2/callback"
|
||||
logoutURL: "https://app.example.com/oauth2/logout"
|
||||
`)
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("logoutURL with absolute URL must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Fix 2: excludedURLs must not prefix reserved paths
|
||||
func TestLoad_RejectsExcludedURLPrefixingReservedPath(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
listen: ":8080"
|
||||
providerURL: "https://idp.example"
|
||||
clientID: "abc"
|
||||
clientSecret: "secret"
|
||||
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
callbackURL: "/oauth2/callback"
|
||||
logoutURL: "/oauth2/logout"
|
||||
excludedURLs: ["/"]
|
||||
`)
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("excludedURLs: ['/'] must be rejected (bypasses all reserved paths)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AllowsNonOverlappingExcludedURL(t *testing.T) {
|
||||
path := writeConfig(t, minimalYAML+`excludedURLs: ["/public"]
|
||||
`)
|
||||
if _, err := Load(path); err != nil {
|
||||
t.Fatalf("non-overlapping excludedURL must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fix 3: env override coverage — every envScalarFields entry must have a
|
||||
// matching case in setScalarField. isAllZeroForField detects drift.
|
||||
func TestEnvOverrideCoverage(t *testing.T) {
|
||||
for _, field := range envScalarFields {
|
||||
field := field
|
||||
t.Run(field, func(t *testing.T) {
|
||||
probe := "/safe/probe-" + field
|
||||
if field == "SessionEncryptionKey" {
|
||||
probe = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
}
|
||||
if field == "LogLevel" {
|
||||
probe = "debug"
|
||||
}
|
||||
if field == "ClientAuthMethod" {
|
||||
probe = "client_secret_post"
|
||||
}
|
||||
if field == "ClientAssertionAlg" {
|
||||
probe = "RS256"
|
||||
}
|
||||
|
||||
var fresh Config
|
||||
setScalarField(&fresh, field, probe)
|
||||
if isAllZeroForField(&fresh, field, probe) {
|
||||
t.Fatalf("envScalarFields includes %q but setScalarField has no matching case (drift)", field)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isAllZeroForField returns true when setScalarField did NOT set the expected
|
||||
// field — i.e., the switch is missing a case for `field`.
|
||||
func isAllZeroForField(cfg *Config, field, probe string) bool {
|
||||
switch field {
|
||||
case "Listen":
|
||||
return cfg.Listen != probe
|
||||
case "AuthPath":
|
||||
return cfg.AuthPath != probe
|
||||
case "StartPath":
|
||||
return cfg.StartPath != probe
|
||||
case "ProviderURL":
|
||||
return cfg.OIDC.ProviderURL != probe
|
||||
case "ClientID":
|
||||
return cfg.OIDC.ClientID != probe
|
||||
case "ClientSecret":
|
||||
return cfg.OIDC.ClientSecret != probe
|
||||
case "Audience":
|
||||
return cfg.OIDC.Audience != probe
|
||||
case "CallbackURL":
|
||||
return cfg.OIDC.CallbackURL != probe
|
||||
case "LogoutURL":
|
||||
return cfg.OIDC.LogoutURL != probe
|
||||
case "PostLogoutRedirectURI":
|
||||
return cfg.OIDC.PostLogoutRedirectURI != probe
|
||||
case "SessionEncryptionKey":
|
||||
return cfg.OIDC.SessionEncryptionKey != probe
|
||||
case "CookiePrefix":
|
||||
return cfg.OIDC.CookiePrefix != probe
|
||||
case "CookieDomain":
|
||||
return cfg.OIDC.CookieDomain != probe
|
||||
case "LogLevel":
|
||||
return cfg.OIDC.LogLevel != probe
|
||||
case "RevocationURL":
|
||||
return cfg.OIDC.RevocationURL != probe
|
||||
case "OIDCEndSessionURL":
|
||||
return cfg.OIDC.OIDCEndSessionURL != probe
|
||||
case "UserIdentifierClaim":
|
||||
return cfg.OIDC.UserIdentifierClaim != probe
|
||||
case "GroupClaimName":
|
||||
return cfg.OIDC.GroupClaimName != probe
|
||||
case "RoleClaimName":
|
||||
return cfg.OIDC.RoleClaimName != probe
|
||||
case "ClientAuthMethod":
|
||||
return cfg.OIDC.ClientAuthMethod != probe
|
||||
case "ClientAssertionPrivateKey":
|
||||
return cfg.OIDC.ClientAssertionPrivateKey != probe
|
||||
case "ClientAssertionKeyPath":
|
||||
return cfg.OIDC.ClientAssertionKeyPath != probe
|
||||
case "ClientAssertionKeyID":
|
||||
return cfg.OIDC.ClientAssertionKeyID != probe
|
||||
case "ClientAssertionAlg":
|
||||
return cfg.OIDC.ClientAssertionAlg != probe
|
||||
case "CACertPath":
|
||||
return cfg.OIDC.CACertPath != probe
|
||||
case "CACertPEM":
|
||||
return cfg.OIDC.CACertPEM != probe
|
||||
}
|
||||
return true // unknown field → drift
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func main() {
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
success := newSuccessHandler()
|
||||
middleware, err := traefikoidc.NewWithContext(ctx, &cfg.OIDC, success, "oidcgate")
|
||||
@@ -42,11 +43,11 @@ func main() {
|
||||
if err := shutdown(srv); err != nil {
|
||||
log.Printf("oidcgate: shutdown error: %v", err)
|
||||
}
|
||||
cancel()
|
||||
}()
|
||||
|
||||
log.Printf("oidcgate: listening on %s", cfg.Listen)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
cancel()
|
||||
log.Fatalf("oidcgate: serve: %v", err)
|
||||
}
|
||||
log.Println("oidcgate: stopped")
|
||||
|
||||
Reference in New Issue
Block a user