mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
* fix(settings): allow documented range + static header templates (#149)
Upgrading past the release where NewWithContext began calling
config.Validate() (546ceb9) broke `headers` configs that worked in 0.8.25:
New() returned a nil handler and Traefik logged the opaque "invalid handler
type: <nil>". Two header-validation checks were stricter than the documented,
legitimate usage:
- validateTemplateSecure rejected the documented multi-valued-claim join
"{{range $i,$e := .Claims.groups}}...{{end}}" as a dangerous {{range}},
even though README/CONFIGURATION show exactly that for groups/roles.
- Config.Validate required every header value to contain {{ }}, rejecting
static/literal values such as a shared proxy secret.
Fixes:
- Allow static/literal header values (reject only CR/LF/NUL header-injection
chars); flag unbalanced {{ }} explicitly.
- Allow {{range}} only when its clause targets .Claims.<field>; a range over
the bare .Claims map stays blocked and a decoy {{.Claims.x}} elsewhere no
longer launders it. Generalize the claims whitelist scan to .Claims. so a
claim reached via range/with/default is whitelisted identically to a direct
{{.Claims.x}} reference.
- normalizeTemplateDelimiters now collapses ALL whitespace inside {{ }}
(newline/CR/FF too), closing a newline-obfuscation bypass ({{\nrange,
{{\nindex) found in review.
- Log the concrete validation reason at construction so operators see WHY the
middleware failed to build instead of only "invalid handler type: <nil>".
Adds issue149_regression_test.go: pins the exact issue config, the documented
groups template rendering "admin,users" past the runtime sanitizer, static and
delimiter edge cases, and the security boundary (whole-Claims-map dump via
range, non-whitelisted claims, call/define/template/printf, and
newline-obfuscated actions all rejected).
* fix(settings): AST-based header template validation, close whitelist bypasses
Follow-up hardening to the #149 work. An adversarial review showed the
substring-based validateTemplateSecure could not actually bound what a header
template reads: get/index with arbitrary keys, {{with .Claims}} field access,
and a whitelisted-decoy + bare {{.}}/{{$}}/{{.Claims}} render all leaked the
whole root (every raw token) or the whole Claims map past validation. The core
problem is structural — the meaning of "." depends on scope, which substrings
cannot see.
Replace the substring validator with an AST walk (text/template/parse):
- Parse with only get/default known; every other function (printf, call, index,
js, urlquery, ...) then fails to parse and is rejected — no blocklist.
- Reject {{define}}/{{block}} (they produce extra trees).
- Walk the tree tracking whether "." is the root. Permit ONLY: literal text;
the four root token fields (AccessToken, IdToken, IDToken, RefreshToken);
whitelisted .Claims.<field>; range/with over a whitelisted .Claims.<field>
("." inside the body is the already-whitelisted element); get on .Claims with
a whitelisted literal key; default over safe values; {{if}} control flow and
$-scoped range/with/decl variables. Everything else — bare {{.}} / {{$}}
(whole-root dump), bare {{.Claims}} (whole-map dump), non-whitelisted claims,
$.Claims root reach — is rejected.
Also closes the get/default exfil and the with-over-bare-map vectors, and fixes
ID-token forwarding: templateData now carries both IDToken and IdToken and the
validator accepts {{.IdToken}} (documented) and {{.IDToken}}; previously the
documented spelling rendered empty and the working one was rejected.
Runtime `get` additionally enforces the claims whitelist on its key, and
safeClaimsFields is promoted to a package-level single source of truth shared by
the validator and the get helper. The header template funcMap is extracted to
headerTemplateFuncMap() so tests exercise the exact production map. Removes the
now-dead normalizeTemplateDelimiters/regex (the parser handles whitespace).
Extends issue149_regression_test.go: renders through the real funcMap to prove
get cannot leak; asserts the whole-root/whole-map/decoy/var-launder/index
vectors are rejected while every documented pattern (groups/roles join,
with .Claims.email, get .Claims "internal_role", default, both IdToken
spellings) still validates and renders.
* feat(settings): allowedClaims to extend the header-template claim whitelist
The AST validator and the runtime `get` only permit a fixed set of claim fields
in header value templates. Providers issue custom claims (e.g. employee_id) that
aren't built in, and there was no supported way to forward them — the previous
`get` escape hatch was closed by the security hardening.
Add `allowedClaims []string` to Config. Listed names extend the effective
whitelist for BOTH static validation and the runtime `get` (a single
claimsWhitelist() = built-in safeClaimsFields + AllowedClaims, so the two never
drift). Built-in claims need not be listed; unlisted claims stay rejected.
allowedClaims:
- employee_id
headers:
- name: X-Employee-Id
value: "{{`{{.Claims.employee_id}}`}}"
Implementation: the whitelist is threaded through the validator (now a small
templateValidator struct carrying the allowed set) and headerTemplateFuncMap
instead of the package global, so it is per-config with no shared mutable state.
Docs: README and docs/CONFIGURATION document the whitelist, the validation
behaviour, and allowedClaims (grounded in safeClaimsFields).
Tests: TestIssue149_AllowedClaimsExtendsWhitelist pins that a custom claim is
rejected by default and validates + renders once listed, via both direct access
and get, while an unlisted claim stays rejected. A regression assertion that
coupled to the old validator's "dangerous" wording now checks the stable
"failed security validation" wrapper (the templates are still rejected).
423 lines
16 KiB
Go
423 lines
16 KiB
Go
package regression
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
traefikoidc "github.com/lukaszraczylo/traefikoidc"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestIssueRegressions consolidates regression tests for reported GitHub issues
|
|
func TestIssueRegressions(t *testing.T) {
|
|
t.Run("Issue53_CSRF_Missing_In_Session", testIssue53CSRFRegression)
|
|
t.Run("Issue53_Reverse_Proxy_HTTPS_Detection", testIssue53ReverseProxyHTTPS)
|
|
t.Run("Issue53_SameSite_Cookie_Handling", testIssue53SameSiteCookies)
|
|
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: <nil>" 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
|
|
// where Azure OIDC authentication fails with "CSRF token missing in session"
|
|
// This was caused by incorrect HTTPS detection in reverse proxy environments
|
|
func testIssue53CSRFRegression(t *testing.T) {
|
|
// This test reproduces the exact scenario from issue #53:
|
|
// 1. User accesses app via HTTPS through Traefik
|
|
// 2. Traefik terminates SSL and forwards HTTP internally
|
|
// 3. Session cookies must be properly configured for HTTPS
|
|
// 4. CSRF token must persist through the OAuth flow
|
|
|
|
sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, traefikoidc.NewLogger("debug"))
|
|
require.NoError(t, err)
|
|
|
|
// Step 1: Initial request to protected resource
|
|
// User accesses https://app.example.com/protected
|
|
// Traefik forwards as http://internal/protected with X-Forwarded-Proto: https
|
|
initReq := httptest.NewRequest("GET", "http://internal/protected", nil)
|
|
initReq.Header.Set("X-Forwarded-Proto", "https")
|
|
initReq.Header.Set("X-Forwarded-Host", "app.example.com")
|
|
initReq.Header.Set("User-Agent", "Mozilla/5.0") // Real browser
|
|
|
|
// Get session and set OAuth flow data
|
|
session, err := sessionManager.GetSession(initReq)
|
|
require.NoError(t, err)
|
|
|
|
// Set CSRF and other OAuth data
|
|
csrfToken := "csrf-token-for-azure"
|
|
nonce := "nonce-for-azure"
|
|
session.SetCSRF(csrfToken)
|
|
session.SetNonce(nonce)
|
|
session.SetCodeVerifier("pkce-verifier")
|
|
session.SetIncomingPath("/protected")
|
|
session.MarkDirty()
|
|
|
|
// Save session - this is where the bug was
|
|
// Previously: used r.URL.Scheme which is always "http" behind proxy
|
|
// Now: uses X-Forwarded-Proto header
|
|
rec := httptest.NewRecorder()
|
|
err = session.Save(initReq, rec)
|
|
require.NoError(t, err)
|
|
|
|
// Verify cookies are secure
|
|
cookies := rec.Result().Cookies()
|
|
require.NotEmpty(t, cookies, "Cookies must be set")
|
|
|
|
var mainCookie *http.Cookie
|
|
for _, cookie := range cookies {
|
|
if cookie.Name == "_oidc_raczylo_m" {
|
|
mainCookie = cookie
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, mainCookie, "Main session cookie must be set")
|
|
|
|
// Critical assertions for issue #53
|
|
assert.True(t, mainCookie.Secure, "Cookie MUST have Secure flag for HTTPS (was the bug)")
|
|
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite, "MUST use Lax for OAuth callbacks to work")
|
|
assert.Equal(t, "/", mainCookie.Path, "Cookie path must be root")
|
|
assert.True(t, mainCookie.HttpOnly, "Cookie must be HttpOnly")
|
|
assert.Equal(t, "app.example.com", mainCookie.Domain, "Domain should use X-Forwarded-Host")
|
|
|
|
// Step 2: OAuth provider redirects back to callback
|
|
// Azure redirects to https://app.example.com/oidc/callback?code=...&state=...
|
|
// Traefik forwards as http://internal/oidc/callback with headers
|
|
callbackReq := httptest.NewRequest("GET",
|
|
"http://internal/oidc/callback?code=azure-auth-code&state="+csrfToken, nil)
|
|
callbackReq.Header.Set("X-Forwarded-Proto", "https")
|
|
callbackReq.Header.Set("X-Forwarded-Host", "app.example.com")
|
|
callbackReq.Header.Set("User-Agent", "Mozilla/5.0")
|
|
|
|
// Add cookies from initial request
|
|
// Browser sends secure cookies because request is HTTPS
|
|
for _, cookie := range cookies {
|
|
callbackReq.AddCookie(cookie)
|
|
}
|
|
|
|
// Get session in callback
|
|
callbackSession, err := sessionManager.GetSession(callbackReq)
|
|
require.NoError(t, err)
|
|
|
|
// Verify CSRF token is present (was missing in issue #53)
|
|
retrievedCSRF := callbackSession.GetCSRF()
|
|
assert.Equal(t, csrfToken, retrievedCSRF,
|
|
"CSRF token MUST persist (was missing in issue #53)")
|
|
|
|
// Verify other session data also persists
|
|
assert.Equal(t, nonce, callbackSession.GetNonce(),
|
|
"Nonce must persist for security")
|
|
assert.Equal(t, "pkce-verifier", callbackSession.GetCodeVerifier(),
|
|
"PKCE verifier must persist")
|
|
assert.Equal(t, "/protected", callbackSession.GetIncomingPath(),
|
|
"Original path must persist for redirect after auth")
|
|
}
|
|
|
|
// testIssue53ReverseProxyHTTPS tests HTTPS detection in reverse proxy setups
|
|
func testIssue53ReverseProxyHTTPS(t *testing.T) {
|
|
sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, traefikoidc.NewLogger("debug"))
|
|
require.NoError(t, err)
|
|
|
|
// Create authenticated session with Azure tokens
|
|
req := httptest.NewRequest("GET", "http://internal/api/data", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
req.Header.Set("X-Forwarded-Host", "app.example.com")
|
|
|
|
session, err := sessionManager.GetSession(req)
|
|
require.NoError(t, err)
|
|
|
|
// Simulate successful Azure authentication
|
|
session.SetAuthenticated(true)
|
|
session.SetUserIdentifier("[email protected]")
|
|
// Azure may use opaque access tokens
|
|
session.SetAccessToken("opaque-azure-access-token")
|
|
session.SetIDToken("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ") // trufflehog:ignore
|
|
session.SetRefreshToken("azure-refresh-token")
|
|
|
|
// Save with proper security
|
|
rec := httptest.NewRecorder()
|
|
err = session.Save(req, rec)
|
|
require.NoError(t, err)
|
|
|
|
// Verify session can be retrieved and tokens are intact
|
|
cookies := rec.Result().Cookies()
|
|
req2 := httptest.NewRequest("GET", "http://internal/api/data", nil)
|
|
req2.Header.Set("X-Forwarded-Proto", "https")
|
|
for _, cookie := range cookies {
|
|
req2.AddCookie(cookie)
|
|
}
|
|
|
|
session2, err := sessionManager.GetSession(req2)
|
|
require.NoError(t, err)
|
|
|
|
assert.True(t, session2.GetAuthenticated(), "User should remain authenticated")
|
|
assert.Equal(t, "[email protected]", session2.GetUserIdentifier())
|
|
assert.NotEmpty(t, session2.GetAccessToken(), "Access token should persist")
|
|
assert.NotEmpty(t, session2.GetIDToken(), "ID token should persist")
|
|
assert.NotEmpty(t, session2.GetRefreshToken(), "Refresh token should persist")
|
|
|
|
// Test redirect loop prevention
|
|
for i := 0; i < 3; i++ {
|
|
session2.IncrementRedirectCount()
|
|
}
|
|
|
|
// Verify redirect count is tracked
|
|
count := session2.GetRedirectCount()
|
|
assert.Equal(t, 3, count, "Redirect count should be tracked")
|
|
|
|
// After successful auth, count should be reset
|
|
session2.SetAuthenticated(true)
|
|
session2.ResetRedirectCount()
|
|
assert.Equal(t, 0, session2.GetRedirectCount(), "Count should reset after auth")
|
|
}
|
|
|
|
// testIssue53SameSiteCookies tests SameSite cookie attribute handling
|
|
// in different reverse proxy scenarios
|
|
func testIssue53SameSiteCookies(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
proto string
|
|
description string
|
|
expectedSameSite http.SameSite
|
|
expectedSecure bool
|
|
}{
|
|
{
|
|
name: "HTTPS via proxy",
|
|
proto: "https",
|
|
expectedSecure: true,
|
|
expectedSameSite: http.SameSiteLaxMode,
|
|
description: "HTTPS should use Lax SameSite for OAuth callbacks",
|
|
},
|
|
{
|
|
name: "HTTP direct",
|
|
proto: "",
|
|
expectedSecure: false,
|
|
expectedSameSite: http.SameSiteLaxMode,
|
|
description: "HTTP should use Lax SameSite for compatibility",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, traefikoidc.NewLogger("debug"))
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequest("GET", "http://internal/test", nil)
|
|
if tc.proto != "" {
|
|
req.Header.Set("X-Forwarded-Proto", tc.proto)
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
|
|
session, err := sessionManager.GetSession(req)
|
|
require.NoError(t, err)
|
|
session.SetCSRF("test")
|
|
|
|
rec := httptest.NewRecorder()
|
|
err = session.Save(req, rec)
|
|
require.NoError(t, err)
|
|
|
|
cookies := rec.Result().Cookies()
|
|
for _, cookie := range cookies {
|
|
if cookie.Name == "_oidc_raczylo_m" {
|
|
assert.Equal(t, tc.expectedSecure, cookie.Secure, tc.description)
|
|
assert.Equal(t, tc.expectedSameSite, cookie.SameSite, tc.description)
|
|
break
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// testIssue60MissingClaimFields tests handling of missing claim fields (GitHub issue #60)
|
|
func testIssue60MissingClaimFields(t *testing.T) {
|
|
config := traefikoidc.CreateConfig()
|
|
config.ProviderURL = "https://example.com"
|
|
config.ClientID = "test-client"
|
|
config.ClientSecret = "test-secret"
|
|
config.CallbackURL = "/callback"
|
|
config.SessionEncryptionKey = "test-encryption-key-32-characters"
|
|
|
|
testCases := []struct {
|
|
name string
|
|
description string
|
|
headers []traefikoidc.TemplatedHeader
|
|
shouldValidate bool
|
|
}{
|
|
{
|
|
name: "Direct claim access",
|
|
headers: []traefikoidc.TemplatedHeader{
|
|
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
|
|
{Name: "X-Internal-Role", Value: "{{.Claims.internal_role}}"},
|
|
},
|
|
shouldValidate: true,
|
|
description: "Direct claim access should validate",
|
|
},
|
|
{
|
|
name: "Azure AD claims",
|
|
headers: []traefikoidc.TemplatedHeader{
|
|
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
|
|
{Name: "X-User-OID", Value: "{{.Claims.oid}}"},
|
|
{Name: "X-User-TID", Value: "{{.Claims.tid}}"},
|
|
{Name: "X-User-UPN", Value: "{{.Claims.upn}}"},
|
|
{Name: "X-Internal-Role", Value: "{{.Claims.internal_role}}"}, // Custom claim from issue #60
|
|
},
|
|
shouldValidate: true,
|
|
description: "Azure AD claims should validate",
|
|
},
|
|
{
|
|
name: "Valid context fields",
|
|
headers: []traefikoidc.TemplatedHeader{
|
|
{Name: "X-Access-Token", Value: "{{.AccessToken}}"},
|
|
{Name: "X-ID-Token", Value: "{{.IdToken}}"},
|
|
{Name: "X-Refresh-Token", Value: "{{.RefreshToken}}"},
|
|
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
|
|
{Name: "X-User-Sub", Value: "{{.Claims.sub}}"},
|
|
},
|
|
shouldValidate: true,
|
|
description: "All valid context fields should pass validation",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
config.Headers = tc.headers
|
|
err := config.Validate()
|
|
if tc.shouldValidate {
|
|
assert.NoError(t, err, tc.description)
|
|
} else {
|
|
assert.Error(t, err, tc.description)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// testIssue60SafeTemplateFunctions tests safe template functions for handling missing fields
|
|
func testIssue60SafeTemplateFunctions(t *testing.T) {
|
|
config := traefikoidc.CreateConfig()
|
|
config.ProviderURL = "https://example.com"
|
|
config.ClientID = "test-client"
|
|
config.ClientSecret = "test-secret"
|
|
config.CallbackURL = "/callback"
|
|
config.SessionEncryptionKey = "test-encryption-key-32-characters"
|
|
|
|
// Templates using safe functions for missing fields
|
|
config.Headers = []traefikoidc.TemplatedHeader{
|
|
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
|
|
{Name: "X-User-Role", Value: "{{get .Claims \"internal_role\"}}"},
|
|
{Name: "X-User-Dept", Value: "{{default \"unknown\" .Claims.department}}"},
|
|
{Name: "X-User-Groups", Value: "{{with .Claims.groups}}{{.}}{{end}}"},
|
|
}
|
|
|
|
// Configuration should validate successfully
|
|
err := config.Validate()
|
|
assert.NoError(t, err, "Config with safe template functions should validate")
|
|
|
|
// Test that dangerous templates are rejected
|
|
dangerousTemplates := []traefikoidc.TemplatedHeader{
|
|
{Name: "X-Bad-1", Value: "{{call .SomeFunc}}"},
|
|
{Name: "X-Bad-2", Value: "{{range .Items}}{{.}}{{end}}"},
|
|
{Name: "X-Bad-3", Value: "{{index .Array 0}}"},
|
|
{Name: "X-Bad-4", Value: "{{printf \"%s\" .Data}}"},
|
|
}
|
|
|
|
for _, header := range dangerousTemplates {
|
|
config.Headers = []traefikoidc.TemplatedHeader{header}
|
|
err := config.Validate()
|
|
require.Error(t, err, "Dangerous template should be rejected: %s", header.Value)
|
|
// The template validator (template_validation.go) rejects these; Config.Validate
|
|
// wraps the reason with this stable prefix. Assert the behavior (rejected by
|
|
// the security validator) rather than a specific message word.
|
|
assert.Contains(t, err.Error(), "failed security validation", "should be rejected by the template security validator")
|
|
}
|
|
|
|
// Test all safe patterns from the documentation
|
|
safePatterns := []traefikoidc.TemplatedHeader{
|
|
// Basic field access
|
|
{Name: "X-User-Role", Value: "{{.Claims.internal_role}}"},
|
|
// Using the get function
|
|
{Name: "X-User-Role-Get", Value: "{{get .Claims \"internal_role\"}}"},
|
|
// Using the default function
|
|
{Name: "X-User-Role-Default", Value: "{{default \"guest\" .Claims.role}}"},
|
|
// Nested fields with 'with'
|
|
{Name: "X-User-Admin", Value: "{{with .Claims.groups}}{{.admin}}{{end}}"},
|
|
}
|
|
|
|
config.Headers = safePatterns
|
|
err = config.Validate()
|
|
assert.NoError(t, err, "All safe patterns from guide should validate")
|
|
}
|
|
|
|
// testIssue60DoubleProcessing tests the user's concern about double processing of templates
|
|
func testIssue60DoubleProcessing(t *testing.T) {
|
|
// The user was concerned that templates might be processed twice:
|
|
// 1. Once when Traefik parses the config
|
|
// 2. Once when the plugin executes the template
|
|
|
|
// This test verifies that templates are stored as strings during config parsing
|
|
config := &traefikoidc.Config{
|
|
Headers: []traefikoidc.TemplatedHeader{
|
|
{Name: "X-Test", Value: "{{.Claims.test}}"},
|
|
},
|
|
}
|
|
|
|
// The template should still be a raw string after config creation
|
|
assert.Equal(t, "{{.Claims.test}}", config.Headers[0].Value,
|
|
"Template should remain as raw string in config")
|
|
|
|
// Test that our custom function syntax survives config marshaling/unmarshaling
|
|
originalValue := `{{get .Claims "internal_role"}}`
|
|
header := traefikoidc.TemplatedHeader{
|
|
Name: "X-Role",
|
|
Value: originalValue,
|
|
}
|
|
|
|
// Even after any marshaling/unmarshaling, the template string should be preserved
|
|
assert.Equal(t, originalValue, header.Value,
|
|
"Template with functions should be preserved exactly")
|
|
}
|