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..045a144 --- /dev/null +++ b/app/auth/plugins/envoy_xfcc/envoy_xfcc_test.go @@ -0,0 +1,432 @@ +package envoy_xfcc + +import ( + "context" + "encoding/json" + "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 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 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") + } + 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") + } + 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") + } + 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"}`) + 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 malformed JSON array 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") + } + + if uri, ok := extractURIFromJSONObject(json.RawMessage(`{"By":"proxy"}`)); !ok || uri != "" { + t.Fatalf("expected no URI in object: uri=%q ok=%v", uri, ok) + } + 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) + } + 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) + } + 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) { + 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()) != 4 { + 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/"}, "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 stripped") + } + 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") + } + values := []string{} + if joined := joinHeaderValues(values); joined != "" { + t.Fatalf("expected empty joined header, got %q", joined) + } + 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) + } + + 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..a9eb74e --- /dev/null +++ b/app/auth/plugins/envoy_xfcc/incoming.go @@ -0,0 +1,368 @@ +package envoy_xfcc + +import ( + "bytes" + "context" + "encoding/json" + "io" + "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"` +} + +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"} +} + +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" + } + 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 := extractCallerIdentityFromValues(r.Header.Values(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 + } + 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 + } + elements, ok := splitXFCC(raw, ',') + if !ok { + return "", false + } + candidates := []string{} + 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 + } + 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 = candidate + } + if selected == "" { + return "", false + } + if !isAllowedIdentity(selected, cfg) { + return "", false + } + return selected, true +} + +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 []json.RawMessage + 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 + } + + uri, ok := extractURIFromJSONObject(json.RawMessage(trimmed)) + if !ok { + return nil, false + } + if uri == "" { + return []string{}, true + } + return []string{uri}, true +} + +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 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 + } + if seenURIKey { + return "", false + } + seenURIKey = true + uriRaw = value + } + end, err := dec.Token() + if err != nil { + return "", false + } + _ = end + if _, err := dec.Token(); err != io.EOF { + return "", false + } + if !seenURIKey { + return "", true + } + var uri string + if err := json.Unmarshal(uriRaw, &uri); err == nil { + if strings.TrimSpace(uri) == "" { + return "", false + } + 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 uriList[0], true + } + return "", false +} + +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..8f8cf65 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 +``` + +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. + ### Outbound `find_replace` ```yaml