From 48a19e04f830e182d1fbb35079515dd2a3ffbbc4 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 26 Jun 2026 11:49:31 +0100 Subject: [PATCH] fix(settings): tolerate whitespace in header template delimiters (#148) validateTemplateSecure matched safe functions with literal strings.Contains ("{{get "), so a space after {{ ("{{ get") failed the whitelist and Config.Validate() rejected the config, surfacing as Traefik's opaque "invalid handler type: ". Go's text/template treats {{ get and {{get identically, so the validator was stricter than the engine. Normalize inner-delimiter whitespace before matching. This also closes a security gap where a leading space let dangerous patterns ("{{ range", "{{ call") evade detection. Adds unit + regression coverage. --- coverage_boost_final_test.go | 42 +++++++++++++++++++++++++++++++++ regression/regression_test.go | 44 +++++++++++++++++++++++++++++++++++ settings.go | 28 ++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/coverage_boost_final_test.go b/coverage_boost_final_test.go index 952a6b3..004351f 100644 --- a/coverage_boost_final_test.go +++ b/coverage_boost_final_test.go @@ -750,6 +750,48 @@ func TestValidateTemplateSecure_CoverageBoost(t *testing.T) { template: "{{.Unknown}}", shouldError: true, }, + // Issue #148: whitespace after {{ and before }} must be tolerated + { + name: "get with leading space (issue #148)", + template: "{{ get .Claims \"email\" }}", + shouldError: false, + }, + { + name: "get with multiple leading spaces", + template: "{{ get .Claims \"email\"}}", + shouldError: false, + }, + { + name: "default with spaces", + template: "{{ default \"unknown\" .Claims.email }}", + shouldError: false, + }, + { + name: "claims with spaces", + template: "{{ .Claims.email }}", + shouldError: false, + }, + { + name: "access token with spaces", + template: "{{ .AccessToken }}", + shouldError: false, + }, + // Issue #148: space must NOT let dangerous patterns bypass detection + { + name: "range with leading space stays blocked", + template: "{{ range .Items }}{{.}}{{end}}", + shouldError: true, + }, + { + name: "call with leading space stays blocked", + template: "{{ call .Func }}", + shouldError: true, + }, + { + name: "disallowed claim with spaces stays blocked", + template: "{{ .Claims.password }}", + shouldError: true, + }, } for _, tt := range tests { diff --git a/regression/regression_test.go b/regression/regression_test.go index db2d924..b66570b 100644 --- a/regression/regression_test.go +++ b/regression/regression_test.go @@ -18,6 +18,50 @@ func TestIssueRegressions(t *testing.T) { t.Run("Issue60_Missing_Claim_Fields", testIssue60MissingClaimFields) t.Run("Issue60_Safe_Template_Functions", testIssue60SafeTemplateFunctions) t.Run("Issue60_Double_Processing_Concern", testIssue60DoubleProcessing) + t.Run("Issue148_Template_Whitespace_Tolerance", testIssue148TemplateWhitespace) +} + +// testIssue148TemplateWhitespace tests that header template validation tolerates +// whitespace inside Go template delimiters (e.g. "{{ get" with a leading space), +// which Go's text/template treats identically to "{{get". v1.0.26 rejected the +// spaced form, causing "invalid handler type: " at startup. See issue #148. +func testIssue148TemplateWhitespace(t *testing.T) { + baseConfig := func() *traefikoidc.Config { + c := traefikoidc.CreateConfig() + c.ProviderURL = "https://example.com" + c.ClientID = "test-client" + c.ClientSecret = "test-secret" + c.CallbackURL = "/callback" + c.SessionEncryptionKey = "test-encryption-key-32-characters" + return c + } + + // Spaced delimiters must validate identically to the compact form. + validSpaced := []traefikoidc.TemplatedHeader{ + {Name: "X-Get-Spaced", Value: "{{ get .Claims \"email\" }}"}, + {Name: "X-Get-MultiSpace", Value: "{{ get .Claims \"email\"}}"}, + {Name: "X-Default-Spaced", Value: "{{ default \"unknown\" .Claims.department }}"}, + {Name: "X-Claims-Spaced", Value: "{{ .Claims.email }}"}, + {Name: "X-AccessToken-Spaced", Value: "{{ .AccessToken }}"}, + } + for _, header := range validSpaced { + config := baseConfig() + config.Headers = []traefikoidc.TemplatedHeader{header} + assert.NoError(t, config.Validate(), + "Spaced template should validate: %s", header.Value) + } + + // A leading space must NOT smuggle a dangerous pattern past validation. + dangerousSpaced := []traefikoidc.TemplatedHeader{ + {Name: "X-Bad-Range", Value: "{{ range .Items }}{{.}}{{end}}"}, + {Name: "X-Bad-Call", Value: "{{ call .Func }}"}, + } + for _, header := range dangerousSpaced { + config := baseConfig() + config.Headers = []traefikoidc.TemplatedHeader{header} + assert.Error(t, config.Validate(), + "Spaced dangerous template must still be rejected: %s", header.Value) + } } // testIssue53CSRFRegression tests the specific issue reported in GitHub issue #53 diff --git a/settings.go b/settings.go index dfccd51..db89c72 100644 --- a/settings.go +++ b/settings.go @@ -9,10 +9,32 @@ import ( "net/http" "net/url" "os" + "regexp" "strconv" "strings" ) +// templateDelimWhitespace matches whitespace immediately inside Go template +// delimiters: after "{{" or before "}}". Go's text/template treats "{{ get", +// "{{ get" and "{{get" identically, so the security validator must collapse +// this whitespace before pattern-matching — otherwise a leading space both +// rejects valid templates (issue #148) and lets dangerous patterns such as +// "{{ range" slip past detection. +var templateDelimWhitespace = regexp.MustCompile(`\{\{[ \t]+|[ \t]+\}\}`) + +// normalizeTemplateDelimiters collapses inner-delimiter whitespace so that +// "{{ get .Claims \"x\" }}" becomes "{{get .Claims \"x\"}}" for matching. +// Trim markers ("{{-", "-}}") are left intact: the dash is not whitespace, so +// they remain detectable as dangerous patterns. +func normalizeTemplateDelimiters(templateStr string) string { + return templateDelimWhitespace.ReplaceAllStringFunc(templateStr, func(m string) string { + if strings.HasPrefix(m, "{{") { + return "{{" + } + return "}}" + }) +} + // TemplatedHeader represents a custom HTTP header with a templated value. // The value can contain template expressions that will be evaluated for each // authenticated request, such as {{.claims.email}} or {{.accessToken}}. @@ -569,6 +591,12 @@ func (c *Config) Validate() error { // It checks for dangerous template patterns that could lead to code execution or data leaks // while allowing safe custom functions for field access and default values. func validateTemplateSecure(templateStr string) error { + // Normalize inner-delimiter whitespace up front so that "{{ get .Claims }}" + // is validated identically to "{{get .Claims}}" (issue #148). All matching + // below operates on the normalized form, which also prevents a stray space + // from smuggling a dangerous pattern (e.g. "{{ range") past detection. + templateStr = normalizeTemplateDelimiters(templateStr) + // Allow our specific safe custom functions // These are added specifically to handle missing fields safely (issue #60) safeCustomFunctions := []string{