From bf6d5928009271c5610eb7135382c89810ef3845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Mi=C5=9B?= Date: Mon, 20 Jul 2026 15:33:30 +0200 Subject: [PATCH 1/2] Implementation of obfuscation mechanism for authorization headers when recording raw request. --- config/config.go | 4 + gateway/analytics_helper.go | 94 ++++++++++ gateway/analytics_helper_test.go | 285 +++++++++++++++++++++++++++++++ gateway/handler_error.go | 6 +- gateway/handler_success.go | 39 +---- gateway/handler_success_test.go | 79 --------- 6 files changed, 387 insertions(+), 120 deletions(-) create mode 100644 gateway/analytics_helper.go create mode 100644 gateway/analytics_helper_test.go diff --git a/config/config.go b/config/config.go index 79c1bf1fd09..d34889d2c4d 100644 --- a/config/config.go +++ b/config/config.go @@ -278,6 +278,10 @@ type AnalyticsConfigConfig struct { // This setting can be overridden with an organization flag, enabed at an API level, or on individual Key level. EnableDetailedRecording bool `json:"enable_detailed_recording"` + // AllowUnsafeDetailedLogs controls whether sensitive headers (Authorization) are obfuscated when detailed recording is enabled. + // Setting this to true restores the legacy behavior where raw requests are captured as-is, which may expose sensitive tokens in plain text. + AllowUnsafeDetailedLogs bool `json:"allow_unsafe_detailed_logs"` + // Tyk can store GeoIP information based on MaxMind DB’s to enable GeoIP tracking on inbound request analytics. Set this value to `true` and assign a DB using the `geo_ip_db_path` setting. EnableGeoIP bool `json:"enable_geo_ip"` diff --git a/gateway/analytics_helper.go b/gateway/analytics_helper.go new file mode 100644 index 00000000000..8059494005a --- /dev/null +++ b/gateway/analytics_helper.go @@ -0,0 +1,94 @@ +package gateway + +import ( + "bytes" + "encoding/base64" + "net/http" + + "github.com/TykTechnologies/tyk/apidef" + "github.com/TykTechnologies/tyk/ctx" + "github.com/TykTechnologies/tyk/header" + "github.com/TykTechnologies/tyk/internal/httputil" + "github.com/TykTechnologies/tyk/user" +) + +func getRawRequest(r *http.Request, spec *APISpec) string { + var wireFormatReq bytes.Buffer + + var originalHeaders http.Header + if !spec.GlobalConfig.AnalyticsConfig.AllowUnsafeDetailedLogs { + originalHeaders = obfuscateAuthorizationHeaders(r, spec) + } + + r.Write(&wireFormatReq) + rawRequest := base64.StdEncoding.EncodeToString(wireFormatReq.Bytes()) + + if originalHeaders != nil { + r.Header = originalHeaders + } + + return rawRequest +} + +func obfuscateAuthorizationHeaders(r *http.Request, spec *APISpec) http.Header { + original := r.Header.Clone() + authNames := make([]string, 0) + + addAuthHeader := func(config apidef.AuthConfig) { + if config.DisableHeader { + return + } + + name := config.AuthHeaderName + if name == "" { + name = header.Authorization + } + authNames = append(authNames, http.CanonicalHeaderKey(name)) + } + + addAuthHeader(spec.Auth) + + for _, authConfig := range spec.AuthConfigs { + addAuthHeader(authConfig) + } + + for _, authName := range authNames { + if _, ok := r.Header[authName]; ok { + r.Header.Set(authName, obfuscationToken) + } + } + + return original +} + +func recordDetail(r *http.Request, spec *APISpec) bool { + // when streaming in grpc, we do not record the request + if httputil.IsStreamingRequest(r) { + return false + } + + return recordDetailUnsafe(r, spec) +} + +func recordDetailUnsafe(r *http.Request, spec *APISpec) bool { + if spec.EnableDetailedRecording { + return true + } + + if session := ctxGetSession(r); session != nil { + if session.EnableDetailedRecording || session.EnableDetailRecording { // nolint:staticcheck // Deprecated DetailRecording + return true + } + } + + // decide based on org session. + if spec.GlobalConfig.EnforceOrgDataDetailLogging { + session, ok := r.Context().Value(ctx.OrgSessionContext).(*user.SessionState) + if ok && session != nil { + return session.EnableDetailedRecording || session.EnableDetailRecording // nolint:staticcheck // Deprecated DetailRecording + } + } + + // no org session found, use global config + return spec.GraphQL.Enabled || spec.GlobalConfig.AnalyticsConfig.EnableDetailedRecording +} diff --git a/gateway/analytics_helper_test.go b/gateway/analytics_helper_test.go new file mode 100644 index 00000000000..e06f4255df3 --- /dev/null +++ b/gateway/analytics_helper_test.go @@ -0,0 +1,285 @@ +package gateway + +import ( + "bytes" + "context" + "encoding/base64" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TykTechnologies/tyk/apidef" + "github.com/TykTechnologies/tyk/config" + ctxpkg "github.com/TykTechnologies/tyk/ctx" + "github.com/TykTechnologies/tyk/user" +) + +func TestObfuscateAuthorizationHeaders(t *testing.T) { + tests := []struct { + name string + spec *APISpec + headers map[string]string + expectedHeader map[string]string + }{ + { + name: "Default Authorization header", + spec: &APISpec{ + APIDefinition: &apidef.APIDefinition{ + Auth: apidef.AuthConfig{}, // Empty AuthHeaderName defaults to "Authorization" + }, + }, + headers: map[string]string{ + "Authorization": "Bearer secret-token", + "X-Custom": "value", + }, + expectedHeader: map[string]string{ + "Authorization": obfuscationToken, + "X-Custom": "value", + }, + }, + { + name: "Custom AuthHeaderName in spec.Auth", + spec: &APISpec{ + APIDefinition: &apidef.APIDefinition{ + Auth: apidef.AuthConfig{ + AuthHeaderName: "X-Api-Key", + }, + }, + }, + headers: map[string]string{ + "X-Api-Key": "my-secret-key", + }, + expectedHeader: map[string]string{ + "X-Api-Key": obfuscationToken, + }, + }, + { + name: "Multiple AuthConfigs", + spec: &APISpec{ + APIDefinition: &apidef.APIDefinition{ + AuthConfigs: map[string]apidef.AuthConfig{ + "jwt": {AuthHeaderName: "X-Jwt-Token"}, + "oidc": {AuthHeaderName: "X-Oidc-Token"}, + }, + }, + }, + headers: map[string]string{ + "X-Jwt-Token": "jwt-secret", + "X-Oidc-Token": "oidc-secret", + "X-Normal": "normal-value", + }, + expectedHeader: map[string]string{ + "X-Jwt-Token": obfuscationToken, + "X-Oidc-Token": obfuscationToken, + "X-Normal": "normal-value", + }, + }, + { + name: "DisableHeader is true", + spec: &APISpec{ + APIDefinition: &apidef.APIDefinition{ + Auth: apidef.AuthConfig{ + AuthHeaderName: "Authorization", + DisableHeader: true, + }, + }, + }, + headers: map[string]string{ + "Authorization": "Bearer secret-token", + }, + expectedHeader: map[string]string{ + "Authorization": "Bearer secret-token", + }, + }, + { + name: "Case insensitivity", + spec: &APISpec{ + APIDefinition: &apidef.APIDefinition{ + Auth: apidef.AuthConfig{ + AuthHeaderName: "x-api-key", + }, + }, + }, + headers: map[string]string{ + "X-API-KEY": "secret-key", + }, + expectedHeader: map[string]string{ + "X-Api-Key": obfuscationToken, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + require.NoError(t, err) + + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + expectedOriginalHeaders := req.Header.Clone() + returnedOriginalHeaders := obfuscateAuthorizationHeaders(req, tt.spec) + + for k, v := range tt.expectedHeader { + assert.Equal(t, v, req.Header.Get(k), "Mutated header %s mismatch", k) + } + + assert.Equal(t, expectedOriginalHeaders, returnedOriginalHeaders, "Returned original headers mismatch") + }) + } +} + +func TestGetRawRequest(t *testing.T) { + tests := []struct { + name string + allowUnsafeDetailedLogs bool + headers map[string]string + expectObfuscated bool + }{ + { + name: "Secure by default (AllowUnsafeDetailedLogs = false)", + allowUnsafeDetailedLogs: false, + headers: map[string]string{ + "Authorization": "Bearer secret-token", + "X-Custom": "value", + }, + expectObfuscated: true, + }, + { + name: "Legacy unsafe mode (AllowUnsafeDetailedLogs = true)", + allowUnsafeDetailedLogs: true, + headers: map[string]string{ + "Authorization": "Bearer secret-token", + "X-Custom": "value", + }, + expectObfuscated: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content := "body-content" + req, err := http.NewRequest("GET", "/test-path", bytes.NewBufferString(content)) + require.NoError(t, err) + req.Host = "localhost" + + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + expectedRestoredHeaders := req.Header.Clone() + + spec := &APISpec{ + APIDefinition: &apidef.APIDefinition{ + Auth: apidef.AuthConfig{}, // Defaults to Authorization + }, + } + + spec.GlobalConfig = config.Config{ + AnalyticsConfig: config.AnalyticsConfigConfig{ + AllowUnsafeDetailedLogs: tt.allowUnsafeDetailedLogs, + }, + } + + rawRequestBase64 := getRawRequest(req, spec) + rawRequestBytes, err := base64.StdEncoding.DecodeString(rawRequestBase64) + require.NoError(t, err) + rawRequestStr := string(rawRequestBytes) + + assert.Contains(t, rawRequestStr, "GET /test-path HTTP/1.1") + assert.Contains(t, rawRequestStr, content) + assert.Contains(t, rawRequestStr, "X-Custom: value") + + if tt.expectObfuscated { + assert.Contains(t, rawRequestStr, "Authorization: "+obfuscationToken) + assert.NotContains(t, rawRequestStr, "Bearer secret-token") + } else { + assert.Contains(t, rawRequestStr, "Authorization: Bearer secret-token") + assert.NotContains(t, rawRequestStr, "Authorization: "+obfuscationToken) + } + + assert.Equal(t, expectedRestoredHeaders, req.Header, "Original request headers were not restored") + }) + } +} + +func TestRecordDetail(t *testing.T) { + testcases := []struct { + title string + spec *APISpec + binding bindContextFunc + expect bool + }{ + { + title: "empty session", + spec: testAPISpec(nil), + expect: false, + }, + { + title: "empty session, enabled analytics", + spec: testAPISpec(func(spec *APISpec) { + spec.EnableDetailedRecording = true + }), + expect: true, + }, + { + title: "empty session, enabled config", + spec: testAPISpec(func(spec *APISpec) { + spec.GlobalConfig.EnforceOrgDataDetailLogging = false + spec.GlobalConfig.AnalyticsConfig.EnableDetailedRecording = true + }), + expect: true, + }, + { + title: "normal session", + spec: testAPISpec(nil), + // attach user session + binding: func(ctx context.Context) context.Context { + session := &user.SessionState{ + EnableDetailedRecording: true, + } + return context.WithValue(ctx, ctxpkg.SessionData, session) + }, + expect: true, + }, + { + title: "org empty session", + spec: testAPISpec(func(spec *APISpec) { + spec.GlobalConfig.EnforceOrgDataDetailLogging = true + }), + expect: false, + }, + { + title: "org session", + spec: testAPISpec(func(spec *APISpec) { + spec.GlobalConfig.EnforceOrgDataDetailLogging = true + }), + // attach user session + binding: func(ctx context.Context) context.Context { + session := &user.SessionState{ + EnableDetailedRecording: true, + } + return context.WithValue(ctx, ctxpkg.OrgSessionContext, session) + }, + expect: true, + }, + { + title: "graphql request", + spec: testAPISpec(func(spec *APISpec) { + spec.GraphQL.Enabled = true + }), + expect: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.title, func(t *testing.T) { + req := testRequestWithContext(tc.binding) + got := recordDetail(req, tc.spec) + assert.Equal(t, tc.expect, got) + }) + } +} diff --git a/gateway/handler_error.go b/gateway/handler_error.go index 51ddd622d11..1e75cc8a3e5 100644 --- a/gateway/handler_error.go +++ b/gateway/handler_error.go @@ -225,11 +225,7 @@ func (e *ErrorHandler) HandleError(w http.ResponseWriter, r *http.Request, errMs rawResponse := "" if recordDetail(r, e.Spec) { - // Get the wire format representation - - var wireFormatReq bytes.Buffer - r.Write(&wireFormatReq) - rawRequest = base64.StdEncoding.EncodeToString(wireFormatReq.Bytes()) + rawRequest = getRawRequest(r, e.Spec) var wireFormatRes bytes.Buffer response.Write(&wireFormatRes) diff --git a/gateway/handler_success.go b/gateway/handler_success.go index 51b758b3745..6a36bba706f 100644 --- a/gateway/handler_success.go +++ b/gateway/handler_success.go @@ -27,6 +27,7 @@ import ( const ( keyDataDeveloperID = "tyk_developer_id" keyDataDeveloperEmail = "tyk_developer_email" + obfuscationToken = "***" ) type ProxyResponse struct { @@ -237,10 +238,8 @@ func (s *SuccessHandler) RecordHit(r *http.Request, timing analytics.Latency, co rawResponse := "" if recordDetail(r, s.Spec) { - // Get the wire format representation - var wireFormatReq bytes.Buffer - r.Write(&wireFormatReq) - rawRequest = base64.StdEncoding.EncodeToString(wireFormatReq.Bytes()) + rawRequest = getRawRequest(r, s.Spec) + // responseCopy, unlike requestCopy, can be nil // here - if the response was cached in // mw_redis_cache, RecordHit gets passed a nil @@ -361,38 +360,6 @@ func (s *SuccessHandler) RecordHit(r *http.Request, timing analytics.Latency, co reportHealthValue(s.Spec, RequestLog, strconv.FormatInt(timing.Total, 10)) } -func recordDetail(r *http.Request, spec *APISpec) bool { - // when streaming in grpc, we do not record the request - if httputil.IsStreamingRequest(r) { - return false - } - - return recordDetailUnsafe(r, spec) -} - -func recordDetailUnsafe(r *http.Request, spec *APISpec) bool { - if spec.EnableDetailedRecording { - return true - } - - if session := ctxGetSession(r); session != nil { - if session.EnableDetailedRecording || session.EnableDetailRecording { // nolint:staticcheck // Deprecated DetailRecording - return true - } - } - - // decide based on org session. - if spec.GlobalConfig.EnforceOrgDataDetailLogging { - session, ok := r.Context().Value(ctx.OrgSessionContext).(*user.SessionState) - if ok && session != nil { - return session.EnableDetailedRecording || session.EnableDetailRecording // nolint:staticcheck // Deprecated DetailRecording - } - } - - // no org session found, use global config - return spec.GraphQL.Enabled || spec.GlobalConfig.AnalyticsConfig.EnableDetailedRecording -} - // classifyUpstreamError classifies upstream responses for structured access logs. // Currently handles 5XX status codes; can be extended for other error classifications. // If a classification was already set earlier in the request lifecycle (e.g. NHU from diff --git a/gateway/handler_success_test.go b/gateway/handler_success_test.go index e34694d991e..881654afded 100644 --- a/gateway/handler_success_test.go +++ b/gateway/handler_success_test.go @@ -19,7 +19,6 @@ import ( "github.com/TykTechnologies/tyk/config" ctxpkg "github.com/TykTechnologies/tyk/ctx" "github.com/TykTechnologies/tyk/test" - "github.com/TykTechnologies/tyk/user" ) type bindContextFunc = func(context.Context) context.Context @@ -45,84 +44,6 @@ func testAPISpec(binding bindAPIDefFunc) *APISpec { return spec } -func TestRecordDetail(t *testing.T) { - testcases := []struct { - title string - spec *APISpec - binding bindContextFunc - expect bool - }{ - { - title: "empty session", - spec: testAPISpec(nil), - expect: false, - }, - { - title: "empty session, enabled analytics", - spec: testAPISpec(func(spec *APISpec) { - spec.EnableDetailedRecording = true - }), - expect: true, - }, - { - title: "empty session, enabled config", - spec: testAPISpec(func(spec *APISpec) { - spec.GlobalConfig.EnforceOrgDataDetailLogging = false - spec.GlobalConfig.AnalyticsConfig.EnableDetailedRecording = true - }), - expect: true, - }, - { - title: "normal session", - spec: testAPISpec(nil), - // attach user session - binding: func(ctx context.Context) context.Context { - session := &user.SessionState{ - EnableDetailedRecording: true, - } - return context.WithValue(ctx, ctxpkg.SessionData, session) - }, - expect: true, - }, - { - title: "org empty session", - spec: testAPISpec(func(spec *APISpec) { - spec.GlobalConfig.EnforceOrgDataDetailLogging = true - }), - expect: false, - }, - { - title: "org session", - spec: testAPISpec(func(spec *APISpec) { - spec.GlobalConfig.EnforceOrgDataDetailLogging = true - }), - // attach user session - binding: func(ctx context.Context) context.Context { - session := &user.SessionState{ - EnableDetailedRecording: true, - } - return context.WithValue(ctx, ctxpkg.OrgSessionContext, session) - }, - expect: true, - }, - { - title: "graphql request", - spec: testAPISpec(func(spec *APISpec) { - spec.GraphQL.Enabled = true - }), - expect: true, - }, - } - - for _, tc := range testcases { - t.Run(tc.title, func(t *testing.T) { - req := testRequestWithContext(tc.binding) - got := recordDetail(req, tc.spec) - assert.Equal(t, tc.expect, got) - }) - } -} - func TestAnalyticRecord_GraphStats(t *testing.T) { generateApiDefinition := func(spec *APISpec) { From f50c2500cce85265efc6d0e4416821b252c2b365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Mi=C5=9B?= Date: Tue, 21 Jul 2026 12:16:54 +0200 Subject: [PATCH 2/2] Refactored code a bit - introduced defer and moved obfuscation token. Included new config var in schema --- cli/linter/schema.json | 3 +++ gateway/analytics_helper.go | 9 +++++---- gateway/handler_success.go | 1 - 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cli/linter/schema.json b/cli/linter/schema.json index 5acf641f396..9376c0f4a6a 100644 --- a/cli/linter/schema.json +++ b/cli/linter/schema.json @@ -509,6 +509,9 @@ "enable_detailed_recording": { "type": "boolean" }, + "allow_unsafe_detailed_logs": { + "type": "boolean" + }, "purge_interval": { "type": "number" }, diff --git a/gateway/analytics_helper.go b/gateway/analytics_helper.go index 8059494005a..6b73c6a95f4 100644 --- a/gateway/analytics_helper.go +++ b/gateway/analytics_helper.go @@ -12,21 +12,22 @@ import ( "github.com/TykTechnologies/tyk/user" ) +const obfuscationToken = "***" + func getRawRequest(r *http.Request, spec *APISpec) string { var wireFormatReq bytes.Buffer var originalHeaders http.Header if !spec.GlobalConfig.AnalyticsConfig.AllowUnsafeDetailedLogs { originalHeaders = obfuscateAuthorizationHeaders(r, spec) + defer func() { + r.Header = originalHeaders + }() } r.Write(&wireFormatReq) rawRequest := base64.StdEncoding.EncodeToString(wireFormatReq.Bytes()) - if originalHeaders != nil { - r.Header = originalHeaders - } - return rawRequest } diff --git a/gateway/handler_success.go b/gateway/handler_success.go index 6a36bba706f..eeaf6a80fac 100644 --- a/gateway/handler_success.go +++ b/gateway/handler_success.go @@ -27,7 +27,6 @@ import ( const ( keyDataDeveloperID = "tyk_developer_id" keyDataDeveloperEmail = "tyk_developer_email" - obfuscationToken = "***" ) type ProxyResponse struct {