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).
363 lines
14 KiB
Go
363 lines
14 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"text/template/parse"
|
|
)
|
|
|
|
// allowedRootTokenFields are the non-Claims values a header template may render
|
|
// directly from the root data context. Both ID-token spellings are accepted
|
|
// (templateData carries IDToken and IdToken).
|
|
var allowedRootTokenFields = map[string]bool{
|
|
"AccessToken": true,
|
|
"IdToken": true,
|
|
"IDToken": true,
|
|
"RefreshToken": true,
|
|
}
|
|
|
|
// validateTemplateParseFuncs is the function set the parser is told about. Only
|
|
// get/default exist; every other identifier used as a command (printf, call,
|
|
// index, js, ...) then fails to parse and is rejected — no blocklist needed.
|
|
var validateTemplateParseFuncs = map[string]any{
|
|
"get": struct{}{},
|
|
"default": struct{}{},
|
|
}
|
|
|
|
// claimsWhitelist returns the effective set of claim field names a header
|
|
// template may emit: the built-in safeClaimsFields plus any operator-configured
|
|
// extras (config.AllowedClaims). Blank/whitespace extras are ignored. The result
|
|
// gates BOTH static validation and the runtime get helper so the two never drift.
|
|
func claimsWhitelist(extra []string) map[string]bool {
|
|
m := make(map[string]bool, len(safeClaimsFields)+len(extra))
|
|
for k := range safeClaimsFields {
|
|
m[k] = true
|
|
}
|
|
for _, c := range extra {
|
|
if c = strings.TrimSpace(c); c != "" {
|
|
m[c] = true
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// templateValidator walks a parsed header template against a specific claims
|
|
// whitelist (built-in + operator-configured). Holding the set on the walker
|
|
// keeps it out of every method signature.
|
|
type templateValidator struct {
|
|
allowed map[string]bool
|
|
}
|
|
|
|
// validateTemplateSecure parses a header value template and walks its AST,
|
|
// permitting ONLY: literal text; the whitelisted root token fields; whitelisted
|
|
// .Claims.<field> access; range/with over a whitelisted .Claims.<field>; the
|
|
// safe get/default helpers; and {{if}} control flow. Everything else — bare
|
|
// {{.}}/{{$}} (whole-root dump), bare {{.Claims}} (whole-map dump), non-
|
|
// whitelisted claims, unknown functions, define/template/block — is rejected.
|
|
//
|
|
// allowed is the effective claim whitelist (see claimsWhitelist); a nil set
|
|
// falls back to the built-in safeClaimsFields.
|
|
//
|
|
// Parsing (not substring matching) is what makes this sound: the parser resolves
|
|
// whitespace, comments and the meaning of "." per scope, so obfuscation and the
|
|
// "whitelisted decoy + bare-map render" bypass class cannot slip through.
|
|
func validateTemplateSecure(templateStr string, allowed map[string]bool) error {
|
|
if allowed == nil {
|
|
allowed = safeClaimsFields
|
|
}
|
|
trees, err := parse.Parse("headerTemplate", templateStr, "{{", "}}", validateTemplateParseFuncs)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid template: %w", err)
|
|
}
|
|
// {{define}}/{{block}} produce additional named trees; a header value template
|
|
// must be a single anonymous template, so reject anything that defines more.
|
|
if len(trees) != 1 {
|
|
return fmt.Errorf("template definitions ({{define}}/{{block}}) are not allowed")
|
|
}
|
|
tree, ok := trees["headerTemplate"]
|
|
if !ok || tree.Root == nil {
|
|
return fmt.Errorf("invalid template: empty parse tree")
|
|
}
|
|
v := &templateValidator{allowed: allowed}
|
|
// dotIsRoot=true: "." is the whole data context at the top level. Inside a
|
|
// range/with over a claims field it rebinds to that (already-whitelisted)
|
|
// element, so relative access there is safe.
|
|
return v.walkNode(tree.Root, true)
|
|
}
|
|
|
|
// walkNode validates a node and its children. dotIsRoot reports whether "."
|
|
// currently refers to the root data context (true) or to a whitelisted claim
|
|
// element rebound by an enclosing range/with (false).
|
|
func (v *templateValidator) walkNode(n parse.Node, dotIsRoot bool) error {
|
|
switch node := n.(type) {
|
|
case nil:
|
|
return nil
|
|
case *parse.ListNode:
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
for _, child := range node.Nodes {
|
|
if err := v.walkNode(child, dotIsRoot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
case *parse.TextNode:
|
|
return nil
|
|
case *parse.ActionNode:
|
|
return v.validatePipe(node.Pipe, dotIsRoot)
|
|
case *parse.IfNode:
|
|
return v.walkBranch(node.Pipe, node.List, node.ElseList, dotIsRoot, dotIsRoot)
|
|
case *parse.WithNode:
|
|
// with rebinds "." to the target inside its body; the target must be a
|
|
// whitelisted claim, so the body runs with dotIsRoot=false.
|
|
if err := v.validateClaimSelector(node.Pipe, dotIsRoot, "with"); err != nil {
|
|
return err
|
|
}
|
|
return v.walkBranch(nil, node.List, node.ElseList, false, dotIsRoot)
|
|
case *parse.RangeNode:
|
|
// range iterates the target and rebinds "." to each element; the target
|
|
// must be a whitelisted claim collection, so the body is dotIsRoot=false.
|
|
if err := v.validateClaimSelector(node.Pipe, dotIsRoot, "range"); err != nil {
|
|
return err
|
|
}
|
|
return v.walkBranch(nil, node.List, node.ElseList, false, dotIsRoot)
|
|
case *parse.TemplateNode:
|
|
return fmt.Errorf("template inclusion is not allowed")
|
|
default:
|
|
return fmt.Errorf("unsupported template construct: %s", n.String())
|
|
}
|
|
}
|
|
|
|
// walkBranch validates an if/with/range: an optional condition pipe (evaluated
|
|
// in the outer scope), the body (bodyDotIsRoot), and the else branch (outer
|
|
// scope). For with/range the condition pipe is validated by the caller and
|
|
// condPipe is nil here.
|
|
func (v *templateValidator) walkBranch(condPipe *parse.PipeNode, body, elseList *parse.ListNode, bodyDotIsRoot, outerDotIsRoot bool) error {
|
|
if condPipe != nil {
|
|
if err := v.validatePipe(condPipe, outerDotIsRoot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := v.walkNode(body, bodyDotIsRoot); err != nil {
|
|
return err
|
|
}
|
|
if elseList != nil {
|
|
if err := v.walkNode(elseList, outerDotIsRoot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validatePipe validates every command in a pipeline, including the right-hand
|
|
// side of any variable declaration ($x := ...). Declared vars therefore always
|
|
// hold already-validated values, so later references to them are safe.
|
|
func (v *templateValidator) validatePipe(pipe *parse.PipeNode, dotIsRoot bool) error {
|
|
if pipe == nil {
|
|
return nil
|
|
}
|
|
for _, cmd := range pipe.Cmds {
|
|
if err := v.validateCommand(cmd, dotIsRoot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateCommand validates a single command (one stage of a pipeline). The
|
|
// first argument may be the get/default function; the rest, and every argument
|
|
// of a non-function command, must be a safe value expression.
|
|
func (v *templateValidator) validateCommand(cmd *parse.CommandNode, dotIsRoot bool) error {
|
|
if cmd == nil || len(cmd.Args) == 0 {
|
|
return nil
|
|
}
|
|
if id, ok := cmd.Args[0].(*parse.IdentifierNode); ok {
|
|
return v.validateFunctionCall(id.Ident, cmd.Args[1:], dotIsRoot)
|
|
}
|
|
for _, arg := range cmd.Args {
|
|
if err := v.validateValue(arg, dotIsRoot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateFunctionCall allows only get/default. get's map argument must be a
|
|
// claims reference (never the root) and default's arguments are validated as
|
|
// values; the runtime get additionally enforces the key whitelist.
|
|
func (v *templateValidator) validateFunctionCall(name string, args []parse.Node, dotIsRoot bool) error {
|
|
switch name {
|
|
case "default":
|
|
for _, arg := range args {
|
|
if err := v.validateValue(arg, dotIsRoot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
case "get":
|
|
if len(args) < 1 {
|
|
return fmt.Errorf("get requires a map argument")
|
|
}
|
|
// The map argument must reference .Claims, never the root map (which
|
|
// would expose tokens / the whole Claims map). Remaining args (the key,
|
|
// possibly a pipeline) are validated as values.
|
|
if err := v.validateClaimsReference(args[0], dotIsRoot); err != nil {
|
|
return fmt.Errorf("get is only allowed on .Claims: %w", err)
|
|
}
|
|
// A literal key must be whitelisted (matches direct .Claims.<field>
|
|
// strictness); a dynamic key is enforced by the runtime get.
|
|
if len(args) >= 2 {
|
|
if key, ok := args[1].(*parse.StringNode); ok && !v.allowed[key.Text] {
|
|
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", key.Text)
|
|
}
|
|
}
|
|
for _, arg := range args[1:] {
|
|
if err := v.validateValue(arg, dotIsRoot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("function %q is not allowed in header templates", name)
|
|
}
|
|
}
|
|
|
|
// validateValue validates an expression that may be rendered or passed as an
|
|
// argument. It rejects anything resolving to the whole root or the whole Claims
|
|
// map, and any non-whitelisted claim.
|
|
func (v *templateValidator) validateValue(node parse.Node, dotIsRoot bool) error {
|
|
switch val := node.(type) {
|
|
case *parse.StringNode, *parse.NumberNode, *parse.BoolNode, *parse.NilNode:
|
|
return nil
|
|
case *parse.DotNode:
|
|
// "." is the whole root at the top level (dumps tokens + all claims); it
|
|
// is a single already-whitelisted claim element inside a range/with body.
|
|
if dotIsRoot {
|
|
return fmt.Errorf("rendering the whole data context ({{.}}) is not allowed")
|
|
}
|
|
return nil
|
|
case *parse.FieldNode:
|
|
return v.validateFieldAccess(val.Ident, dotIsRoot)
|
|
case *parse.VariableNode:
|
|
return v.validateVariableAccess(val.Ident)
|
|
case *parse.PipeNode:
|
|
return v.validatePipe(val, dotIsRoot)
|
|
case *parse.ChainNode:
|
|
// (base).Field.Field — the field chain only narrows access, so validating
|
|
// the base value is sufficient.
|
|
return v.validateValue(val.Node, dotIsRoot)
|
|
default:
|
|
return fmt.Errorf("unsupported template expression: %s", node.String())
|
|
}
|
|
}
|
|
|
|
// validateFieldAccess validates a dot-relative field access ".A.B.C" given
|
|
// whether "." is the root. In an element scope (dotIsRoot=false) the element is
|
|
// already a whitelisted claim, so any sub-field access is safe.
|
|
func (v *templateValidator) validateFieldAccess(idents []string, dotIsRoot bool) error {
|
|
if !dotIsRoot {
|
|
return nil
|
|
}
|
|
if len(idents) == 0 {
|
|
return fmt.Errorf("rendering the whole data context is not allowed")
|
|
}
|
|
root := idents[0]
|
|
if allowedRootTokenFields[root] {
|
|
return nil
|
|
}
|
|
if root == "Claims" {
|
|
if len(idents) < 2 {
|
|
return fmt.Errorf("rendering the whole Claims map ({{.Claims}}) is not allowed")
|
|
}
|
|
if !v.allowed[idents[1]] {
|
|
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", idents[1])
|
|
}
|
|
// By design, only the top-level claim is whitelisted; deeper subfields
|
|
// (idents[2:]) of a map-valued whitelisted claim such as realm_access /
|
|
// resource_access are forwarded as-is. Whitelisting a map claim means
|
|
// opting into its nested contents, and the documented Keycloak pattern
|
|
// {{with .Claims.realm_access}}{{.roles}}{{end}} relies on it; nested
|
|
// provider keys can't be enumerated to whitelist individually.
|
|
return nil
|
|
}
|
|
return fmt.Errorf("access to .%s is not allowed in header templates", root)
|
|
}
|
|
|
|
// validateVariableAccess validates a $-variable reference. The bare root ("$"
|
|
// and "$.x") is treated as a root field access; a named range/with/declaration
|
|
// variable holds an already-validated value, so it and its sub-fields are safe.
|
|
func (v *templateValidator) validateVariableAccess(idents []string) error {
|
|
if len(idents) == 0 {
|
|
return nil
|
|
}
|
|
if idents[0] == "$" {
|
|
// "$" is the root: "$" alone dumps everything; "$.Claims.email" is a
|
|
// root-absolute field access validated like ".Claims.email".
|
|
if len(idents) == 1 {
|
|
return fmt.Errorf("rendering the whole data context ({{$}}) is not allowed")
|
|
}
|
|
return v.validateFieldAccess(idents[1:], true)
|
|
}
|
|
// Named variable ($e, $i, $x): holds a validated element/index/value.
|
|
return nil
|
|
}
|
|
|
|
// validateClaimsReference requires a node to reference .Claims (or $.Claims),
|
|
// never the root map — used for get's map argument, whose key is separately
|
|
// enforced against the whitelist at render time.
|
|
func (v *templateValidator) validateClaimsReference(node parse.Node, dotIsRoot bool) error {
|
|
switch ref := node.(type) {
|
|
case *parse.FieldNode:
|
|
if dotIsRoot && len(ref.Ident) >= 1 && ref.Ident[0] == "Claims" {
|
|
if len(ref.Ident) >= 2 && !v.allowed[ref.Ident[1]] {
|
|
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", ref.Ident[1])
|
|
}
|
|
return nil
|
|
}
|
|
case *parse.VariableNode:
|
|
if len(ref.Ident) >= 2 && ref.Ident[0] == "$" && ref.Ident[1] == "Claims" {
|
|
if len(ref.Ident) >= 3 && !v.allowed[ref.Ident[2]] {
|
|
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", ref.Ident[2])
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("expected a .Claims reference")
|
|
}
|
|
|
|
// validateClaimSelector validates the target of a range/with: it must iterate a
|
|
// specific whitelisted claim field (.Claims.<field> or $.Claims.<field>), never
|
|
// the bare Claims map or any non-claims value.
|
|
func (v *templateValidator) validateClaimSelector(pipe *parse.PipeNode, dotIsRoot bool, kind string) error {
|
|
if pipe == nil || len(pipe.Cmds) == 0 {
|
|
return fmt.Errorf("%s requires a .Claims.<field> target", kind)
|
|
}
|
|
cmd := pipe.Cmds[len(pipe.Cmds)-1]
|
|
if len(cmd.Args) == 0 {
|
|
return fmt.Errorf("%s requires a .Claims.<field> target", kind)
|
|
}
|
|
target := cmd.Args[0]
|
|
if err := v.validateClaimField(target, dotIsRoot); err != nil {
|
|
return fmt.Errorf("%s is only allowed over a whitelisted claims field, e.g. {{range $i, $e := .Claims.groups}} or {{with .Claims.email}}: %w", kind, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateClaimField requires a node to be a specific whitelisted claim field
|
|
// (.Claims.<whitelisted> or $.Claims.<whitelisted>) — stricter than
|
|
// validateClaimsReference, which also permits the bare .Claims map for get.
|
|
func (v *templateValidator) validateClaimField(node parse.Node, dotIsRoot bool) error {
|
|
switch field := node.(type) {
|
|
case *parse.FieldNode:
|
|
if dotIsRoot && len(field.Ident) >= 2 && field.Ident[0] == "Claims" && v.allowed[field.Ident[1]] {
|
|
return nil
|
|
}
|
|
case *parse.VariableNode:
|
|
if len(field.Ident) >= 3 && field.Ident[0] == "$" && field.Ident[1] == "Claims" && v.allowed[field.Ident[2]] {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("not a whitelisted .Claims.<field>")
|
|
}
|