From 841e85744245b70d13075e9d140ef55b60bab793 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 8 Apr 2026 01:18:25 -0700 Subject: [PATCH 1/8] Document envoy_xfcc auth plugin usage --- .../plugins/envoy_xfcc/envoy_xfcc_test.go | 249 ++++++++++++++++++ app/auth/plugins/envoy_xfcc/incoming.go | 211 +++++++++++++++ app/auth/plugins/plugins.go | 1 + docs/auth-plugins.md | 24 ++ 4 files changed, 485 insertions(+) create mode 100644 app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go create mode 100644 app/auth/plugins/envoy_xfcc/incoming.go diff --git a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go new file mode 100644 index 0000000..966beff --- /dev/null +++ b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go @@ -0,0 +1,249 @@ +package envoy_xfcc + +import ( + "context" + "net/http" + "testing" +) + +func TestEnvoyXFCCSingleElementAllowed(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{"allowed_uris": []string{"spiffe://example/ns/default/sa/caller"}}) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{"X-Forwarded-Client-Cert": []string{"By=spiffe://proxy;URI=spiffe://example/ns/default/sa/caller"}}} + if !p.Authenticate(context.Background(), r, cfg) { + t.Fatal("expected authentication success") + } +} + +func TestEnvoyXFCCMissingHeaderFails(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{"allowed_uris": []string{"spiffe://allowed"}}) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{}} + if p.Authenticate(context.Background(), r, cfg) { + t.Fatal("expected missing header to fail") + } +} + +func TestEnvoyXFCCDisallowedURIFails(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{"allowed_uris": []string{"spiffe://allowed"}}) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{"X-Forwarded-Client-Cert": []string{"URI=spiffe://denied"}}} + if p.Authenticate(context.Background(), r, cfg) { + t.Fatal("expected disallowed uri to fail") + } +} + +func TestEnvoyXFCCMultipleNonIgnoredURIsFails(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{"allowed_uri_prefixes": []string{"spiffe://"}}) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{"X-Forwarded-Client-Cert": []string{"URI=spiffe://caller-a,URI=spiffe://caller-b"}}} + if p.Authenticate(context.Background(), r, cfg) { + t.Fatal("expected multiple caller identities to fail") + } +} + +func TestEnvoyXFCCMultipleURIFieldsInElementFails(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{"allowed_uri_prefixes": []string{"spiffe://"}}) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{"X-Forwarded-Client-Cert": []string{"URI=spiffe://caller-a;URI=spiffe://caller-b"}}} + if p.Authenticate(context.Background(), r, cfg) { + t.Fatal("expected multiple URI fields in one element to fail") + } +} + +func TestEnvoyXFCCIgnoredProxyAndAllowedCallerSucceeds(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{ + "allowed_uris": []string{"spiffe://cluster.local/ns/team/sa/caller"}, + "ignored_uris": []string{"spiffe://cluster.local/ns/gw/sa/envoy"}, + }) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{"X-Forwarded-Client-Cert": []string{"URI=spiffe://cluster.local/ns/gw/sa/envoy,URI=spiffe://cluster.local/ns/team/sa/caller"}}} + if !p.Authenticate(context.Background(), r, cfg) { + t.Fatal("expected caller URI to be accepted after ignoring gateway URI") + } +} + +func TestEnvoyXFCCQuotedSubjectWithSeparatorsParses(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{"allowed_uris": []string{"spiffe://example/ns/default/sa/caller"}}) + if err != nil { + t.Fatal(err) + } + value := "By=spiffe://proxy;Subject=\"CN=gw\\\"team\\\";OU=edge,region\";URI=spiffe://example/ns/default/sa/caller" + r := &http.Request{Header: http.Header{"X-Forwarded-Client-Cert": []string{value}}} + if !p.Authenticate(context.Background(), r, cfg) { + t.Fatal("expected quoted subject separators to not break parser") + } +} + +func TestEnvoyXFCCIdentifyReturnsCallerURI(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{"allowed_uri_prefixes": []string{"spiffe://example/ns/default/"}}) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{"X-Forwarded-Client-Cert": []string{"URI=spiffe://example/ns/default/sa/caller"}}} + id, ok := p.Identify(r, cfg) + if !ok { + t.Fatal("expected identify success") + } + if id != "spiffe://example/ns/default/sa/caller" { + t.Fatalf("unexpected id %q", id) + } +} + +func TestEnvoyXFCCStripHeaderWhenEnabled(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{"allowed_uris": []string{"spiffe://allowed"}}) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{"X-Forwarded-Client-Cert": []string{"URI=spiffe://allowed"}}} + p.StripAuth(r, cfg) + if got := r.Header.Get("X-Forwarded-Client-Cert"); got != "" { + t.Fatalf("expected stripped header, got %q", got) + } +} + +func TestEnvoyXFCCCoverageEdges(t *testing.T) { + p := EnvoyXFCCAuth{} + if p.Name() != "envoy_xfcc" { + t.Fatal("unexpected name") + } + if len(p.RequiredParams()) != 0 { + t.Fatal("expected no required params") + } + if len(p.OptionalParams()) != 5 { + t.Fatal("unexpected optional params") + } + + if _, err := p.ParseParams(map[string]interface{}{"unknown": true}); err == nil { + t.Fatal("expected unknown param error") + } + if _, err := p.ParseParams(map[string]interface{}{"allowed_uris": "bad"}); err == nil { + t.Fatal("expected type mismatch") + } + + cfgAny, err := p.ParseParams(map[string]interface{}{"allowed_uri_prefixes": []string{"spiffe://ok/"}, "strip_header": false, "header": "X-Custom-XFCC"}) + if err != nil { + t.Fatal(err) + } + cfgAnyTyped := cfgAny.(*inParams) + if cfgAnyTyped.Header != "X-Custom-XFCC" || len(cfgAnyTyped.AllowedURIPrefix) != 1 || cfgAnyTyped.AllowedURIPrefix[0] != "spiffe://ok/" { + t.Fatalf("unexpected parsed config: %+v", cfgAnyTyped) + } + r := &http.Request{Header: http.Header{}} + r.Header.Set("X-Custom-XFCC", "URI=spiffe://ok/caller") + if !isAllowedIdentity("spiffe://ok/caller", cfgAnyTyped) { + t.Fatal("expected allow-list prefix match") + } + if id, ok := extractCallerIdentity(r.Header.Get("X-Custom-XFCC"), cfgAnyTyped); !ok || id != "spiffe://ok/caller" { + t.Fatalf("unexpected extract result id=%q ok=%v", id, ok) + } + if !p.Authenticate(context.Background(), r, cfgAny) { + t.Fatal("expected custom header auth success") + } + if id, ok := p.Identify(r, nil); ok || id != "" { + t.Fatal("expected identify false with invalid params") + } + p.StripAuth(r, cfgAny) + if got := r.Header.Get("X-Custom-XFCC"); got == "" { + t.Fatal("expected header not stripped when strip_header false") + } + p.StripAuth(r, nil) + + malformed := &http.Request{Header: http.Header{}} + malformed.Header.Set("X-Custom-XFCC", "URI=\"unterminated") + if p.Authenticate(context.Background(), malformed, cfgAny) { + t.Fatal("expected malformed quote to fail") + } + badField := &http.Request{Header: http.Header{}} + badField.Header.Set("X-Custom-XFCC", "URI=spiffe://ok/caller;NoEquals") + if p.Authenticate(context.Background(), badField, cfgAny) { + t.Fatal("expected malformed field to fail") + } + noURI := &http.Request{Header: http.Header{}} + noURI.Header.Set("X-Custom-XFCC", "By=spiffe://proxy") + if p.Authenticate(context.Background(), noURI, cfgAny) { + t.Fatal("expected no URI to fail") + } + emptyElem := &http.Request{Header: http.Header{}} + emptyElem.Header.Set("X-Custom-XFCC", "URI=spiffe://ok/caller,") + if p.Authenticate(context.Background(), emptyElem, cfgAny) { + t.Fatal("expected trailing separator to fail") + } + quotedURI := &http.Request{Header: http.Header{}} + quotedURI.Header.Set("X-Custom-XFCC", "URI=\"spiffe://ok/caller\"") + if !p.Authenticate(context.Background(), quotedURI, cfgAny) { + t.Fatal("expected quoted URI to succeed") + } + escapedURI := &http.Request{Header: http.Header{}} + escapedURI.Header.Set("X-Custom-XFCC", "URI=\"spiffe://ok\\/caller\"") + if !p.Authenticate(context.Background(), escapedURI, cfgAny) { + t.Fatal("expected escaped quoted URI to succeed") + } + badQuotedURI := &http.Request{Header: http.Header{}} + badQuotedURI.Header.Set("X-Custom-XFCC", "URI=\"spiffe://ok/caller\\\"") + if p.Authenticate(context.Background(), badQuotedURI, cfgAny) { + t.Fatal("expected dangling escape in URI value to fail") + } + emptyValue := &http.Request{Header: http.Header{}} + emptyValue.Header.Set("X-Custom-XFCC", "URI=") + if p.Authenticate(context.Background(), emptyValue, cfgAny) { + t.Fatal("expected empty URI value to fail") + } + emptyKey := &http.Request{Header: http.Header{}} + emptyKey.Header.Set("X-Custom-XFCC", "=spiffe://ok/caller") + if p.Authenticate(context.Background(), emptyKey, cfgAny) { + t.Fatal("expected empty key to fail") + } + emptyField := &http.Request{Header: http.Header{}} + emptyField.Header.Set("X-Custom-XFCC", "URI=spiffe://ok/caller;;By=spiffe://proxy") + if p.Authenticate(context.Background(), emptyField, cfgAny) { + t.Fatal("expected empty field segment to fail") + } + if v, ok := decodeFieldValue("\""); ok || v != "" { + t.Fatal("expected single-quote value to fail decoding") + } + if v, ok := decodeFieldValue("\"abc\\\""); ok || v != "" { + t.Fatal("expected dangling escape inside quoted value to fail decoding") + } + + cfgIgnoredOnly, err := p.ParseParams(map[string]interface{}{ + "allowed_uri_prefixes": []string{"spiffe://ok/"}, + "ignored_uris": []string{"spiffe://ok/caller"}, + }) + if err != nil { + t.Fatal(err) + } + ignoredOnly := &http.Request{Header: http.Header{"X-Forwarded-Client-Cert": []string{"URI=spiffe://ok/caller"}}} + if p.Authenticate(context.Background(), ignoredOnly, cfgIgnoredOnly) { + t.Fatal("expected ignored-only identities to fail") + } + + cfgNoAllow, err := p.ParseParams(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + if p.Authenticate(context.Background(), &http.Request{Header: http.Header{"X-Forwarded-Client-Cert": []string{"URI=spiffe://ok/caller"}}}, cfgNoAllow) { + t.Fatal("expected no allow-lists to fail closed") + } +} diff --git a/app/auth/plugins/envoy_xfcc/incoming.go b/app/auth/plugins/envoy_xfcc/incoming.go new file mode 100644 index 0000000..5a823f0 --- /dev/null +++ b/app/auth/plugins/envoy_xfcc/incoming.go @@ -0,0 +1,211 @@ +package envoy_xfcc + +import ( + "context" + "net/http" + "strings" + + "github.com/winhowes/AuthTranslator/app/auth" +) + +type inParams struct { + AllowedURIs []string `json:"allowed_uris"` + AllowedURIPrefix []string `json:"allowed_uri_prefixes"` + Header string `json:"header"` + IgnoredURIs []string `json:"ignored_uris"` + StripHeader *bool `json:"strip_header"` +} + +type EnvoyXFCCAuth struct{} + +func (e *EnvoyXFCCAuth) Name() string { return "envoy_xfcc" } + +func (e *EnvoyXFCCAuth) RequiredParams() []string { return []string{} } + +func (e *EnvoyXFCCAuth) OptionalParams() []string { + return []string{"allowed_uris", "allowed_uri_prefixes", "header", "ignored_uris", "strip_header"} +} + +func (e *EnvoyXFCCAuth) ParseParams(m map[string]interface{}) (interface{}, error) { + cfg, err := authplugins.ParseParams[inParams](m) + if err != nil { + return nil, err + } + if cfg.Header == "" { + cfg.Header = "X-Forwarded-Client-Cert" + } + if cfg.StripHeader == nil { + defaultStrip := true + cfg.StripHeader = &defaultStrip + } + return cfg, nil +} + +func (e *EnvoyXFCCAuth) Authenticate(ctx context.Context, r *http.Request, p interface{}) bool { + _, ok := e.Identify(r, p) + return ok +} + +func (e *EnvoyXFCCAuth) Identify(r *http.Request, p interface{}) (string, bool) { + cfg, ok := p.(*inParams) + if !ok { + return "", false + } + identity, ok := extractCallerIdentity(r.Header.Get(cfg.Header), cfg) + if !ok { + return "", false + } + return identity, true +} + +func (e *EnvoyXFCCAuth) StripAuth(r *http.Request, p interface{}) { + cfg, ok := p.(*inParams) + if !ok { + return + } + if cfg.StripHeader != nil && *cfg.StripHeader { + r.Header.Del(cfg.Header) + } +} + +func extractCallerIdentity(raw string, cfg *inParams) (string, bool) { + if strings.TrimSpace(raw) == "" { + return "", false + } + elements, ok := splitXFCC(raw, ',') + if !ok { + return "", false + } + ignored := map[string]struct{}{} + for _, uri := range cfg.IgnoredURIs { + ignored[uri] = struct{}{} + } + + selected := "" + for _, elem := range elements { + fields, ok := splitXFCC(elem, ';') + if !ok { + return "", false + } + elementURI := "" + for _, field := range fields { + kv := strings.SplitN(strings.TrimSpace(field), "=", 2) + if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { + return "", false + } + if !strings.EqualFold(strings.TrimSpace(kv[0]), "URI") { + continue + } + uri, ok := decodeFieldValue(kv[1]) + if !ok || uri == "" { + return "", false + } + if elementURI != "" { + return "", false + } + elementURI = uri + } + + if elementURI == "" { + continue + } + if _, isIgnored := ignored[elementURI]; isIgnored { + continue + } + if selected != "" { + return "", false + } + selected = elementURI + } + + if selected == "" { + return "", false + } + if !isAllowedIdentity(selected, cfg) { + return "", false + } + return selected, true +} + +func splitXFCC(raw string, sep rune) ([]string, bool) { + parts := []string{} + start := 0 + inQuotes := false + escaped := false + for i, r := range raw { + switch { + case inQuotes && escaped: + escaped = false + case inQuotes && r == '\\': + escaped = true + case r == '"': + inQuotes = !inQuotes + case !inQuotes && r == sep: + part := strings.TrimSpace(raw[start:i]) + if part == "" { + return nil, false + } + parts = append(parts, part) + start = i + 1 + } + } + if inQuotes || escaped { + return nil, false + } + last := strings.TrimSpace(raw[start:]) + if last == "" { + return nil, false + } + parts = append(parts, last) + return parts, true +} + +func decodeFieldValue(v string) (string, bool) { + value := strings.TrimSpace(v) + if value == "" { + return "", false + } + if !strings.HasPrefix(value, "\"") { + return value, true + } + if len(value) < 2 || !strings.HasSuffix(value, "\"") { + return "", false + } + inner := value[1 : len(value)-1] + var b strings.Builder + escaped := false + for _, r := range inner { + if escaped { + b.WriteRune(r) + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + b.WriteRune(r) + } + if escaped { + return "", false + } + return b.String(), true +} + +func isAllowedIdentity(uri string, cfg *inParams) bool { + for _, allowed := range cfg.AllowedURIs { + if uri == allowed { + return true + } + } + for _, prefix := range cfg.AllowedURIPrefix { + if strings.HasPrefix(uri, prefix) { + return true + } + } + return false +} + +func init() { + authplugins.RegisterIncoming(&EnvoyXFCCAuth{}) +} diff --git a/app/auth/plugins/plugins.go b/app/auth/plugins/plugins.go index 4e914ed..0279f56 100644 --- a/app/auth/plugins/plugins.go +++ b/app/auth/plugins/plugins.go @@ -3,6 +3,7 @@ package plugins import ( _ "github.com/winhowes/AuthTranslator/app/auth/plugins/azure_managed_identity" _ "github.com/winhowes/AuthTranslator/app/auth/plugins/basic" + _ "github.com/winhowes/AuthTranslator/app/auth/plugins/envoy_xfcc" _ "github.com/winhowes/AuthTranslator/app/auth/plugins/findreplace" _ "github.com/winhowes/AuthTranslator/app/auth/plugins/gcp_token" _ "github.com/winhowes/AuthTranslator/app/auth/plugins/github_signature" diff --git a/docs/auth-plugins.md b/docs/auth-plugins.md index 5896042..8a930ca 100644 --- a/docs/auth-plugins.md +++ b/docs/auth-plugins.md @@ -24,6 +24,7 @@ AuthTranslator’s behaviour is extended by **plugins** – small Go packages th | Inbound | `hmac_signature` | Generic HMAC validation using a shared secret. | | Inbound | `jwt` | Verifies JWTs with provided keys. | | Inbound | `mtls` | Requires a trusted client certificate. | +| Inbound | `envoy_xfcc` | Validates caller SPIFFE URI from Envoy `X-Forwarded-Client-Cert`. | | Inbound | `slack_signature` | Validates Slack request signatures. | | Inbound | `twilio_signature` | Validates Twilio webhook signatures. | | Inbound | `token` | Compares a shared token header. | @@ -71,6 +72,29 @@ outgoing_auth: Adds the configured token to the `X-Api-Key` header on each request. +### Inbound `envoy_xfcc` + +```yaml +incoming_auth: + - type: envoy_xfcc + params: + allowed_uris: + - spiffe://cluster.local/ns/team/sa/caller + ignored_uris: + - spiffe://cluster.local/ns/gateway/sa/envoy + allowed_uri_prefixes: + - spiffe://cluster.local/ns/team/ + header: X-Forwarded-Client-Cert # optional + strip_header: true # optional, default true +``` + +Reads Envoy's XFCC header and extracts a single caller `URI=` identity (SPIFFE). +It fails closed when the header is missing, malformed, ambiguous, or not +allowed by either `allowed_uris` or `allowed_uri_prefixes`. + +Use this only when your edge Envoy/Gateway is trusted to sanitize and set the +XFCC header. + ### Outbound `find_replace` ```yaml From 57e4f18e5b1f2242746f94eec2ce3a62eb9b4a20 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 8 Apr 2026 01:26:16 -0700 Subject: [PATCH 2/8] Make envoy_xfcc always strip auth header --- app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go | 8 ++++---- app/auth/plugins/envoy_xfcc/incoming.go | 11 ++--------- docs/auth-plugins.md | 3 ++- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go index 966beff..715e213 100644 --- a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go +++ b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go @@ -131,7 +131,7 @@ func TestEnvoyXFCCCoverageEdges(t *testing.T) { if len(p.RequiredParams()) != 0 { t.Fatal("expected no required params") } - if len(p.OptionalParams()) != 5 { + if len(p.OptionalParams()) != 4 { t.Fatal("unexpected optional params") } @@ -142,7 +142,7 @@ func TestEnvoyXFCCCoverageEdges(t *testing.T) { t.Fatal("expected type mismatch") } - cfgAny, err := p.ParseParams(map[string]interface{}{"allowed_uri_prefixes": []string{"spiffe://ok/"}, "strip_header": false, "header": "X-Custom-XFCC"}) + cfgAny, err := p.ParseParams(map[string]interface{}{"allowed_uri_prefixes": []string{"spiffe://ok/"}, "header": "X-Custom-XFCC"}) if err != nil { t.Fatal(err) } @@ -165,8 +165,8 @@ func TestEnvoyXFCCCoverageEdges(t *testing.T) { t.Fatal("expected identify false with invalid params") } p.StripAuth(r, cfgAny) - if got := r.Header.Get("X-Custom-XFCC"); got == "" { - t.Fatal("expected header not stripped when strip_header false") + if got := r.Header.Get("X-Custom-XFCC"); got != "" { + t.Fatal("expected header stripped") } p.StripAuth(r, nil) diff --git a/app/auth/plugins/envoy_xfcc/incoming.go b/app/auth/plugins/envoy_xfcc/incoming.go index 5a823f0..9240bd8 100644 --- a/app/auth/plugins/envoy_xfcc/incoming.go +++ b/app/auth/plugins/envoy_xfcc/incoming.go @@ -13,7 +13,6 @@ type inParams struct { AllowedURIPrefix []string `json:"allowed_uri_prefixes"` Header string `json:"header"` IgnoredURIs []string `json:"ignored_uris"` - StripHeader *bool `json:"strip_header"` } type EnvoyXFCCAuth struct{} @@ -23,7 +22,7 @@ func (e *EnvoyXFCCAuth) Name() string { return "envoy_xfcc" } func (e *EnvoyXFCCAuth) RequiredParams() []string { return []string{} } func (e *EnvoyXFCCAuth) OptionalParams() []string { - return []string{"allowed_uris", "allowed_uri_prefixes", "header", "ignored_uris", "strip_header"} + return []string{"allowed_uris", "allowed_uri_prefixes", "header", "ignored_uris"} } func (e *EnvoyXFCCAuth) ParseParams(m map[string]interface{}) (interface{}, error) { @@ -34,10 +33,6 @@ func (e *EnvoyXFCCAuth) ParseParams(m map[string]interface{}) (interface{}, erro if cfg.Header == "" { cfg.Header = "X-Forwarded-Client-Cert" } - if cfg.StripHeader == nil { - defaultStrip := true - cfg.StripHeader = &defaultStrip - } return cfg, nil } @@ -63,9 +58,7 @@ func (e *EnvoyXFCCAuth) StripAuth(r *http.Request, p interface{}) { if !ok { return } - if cfg.StripHeader != nil && *cfg.StripHeader { - r.Header.Del(cfg.Header) - } + r.Header.Del(cfg.Header) } func extractCallerIdentity(raw string, cfg *inParams) (string, bool) { diff --git a/docs/auth-plugins.md b/docs/auth-plugins.md index 8a930ca..693617f 100644 --- a/docs/auth-plugins.md +++ b/docs/auth-plugins.md @@ -85,12 +85,13 @@ incoming_auth: allowed_uri_prefixes: - spiffe://cluster.local/ns/team/ header: X-Forwarded-Client-Cert # optional - strip_header: true # optional, default true ``` Reads Envoy's XFCC header and extracts a single caller `URI=` identity (SPIFFE). It fails closed when the header is missing, malformed, ambiguous, or not allowed by either `allowed_uris` or `allowed_uri_prefixes`. +After successful auth, the configured XFCC header is stripped before the +request is forwarded upstream. Use this only when your edge Envoy/Gateway is trusted to sanitize and set the XFCC header. From 80a87663e64c3a60458878a82286f75e5f7010e7 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 8 Apr 2026 01:29:24 -0700 Subject: [PATCH 3/8] Keep envoy_xfcc docs consistent with other auth plugins --- docs/auth-plugins.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/auth-plugins.md b/docs/auth-plugins.md index 693617f..0f092a2 100644 --- a/docs/auth-plugins.md +++ b/docs/auth-plugins.md @@ -90,8 +90,6 @@ incoming_auth: Reads Envoy's XFCC header and extracts a single caller `URI=` identity (SPIFFE). It fails closed when the header is missing, malformed, ambiguous, or not allowed by either `allowed_uris` or `allowed_uri_prefixes`. -After successful auth, the configured XFCC header is stripped before the -request is forwarded upstream. Use this only when your edge Envoy/Gateway is trusted to sanitize and set the XFCC header. From b8c0992a731f10e00d9ea3e3e04b6a48b97b5475 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 8 Apr 2026 01:30:00 -0700 Subject: [PATCH 4/8] Handle repeated XFCC header values in envoy_xfcc --- .../plugins/envoy_xfcc/envoy_xfcc_test.go | 30 +++++++++++++++++++ app/auth/plugins/envoy_xfcc/incoming.go | 10 ++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go index 715e213..e0aa4b5 100644 --- a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go +++ b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go @@ -110,6 +110,27 @@ func TestEnvoyXFCCIdentifyReturnsCallerURI(t *testing.T) { } } +func TestEnvoyXFCCMultipleHeaderValuesCombined(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{ + "allowed_uris": []string{"spiffe://cluster.local/ns/team/sa/caller"}, + "ignored_uris": []string{"spiffe://cluster.local/ns/gateway/sa/envoy"}, + }) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{}} + r.Header.Add("X-Forwarded-Client-Cert", "URI=spiffe://cluster.local/ns/gateway/sa/envoy") + r.Header.Add("X-Forwarded-Client-Cert", "URI=spiffe://cluster.local/ns/team/sa/caller") + if !p.Authenticate(context.Background(), r, cfg) { + t.Fatal("expected auth success when caller URI is in a later header value") + } + id, ok := p.Identify(r, cfg) + if !ok || id != "spiffe://cluster.local/ns/team/sa/caller" { + t.Fatalf("unexpected identify result id=%q ok=%v", id, ok) + } +} + func TestEnvoyXFCCStripHeaderWhenEnabled(t *testing.T) { p := EnvoyXFCCAuth{} cfg, err := p.ParseParams(map[string]interface{}{"allowed_uris": []string{"spiffe://allowed"}}) @@ -226,6 +247,15 @@ func TestEnvoyXFCCCoverageEdges(t *testing.T) { if v, ok := decodeFieldValue("\"abc\\\""); ok || v != "" { t.Fatal("expected dangling escape inside quoted value to fail decoding") } + h := http.Header{} + if joined := joinHeaderValues(h, "X-Forwarded-Client-Cert"); joined != "" { + t.Fatalf("expected empty joined header, got %q", joined) + } + h.Add("X-Forwarded-Client-Cert", "URI=spiffe://ok/one") + h.Add("X-Forwarded-Client-Cert", "URI=spiffe://ok/two") + if joined := joinHeaderValues(h, "X-Forwarded-Client-Cert"); joined != "URI=spiffe://ok/one,URI=spiffe://ok/two" { + t.Fatalf("unexpected joined header %q", joined) + } cfgIgnoredOnly, err := p.ParseParams(map[string]interface{}{ "allowed_uri_prefixes": []string{"spiffe://ok/"}, diff --git a/app/auth/plugins/envoy_xfcc/incoming.go b/app/auth/plugins/envoy_xfcc/incoming.go index 9240bd8..c8c4fdc 100644 --- a/app/auth/plugins/envoy_xfcc/incoming.go +++ b/app/auth/plugins/envoy_xfcc/incoming.go @@ -46,7 +46,7 @@ func (e *EnvoyXFCCAuth) Identify(r *http.Request, p interface{}) (string, bool) if !ok { return "", false } - identity, ok := extractCallerIdentity(r.Header.Get(cfg.Header), cfg) + identity, ok := extractCallerIdentity(joinHeaderValues(r.Header, cfg.Header), cfg) if !ok { return "", false } @@ -120,6 +120,14 @@ func extractCallerIdentity(raw string, cfg *inParams) (string, bool) { return selected, true } +func joinHeaderValues(h http.Header, header string) string { + values := h.Values(header) + if len(values) == 0 { + return "" + } + return strings.Join(values, ",") +} + func splitXFCC(raw string, sep rune) ([]string, bool) { parts := []string{} start := 0 From d42251835377714dc2afcf1aaa12f7d854a049be Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 8 Apr 2026 01:42:28 -0700 Subject: [PATCH 5/8] Add JSON XFCC support to envoy_xfcc plugin --- .../plugins/envoy_xfcc/envoy_xfcc_test.go | 129 ++++++++++++++- app/auth/plugins/envoy_xfcc/incoming.go | 149 ++++++++++++++++-- docs/auth-plugins.md | 1 + 3 files changed, 262 insertions(+), 17 deletions(-) diff --git a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go index e0aa4b5..01cbc5c 100644 --- a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go +++ b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go @@ -131,6 +131,126 @@ func TestEnvoyXFCCMultipleHeaderValuesCombined(t *testing.T) { } } +func TestEnvoyXFCCJSONHeaderSupported(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{ + "allowed_uris": []string{"spiffe://cluster.local/ns/team/sa/caller"}, + }) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{}} + r.Header.Set("X-Forwarded-Client-Cert", `{"URI":"spiffe://cluster.local/ns/team/sa/caller","Subject":"CN=client"}`) + if !p.Authenticate(context.Background(), r, cfg) { + t.Fatal("expected JSON XFCC header to authenticate") + } +} + +func TestEnvoyXFCCJSONArrayWithIgnoredIdentity(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{ + "allowed_uris": []string{"spiffe://cluster.local/ns/team/sa/caller"}, + "ignored_uris": []string{"spiffe://cluster.local/ns/gateway/sa/envoy"}, + }) + if err != nil { + t.Fatal(err) + } + r := &http.Request{Header: http.Header{}} + r.Header.Set("X-Forwarded-Client-Cert", `[{"URI":"spiffe://cluster.local/ns/gateway/sa/envoy"},{"URI":"spiffe://cluster.local/ns/team/sa/caller"}]`) + if !p.Authenticate(context.Background(), r, cfg) { + t.Fatal("expected JSON XFCC array with ignored gateway to authenticate") + } +} + +func TestEnvoyXFCCJSONMalformedOrMixedFails(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{ + "allowed_uri_prefixes": []string{"spiffe://cluster.local/ns/team/"}, + }) + if err != nil { + t.Fatal(err) + } + + malformed := &http.Request{Header: http.Header{}} + malformed.Header.Set("X-Forwarded-Client-Cert", `{"URI":`) + if p.Authenticate(context.Background(), malformed, cfg) { + t.Fatal("expected malformed JSON header to fail") + } + + mixed := &http.Request{Header: http.Header{}} + mixed.Header.Add("X-Forwarded-Client-Cert", `{"URI":"spiffe://cluster.local/ns/team/sa/caller"}`) + mixed.Header.Add("X-Forwarded-Client-Cert", `URI=spiffe://cluster.local/ns/team/sa/caller`) + if p.Authenticate(context.Background(), mixed, cfg) { + t.Fatal("expected mixed JSON/text XFCC header values to fail") + } +} + +func TestEnvoyXFCCJSONCoverageEdges(t *testing.T) { + p := EnvoyXFCCAuth{} + cfg, err := p.ParseParams(map[string]interface{}{ + "allowed_uri_prefixes": []string{"spiffe://ok/"}, + }) + if err != nil { + t.Fatal(err) + } + if _, ok := extractCallerIdentityFromValues(nil, cfg.(*inParams)); ok { + t.Fatal("expected empty header values to fail") + } + if _, ok := extractCallerIdentityFromValues([]string{" "}, cfg.(*inParams)); ok { + t.Fatal("expected blank header value to fail") + } + if _, ok := extractCallerIdentity("", cfg.(*inParams)); ok { + t.Fatal("expected blank text XFCC to fail") + } + if detectHeaderFormat("") != headerFormatUnknown { + t.Fatal("expected unknown format for empty value") + } + if detectHeaderFormat("{\"URI\":\"spiffe://ok/a\"}") != headerFormatJSON { + t.Fatal("expected JSON format detection") + } + if detectHeaderFormat("URI=spiffe://ok/a") != headerFormatText { + t.Fatal("expected text format detection") + } + + if uris, ok := extractJSONURIs(`{"By":"proxy"}`); !ok || len(uris) != 0 { + t.Fatalf("expected JSON object without URI to succeed with empty URIs: uris=%v ok=%v", uris, ok) + } + if uris, ok := extractJSONURIs(`[{"URI":"spiffe://ok/a"}]`); !ok || len(uris) != 1 || uris[0] != "spiffe://ok/a" { + t.Fatalf("unexpected JSON array parse uris=%v ok=%v", uris, ok) + } + if _, ok := extractJSONURIs(`["bad"]`); ok { + t.Fatal("expected invalid JSON array element to fail") + } + if _, ok := extractJSONURIs(`{"URI":["a","b"]}`); ok { + t.Fatal("expected URI array with multiple entries to fail") + } + if _, ok := extractJSONURIs(`{"URI":[""]}`); ok { + t.Fatal("expected empty URI array entry to fail") + } + if _, ok := extractJSONURIs(""); ok { + t.Fatal("expected empty JSON string to fail") + } + if _, ok := extractJSONURIs(`[{"URI":1}]`); ok { + t.Fatal("expected JSON array element with invalid URI to fail") + } + if _, ok := extractJSONURIs(`{"URI":1}`); ok { + t.Fatal("expected non-string URI to fail") + } + + objNoURI := map[string]interface{}{"By": "proxy"} + if uri, ok := extractURIFromJSONObject(objNoURI); !ok || uri != "" { + t.Fatalf("expected no URI in object: uri=%q ok=%v", uri, ok) + } + objURIArray := map[string]interface{}{"URI": []interface{}{"spiffe://ok/a"}} + if uri, ok := extractURIFromJSONObject(objURIArray); !ok || uri != "spiffe://ok/a" { + t.Fatalf("unexpected URI array decode: uri=%q ok=%v", uri, ok) + } + objBlankURI := map[string]interface{}{"URI": " "} + if uri, ok := extractURIFromJSONObject(objBlankURI); ok || uri != "" { + t.Fatalf("expected blank URI string to fail: uri=%q ok=%v", uri, ok) + } +} + func TestEnvoyXFCCStripHeaderWhenEnabled(t *testing.T) { p := EnvoyXFCCAuth{} cfg, err := p.ParseParams(map[string]interface{}{"allowed_uris": []string{"spiffe://allowed"}}) @@ -247,13 +367,12 @@ func TestEnvoyXFCCCoverageEdges(t *testing.T) { if v, ok := decodeFieldValue("\"abc\\\""); ok || v != "" { t.Fatal("expected dangling escape inside quoted value to fail decoding") } - h := http.Header{} - if joined := joinHeaderValues(h, "X-Forwarded-Client-Cert"); joined != "" { + values := []string{} + if joined := joinHeaderValues(values); joined != "" { t.Fatalf("expected empty joined header, got %q", joined) } - h.Add("X-Forwarded-Client-Cert", "URI=spiffe://ok/one") - h.Add("X-Forwarded-Client-Cert", "URI=spiffe://ok/two") - if joined := joinHeaderValues(h, "X-Forwarded-Client-Cert"); joined != "URI=spiffe://ok/one,URI=spiffe://ok/two" { + values = append(values, "URI=spiffe://ok/one", "URI=spiffe://ok/two") + if joined := joinHeaderValues(values); joined != "URI=spiffe://ok/one,URI=spiffe://ok/two" { t.Fatalf("unexpected joined header %q", joined) } diff --git a/app/auth/plugins/envoy_xfcc/incoming.go b/app/auth/plugins/envoy_xfcc/incoming.go index c8c4fdc..5c528f6 100644 --- a/app/auth/plugins/envoy_xfcc/incoming.go +++ b/app/auth/plugins/envoy_xfcc/incoming.go @@ -2,6 +2,7 @@ package envoy_xfcc import ( "context" + "encoding/json" "net/http" "strings" @@ -46,7 +47,7 @@ func (e *EnvoyXFCCAuth) Identify(r *http.Request, p interface{}) (string, bool) if !ok { return "", false } - identity, ok := extractCallerIdentity(joinHeaderValues(r.Header, cfg.Header), cfg) + identity, ok := extractCallerIdentityFromValues(r.Header.Values(cfg.Header), cfg) if !ok { return "", false } @@ -61,6 +62,42 @@ func (e *EnvoyXFCCAuth) StripAuth(r *http.Request, p interface{}) { r.Header.Del(cfg.Header) } +func extractCallerIdentityFromValues(values []string, cfg *inParams) (string, bool) { + if len(values) == 0 { + return "", false + } + format := headerFormatUnknown + jsonURIs := []string{} + textValues := []string{} + for _, raw := range values { + valueFormat := detectHeaderFormat(raw) + if valueFormat == headerFormatUnknown { + return "", false + } + if format == headerFormatUnknown { + format = valueFormat + } + if format != valueFormat { + return "", false + } + switch valueFormat { + case headerFormatText: + textValues = append(textValues, raw) + case headerFormatJSON: + uris, ok := extractJSONURIs(raw) + if !ok { + return "", false + } + jsonURIs = append(jsonURIs, uris...) + } + } + + if format == headerFormatText { + return extractCallerIdentity(joinHeaderValues(textValues), cfg) + } + return selectIdentity(jsonURIs, cfg) +} + func extractCallerIdentity(raw string, cfg *inParams) (string, bool) { if strings.TrimSpace(raw) == "" { return "", false @@ -69,12 +106,7 @@ func extractCallerIdentity(raw string, cfg *inParams) (string, bool) { if !ok { return "", false } - ignored := map[string]struct{}{} - for _, uri := range cfg.IgnoredURIs { - ignored[uri] = struct{}{} - } - - selected := "" + candidates := []string{} for _, elem := range elements { fields, ok := splitXFCC(elem, ';') if !ok { @@ -102,15 +134,26 @@ func extractCallerIdentity(raw string, cfg *inParams) (string, bool) { if elementURI == "" { continue } - if _, isIgnored := ignored[elementURI]; isIgnored { + candidates = append(candidates, elementURI) + } + return selectIdentity(candidates, cfg) +} + +func selectIdentity(candidates []string, cfg *inParams) (string, bool) { + ignored := map[string]struct{}{} + for _, uri := range cfg.IgnoredURIs { + ignored[uri] = struct{}{} + } + selected := "" + for _, candidate := range candidates { + if _, isIgnored := ignored[candidate]; isIgnored { continue } if selected != "" { return "", false } - selected = elementURI + selected = candidate } - if selected == "" { return "", false } @@ -120,14 +163,96 @@ func extractCallerIdentity(raw string, cfg *inParams) (string, bool) { return selected, true } -func joinHeaderValues(h http.Header, header string) string { - values := h.Values(header) +func joinHeaderValues(values []string) string { if len(values) == 0 { return "" } return strings.Join(values, ",") } +type xfccHeaderFormat int + +const ( + headerFormatUnknown xfccHeaderFormat = iota + headerFormatText + headerFormatJSON +) + +func detectHeaderFormat(raw string) xfccHeaderFormat { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return headerFormatUnknown + } + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + return headerFormatJSON + } + return headerFormatText +} + +func extractJSONURIs(raw string) ([]string, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, false + } + if strings.HasPrefix(trimmed, "[") { + var arr []map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &arr); err != nil { + return nil, false + } + uris := []string{} + for _, element := range arr { + uri, ok := extractURIFromJSONObject(element) + if !ok { + return nil, false + } + if uri != "" { + uris = append(uris, uri) + } + } + return uris, true + } + + var obj map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &obj); err != nil { + return nil, false + } + uri, ok := extractURIFromJSONObject(obj) + if !ok { + return nil, false + } + if uri == "" { + return []string{}, true + } + return []string{uri}, true +} + +func extractURIFromJSONObject(obj map[string]interface{}) (string, bool) { + for key, value := range obj { + if !strings.EqualFold(key, "URI") { + continue + } + switch v := value.(type) { + case string: + if strings.TrimSpace(v) == "" { + return "", false + } + return v, true + case []interface{}: + if len(v) != 1 { + return "", false + } + uri, ok := v[0].(string) + if !ok || strings.TrimSpace(uri) == "" { + return "", false + } + return uri, true + default: + return "", false + } + } + return "", true +} + func splitXFCC(raw string, sep rune) ([]string, bool) { parts := []string{} start := 0 diff --git a/docs/auth-plugins.md b/docs/auth-plugins.md index 0f092a2..8f8cf65 100644 --- a/docs/auth-plugins.md +++ b/docs/auth-plugins.md @@ -90,6 +90,7 @@ incoming_auth: Reads Envoy's XFCC header and extracts a single caller `URI=` identity (SPIFFE). It fails closed when the header is missing, malformed, ambiguous, or not allowed by either `allowed_uris` or `allowed_uri_prefixes`. +Supports both Envoy's legacy text XFCC and JSON XFCC header formats. Use this only when your edge Envoy/Gateway is trusted to sanitize and set the XFCC header. From b7e41d56609ba940ee3bb104d33b26b13400714c Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 8 Apr 2026 01:51:21 -0700 Subject: [PATCH 6/8] Reject duplicate URI keys in JSON XFCC objects --- .../plugins/envoy_xfcc/envoy_xfcc_test.go | 5 +++ app/auth/plugins/envoy_xfcc/incoming.go | 43 +++++++++++-------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go index 01cbc5c..a1c3241 100644 --- a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go +++ b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go @@ -176,6 +176,11 @@ func TestEnvoyXFCCJSONMalformedOrMixedFails(t *testing.T) { if p.Authenticate(context.Background(), malformed, cfg) { t.Fatal("expected malformed JSON header to fail") } + duplicateURIKeys := &http.Request{Header: http.Header{}} + duplicateURIKeys.Header.Set("X-Forwarded-Client-Cert", `{"URI":"spiffe://cluster.local/ns/team/sa/caller","uri":"spiffe://cluster.local/ns/other/sa/caller"}`) + if p.Authenticate(context.Background(), duplicateURIKeys, cfg) { + t.Fatal("expected duplicate case-insensitive URI keys to fail") + } mixed := &http.Request{Header: http.Header{}} mixed.Header.Add("X-Forwarded-Client-Cert", `{"URI":"spiffe://cluster.local/ns/team/sa/caller"}`) diff --git a/app/auth/plugins/envoy_xfcc/incoming.go b/app/auth/plugins/envoy_xfcc/incoming.go index 5c528f6..670d360 100644 --- a/app/auth/plugins/envoy_xfcc/incoming.go +++ b/app/auth/plugins/envoy_xfcc/incoming.go @@ -227,30 +227,39 @@ func extractJSONURIs(raw string) ([]string, bool) { } func extractURIFromJSONObject(obj map[string]interface{}) (string, bool) { + var uriValue interface{} + seenURIKey := false for key, value := range obj { if !strings.EqualFold(key, "URI") { continue } - switch v := value.(type) { - case string: - if strings.TrimSpace(v) == "" { - return "", false - } - return v, true - case []interface{}: - if len(v) != 1 { - return "", false - } - uri, ok := v[0].(string) - if !ok || strings.TrimSpace(uri) == "" { - return "", false - } - return uri, true - default: + if seenURIKey { return "", false } + seenURIKey = true + uriValue = value + } + if !seenURIKey { + return "", true + } + switch v := uriValue.(type) { + case string: + if strings.TrimSpace(v) == "" { + return "", false + } + return v, true + case []interface{}: + if len(v) != 1 { + return "", false + } + uri, ok := v[0].(string) + if !ok || strings.TrimSpace(uri) == "" { + return "", false + } + return uri, true + default: + return "", false } - return "", true } func splitXFCC(raw string, sep rune) ([]string, bool) { From 7aad86ffadad63d9984bbe150595a690be6d8d61 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 8 Apr 2026 02:03:59 -0700 Subject: [PATCH 7/8] Detect duplicate JSON URI keys before unmarshal collapse --- .../plugins/envoy_xfcc/envoy_xfcc_test.go | 33 ++++++++-- app/auth/plugins/envoy_xfcc/incoming.go | 64 ++++++++++++------- 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go index a1c3241..2a3cf66 100644 --- a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go +++ b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go @@ -2,6 +2,7 @@ package envoy_xfcc import ( "context" + "encoding/json" "net/http" "testing" ) @@ -181,6 +182,11 @@ func TestEnvoyXFCCJSONMalformedOrMixedFails(t *testing.T) { if p.Authenticate(context.Background(), duplicateURIKeys, cfg) { t.Fatal("expected duplicate case-insensitive URI keys to fail") } + duplicateExactURIKeys := &http.Request{Header: http.Header{}} + duplicateExactURIKeys.Header.Set("X-Forwarded-Client-Cert", `{"URI":"spiffe://cluster.local/ns/other/sa/caller","URI":"spiffe://cluster.local/ns/team/sa/caller"}`) + if p.Authenticate(context.Background(), duplicateExactURIKeys, cfg) { + t.Fatal("expected duplicate exact URI keys to fail") + } mixed := &http.Request{Header: http.Header{}} mixed.Header.Add("X-Forwarded-Client-Cert", `{"URI":"spiffe://cluster.local/ns/team/sa/caller"}`) @@ -232,6 +238,9 @@ func TestEnvoyXFCCJSONCoverageEdges(t *testing.T) { if _, ok := extractJSONURIs(`{"URI":[""]}`); ok { t.Fatal("expected empty URI array entry to fail") } + if _, ok := extractJSONURIs(`[`); ok { + t.Fatal("expected malformed JSON array to fail") + } if _, ok := extractJSONURIs(""); ok { t.Fatal("expected empty JSON string to fail") } @@ -242,18 +251,30 @@ func TestEnvoyXFCCJSONCoverageEdges(t *testing.T) { t.Fatal("expected non-string URI to fail") } - objNoURI := map[string]interface{}{"By": "proxy"} - if uri, ok := extractURIFromJSONObject(objNoURI); !ok || uri != "" { + if uri, ok := extractURIFromJSONObject(json.RawMessage(`{"By":"proxy"}`)); !ok || uri != "" { t.Fatalf("expected no URI in object: uri=%q ok=%v", uri, ok) } - objURIArray := map[string]interface{}{"URI": []interface{}{"spiffe://ok/a"}} - if uri, ok := extractURIFromJSONObject(objURIArray); !ok || uri != "spiffe://ok/a" { + if uri, ok := extractURIFromJSONObject(json.RawMessage(`{"URI":["spiffe://ok/a"]}`)); !ok || uri != "spiffe://ok/a" { t.Fatalf("unexpected URI array decode: uri=%q ok=%v", uri, ok) } - objBlankURI := map[string]interface{}{"URI": " "} - if uri, ok := extractURIFromJSONObject(objBlankURI); ok || uri != "" { + if uri, ok := extractURIFromJSONObject(json.RawMessage(`{"URI":" "}`)); ok || uri != "" { t.Fatalf("expected blank URI string to fail: uri=%q ok=%v", uri, ok) } + if uri, ok := extractURIFromJSONObject(json.RawMessage(`[]`)); ok || uri != "" { + t.Fatalf("expected non-object JSON to fail: uri=%q ok=%v", uri, ok) + } + if uri, ok := extractURIFromJSONObject(json.RawMessage(``)); ok || uri != "" { + t.Fatalf("expected empty JSON object bytes to fail: uri=%q ok=%v", uri, ok) + } + if uri, ok := extractURIFromJSONObject(json.RawMessage(`{"URI":"spiffe://ok/a"`)); ok || uri != "" { + t.Fatalf("expected unterminated JSON object to fail: uri=%q ok=%v", uri, ok) + } + if uri, ok := extractURIFromJSONObject(json.RawMessage(`{"URI":}`)); ok || uri != "" { + t.Fatalf("expected invalid JSON URI value to fail: uri=%q ok=%v", uri, ok) + } + if uri, ok := extractURIFromJSONObject(json.RawMessage(`{"URI":"spiffe://ok/a",`)); ok || uri != "" { + t.Fatalf("expected malformed trailing-comma object to fail: uri=%q ok=%v", uri, ok) + } } func TestEnvoyXFCCStripHeaderWhenEnabled(t *testing.T) { diff --git a/app/auth/plugins/envoy_xfcc/incoming.go b/app/auth/plugins/envoy_xfcc/incoming.go index 670d360..63e64bb 100644 --- a/app/auth/plugins/envoy_xfcc/incoming.go +++ b/app/auth/plugins/envoy_xfcc/incoming.go @@ -1,6 +1,7 @@ package envoy_xfcc import ( + "bytes" "context" "encoding/json" "net/http" @@ -195,7 +196,7 @@ func extractJSONURIs(raw string) ([]string, bool) { return nil, false } if strings.HasPrefix(trimmed, "[") { - var arr []map[string]interface{} + var arr []json.RawMessage if err := json.Unmarshal([]byte(trimmed), &arr); err != nil { return nil, false } @@ -212,11 +213,7 @@ func extractJSONURIs(raw string) ([]string, bool) { return uris, true } - var obj map[string]interface{} - if err := json.Unmarshal([]byte(trimmed), &obj); err != nil { - return nil, false - } - uri, ok := extractURIFromJSONObject(obj) + uri, ok := extractURIFromJSONObject(json.RawMessage(trimmed)) if !ok { return nil, false } @@ -226,10 +223,29 @@ func extractJSONURIs(raw string) ([]string, bool) { return []string{uri}, true } -func extractURIFromJSONObject(obj map[string]interface{}) (string, bool) { - var uriValue interface{} +func extractURIFromJSONObject(raw json.RawMessage) (string, bool) { + dec := json.NewDecoder(bytes.NewReader(raw)) + start, err := dec.Token() + if err != nil { + return "", false + } + startDelim, ok := start.(json.Delim) + if !ok || startDelim != '{' { + return "", false + } + + var uriRaw json.RawMessage seenURIKey := false - for key, value := range obj { + for dec.More() { + keyToken, err := dec.Token() + if err != nil { + return "", false + } + key := keyToken.(string) + var value json.RawMessage + if err := dec.Decode(&value); err != nil { + return "", false + } if !strings.EqualFold(key, "URI") { continue } @@ -237,29 +253,31 @@ func extractURIFromJSONObject(obj map[string]interface{}) (string, bool) { return "", false } seenURIKey = true - uriValue = value + uriRaw = value } + end, err := dec.Token() + if err != nil { + return "", false + } + _ = end if !seenURIKey { return "", true } - switch v := uriValue.(type) { - case string: - if strings.TrimSpace(v) == "" { - return "", false - } - return v, true - case []interface{}: - if len(v) != 1 { + var uri string + if err := json.Unmarshal(uriRaw, &uri); err == nil { + if strings.TrimSpace(uri) == "" { return "", false } - uri, ok := v[0].(string) - if !ok || strings.TrimSpace(uri) == "" { + return uri, true + } + var uriList []string + if err := json.Unmarshal(uriRaw, &uriList); err == nil { + if len(uriList) != 1 || strings.TrimSpace(uriList[0]) == "" { return "", false } - return uri, true - default: - return "", false + return uriList[0], true } + return "", false } func splitXFCC(raw string, sep rune) ([]string, bool) { From 53a5dc6d249946eb4e053f31dd3bd71db0889d68 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 8 Apr 2026 02:10:48 -0700 Subject: [PATCH 8/8] Reject JSON XFCC values with trailing tokens --- app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go | 8 ++++++++ app/auth/plugins/envoy_xfcc/incoming.go | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go index 2a3cf66..045a144 100644 --- a/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go +++ b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go @@ -187,6 +187,11 @@ func TestEnvoyXFCCJSONMalformedOrMixedFails(t *testing.T) { if p.Authenticate(context.Background(), duplicateExactURIKeys, cfg) { t.Fatal("expected duplicate exact URI keys to fail") } + jsonWithTrailingGarbage := &http.Request{Header: http.Header{}} + jsonWithTrailingGarbage.Header.Set("X-Forwarded-Client-Cert", `{"URI":"spiffe://cluster.local/ns/team/sa/caller"},URI=spiffe://cluster.local/ns/other/sa/caller`) + if p.Authenticate(context.Background(), jsonWithTrailingGarbage, cfg) { + t.Fatal("expected JSON XFCC header with trailing garbage to fail") + } mixed := &http.Request{Header: http.Header{}} mixed.Header.Add("X-Forwarded-Client-Cert", `{"URI":"spiffe://cluster.local/ns/team/sa/caller"}`) @@ -275,6 +280,9 @@ func TestEnvoyXFCCJSONCoverageEdges(t *testing.T) { if uri, ok := extractURIFromJSONObject(json.RawMessage(`{"URI":"spiffe://ok/a",`)); ok || uri != "" { t.Fatalf("expected malformed trailing-comma object to fail: uri=%q ok=%v", uri, ok) } + if uri, ok := extractURIFromJSONObject(json.RawMessage(`{"URI":"spiffe://ok/a"} trailing`)); ok || uri != "" { + t.Fatalf("expected trailing tokens after JSON object to fail: uri=%q ok=%v", uri, ok) + } } func TestEnvoyXFCCStripHeaderWhenEnabled(t *testing.T) { diff --git a/app/auth/plugins/envoy_xfcc/incoming.go b/app/auth/plugins/envoy_xfcc/incoming.go index 63e64bb..a9eb74e 100644 --- a/app/auth/plugins/envoy_xfcc/incoming.go +++ b/app/auth/plugins/envoy_xfcc/incoming.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "io" "net/http" "strings" @@ -260,6 +261,9 @@ func extractURIFromJSONObject(raw json.RawMessage) (string, bool) { return "", false } _ = end + if _, err := dec.Token(); err != io.EOF { + return "", false + } if !seenURIKey { return "", true }