package proxy import ( "context" "net/http" "os" "testing" "github.com/centrifugal/centrifugo/v6/internal/clientcontext" "github.com/centrifugal/centrifugo/v6/internal/metrics" "github.com/centrifugal/centrifugo/v6/internal/middleware" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "google.golang.org/grpc/metadata" ) func TestMain(m *testing.M) { // Initialize metrics with a custom registry for tests to avoid conflicts registry := prometheus.NewRegistry() _ = metrics.Init(metrics.Config{ Registerer: registry, }) os.Exit(m.Run()) } // TestRequestHeaders_StaticHeadersOverride tests that static headers are set // but can be overridden by client headers only when the header key is explicitly // allowed in the configuration. func TestRequestHeaders_StaticHeadersOverride(t *testing.T) { tests := []struct { name string staticHeaders map[string]string clientHeaders http.Header allowedHeaders []string expectedResult map[string]string }{ { name: "static header not overridden when key not allowed", staticHeaders: map[string]string{ "X-Static-Header": "static-value", }, clientHeaders: http.Header{ "X-Static-Header": []string{"client-value"}, }, allowedHeaders: []string{}, // No headers allowed expectedResult: map[string]string{ "X-Static-Header": "static-value", "Content-Type": "application/json", }, }, { name: "static header overridden when key is allowed", staticHeaders: map[string]string{ "X-Static-Header": "static-value", }, clientHeaders: http.Header{ "X-Static-Header": []string{"client-value"}, }, allowedHeaders: []string{"x-static-header"}, // Header is allowed expectedResult: map[string]string{ "X-Static-Header": "client-value", "Content-Type": "application/json", }, }, { name: "multiple static headers with partial override", staticHeaders: map[string]string{ "X-Static-1": "static-1", "X-Static-2": "static-2", }, clientHeaders: http.Header{ "X-Static-1": []string{"client-1"}, "X-Static-2": []string{"client-2"}, }, allowedHeaders: []string{"x-static-1"}, // Only first header is allowed expectedResult: map[string]string{ "X-Static-1": "client-1", // Overridden (allowed) "X-Static-2": "static-2", // Not overridden (not allowed) "Content-Type": "application/json", }, }, { name: "client header not in static set but allowed", staticHeaders: map[string]string{ "X-Static-Header": "static-value", }, clientHeaders: http.Header{ "X-Client-Only": []string{"client-only-value"}, }, allowedHeaders: []string{"x-client-only"}, expectedResult: map[string]string{ "X-Static-Header": "static-value", "X-Client-Only": "client-only-value", "Content-Type": "application/json", }, }, { name: "no static headers", staticHeaders: map[string]string{}, clientHeaders: http.Header{ "X-Client-Header": []string{"client-value"}, }, allowedHeaders: []string{"x-client-header"}, expectedResult: map[string]string{ "X-Client-Header": "client-value", "Content-Type": "application/json", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() ctx = middleware.SetHeadersToContext(ctx, tt.clientHeaders) result := requestHeaders(ctx, tt.allowedHeaders, []string{}, []string{}, tt.staticHeaders) require.Equal(t, len(tt.expectedResult), len(result), "number of headers should match") for expectedKey, expectedValue := range tt.expectedResult { require.Equal(t, expectedValue, result.Get(expectedKey), "header %s should have value %s", expectedKey, expectedValue) } }) } } func TestDroppedEmulatedHeaderNames(t *testing.T) { emulated := map[string]string{ "X-User-Id": "forged", // in http_headers only -> dropped (migration hint) "X-App-Token": "ok", // in emulated_headers -> forwarded, not dropped "X-Other": "x", // in neither list -> not dropped (never forwarded) } dropped := droppedEmulatedHeaderNames(emulated, []string{"x-user-id"}, []string{"x-app-token"}) require.Equal(t, []string{"x-user-id"}, dropped) // Nothing dropped when the name is also in emulated_headers (dual-source). dropped = droppedEmulatedHeaderNames(emulated, []string{"x-user-id"}, []string{"x-app-token", "x-user-id"}) require.Empty(t, dropped) // Nothing dropped when http_headers is empty. dropped = droppedEmulatedHeaderNames(emulated, nil, []string{"x-app-token"}) require.Empty(t, dropped) } func TestEmulatedHeaderAllowList(t *testing.T) { var p Config p.HttpHeaders = []string{"x-user-id"} p.EmulatedHeaders = []string{"x-app-token"} // Only emulated_headers are eligible for emulation; http_headers never are. require.Equal(t, []string{"x-app-token"}, emulatedHeaderAllowList(p)) } // TestRequestHeaders_EmulatedVsTransport verifies that client-supplied emulated // headers (from the connect frame) are forwarded only via emulated_headers, // never via the transport-level http_headers allow list, and that a real transport // header overrides an emulated one when a name is listed in both. func TestRequestHeaders_EmulatedVsTransport(t *testing.T) { tests := []struct { name string emulatedHeaders map[string]string transportHeaders http.Header allowedHeaders []string allowedEmulatedHeaders []string expectedResult map[string]string }{ { name: "emulated header is NOT forwarded via http_headers", emulatedHeaders: map[string]string{"X-User-Id": "spoofed"}, allowedHeaders: []string{"x-user-id"}, expectedResult: map[string]string{ "Content-Type": "application/json", }, }, { name: "emulated header forwarded via emulated_headers", emulatedHeaders: map[string]string{"X-User-Id": "from-client"}, allowedEmulatedHeaders: []string{"x-user-id"}, expectedResult: map[string]string{ "X-User-Id": "from-client", "Content-Type": "application/json", }, }, { name: "transport header overrides emulated when listed in both", emulatedHeaders: map[string]string{"X-User-Id": "spoofed"}, transportHeaders: http.Header{"X-User-Id": []string{"trusted"}}, allowedHeaders: []string{"x-user-id"}, allowedEmulatedHeaders: []string{"x-user-id"}, expectedResult: map[string]string{ "X-User-Id": "trusted", "Content-Type": "application/json", }, }, { name: "transport header forwarded via http_headers", transportHeaders: http.Header{"X-Real-Ip": []string{"1.2.3.4"}}, allowedHeaders: []string{"x-real-ip"}, expectedResult: map[string]string{ "X-Real-Ip": "1.2.3.4", "Content-Type": "application/json", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() if tt.transportHeaders != nil { ctx = middleware.SetHeadersToContext(ctx, tt.transportHeaders) } if tt.emulatedHeaders != nil { ctx = clientcontext.SetEmulatedHeadersToContext(ctx, tt.emulatedHeaders) } result := requestHeaders(ctx, tt.allowedHeaders, []string{}, tt.allowedEmulatedHeaders, map[string]string{}) require.Equal(t, len(tt.expectedResult), len(result), "number of headers should match") for expectedKey, expectedValue := range tt.expectedResult { require.Equal(t, expectedValue, result.Get(expectedKey), "header %s should have value %s", expectedKey, expectedValue) } }) } } // TestRequestHeaders_GrpcMetadataOverridesEmulated covers a client on a gRPC // transport with an HTTP proxy backend, with the same name listed in both // emulated_headers and grpc_metadata. The transport metadata value must land on // the same (canonical) header key as the emulated value and override it - // previously the lowercase metadata key coexisted with the canonical emulated // key and both values were sent as duplicate headers. func TestRequestHeaders_GrpcMetadataOverridesEmulated(t *testing.T) { ctx := context.Background() ctx = clientcontext.SetEmulatedHeadersToContext(ctx, map[string]string{"x-user-id": "emulated-value"}) ctx = metadata.NewIncomingContext(ctx, metadata.MD{"x-user-id": []string{"transport-value"}}) result := requestHeaders(ctx, []string{}, []string{"x-user-id"}, []string{"x-user-id"}, nil) require.Equal(t, []string{"transport-value"}, result["X-User-Id"], "transport metadata value must override the emulated value") // Inspect the raw map by its non-canonical key on purpose to prove no // duplicate lowercase key coexists with the canonical one. Indexing // http.Header with a non-canonical key would be flagged by staticcheck // (SA1008), so convert to the underlying map type for this probe. _, hasLowercaseKey := map[string][]string(result)["x-user-id"] require.False(t, hasLowercaseKey, "no duplicate lowercase header key must remain") require.Len(t, result, 2, "only the canonical header and Content-Type expected") // X-User-Id + Content-Type } // TestRequestMetadata_StaticMetadataOverride tests that static metadata is set // but can be overridden by client metadata only when the metadata key is explicitly // allowed in the configuration. func TestRequestMetadata_StaticMetadataOverride(t *testing.T) { tests := []struct { name string staticMetadata map[string]string clientMetadata metadata.MD allowedMetaKeys []string expectedResult map[string]string }{ { name: "static metadata not overridden when key not allowed", staticMetadata: map[string]string{ "x-static-meta": "static-value", }, clientMetadata: metadata.MD{ "x-static-meta": []string{"client-value"}, }, allowedMetaKeys: []string{}, // No metadata keys allowed expectedResult: map[string]string{ "x-static-meta": "static-value", }, }, { name: "static metadata overridden when key is allowed", staticMetadata: map[string]string{ "x-static-meta": "static-value", }, clientMetadata: metadata.MD{ "x-static-meta": []string{"client-value"}, }, allowedMetaKeys: []string{"x-static-meta"}, // Metadata key is allowed expectedResult: map[string]string{ "x-static-meta": "client-value", }, }, { name: "multiple static metadata with partial override", staticMetadata: map[string]string{ "x-static-1": "static-1", "x-static-2": "static-2", }, clientMetadata: metadata.MD{ "x-static-1": []string{"client-1"}, "x-static-2": []string{"client-2"}, }, allowedMetaKeys: []string{"x-static-1"}, // Only first key is allowed expectedResult: map[string]string{ "x-static-1": "client-1", // Overridden (allowed) "x-static-2": "static-2", // Not overridden (not allowed) }, }, { name: "client metadata not in static set but allowed", staticMetadata: map[string]string{ "x-static-meta": "static-value", }, clientMetadata: metadata.MD{ "x-client-only": []string{"client-only-value"}, }, allowedMetaKeys: []string{"x-client-only"}, expectedResult: map[string]string{ "x-static-meta": "static-value", "x-client-only": "client-only-value", }, }, { name: "no static metadata", staticMetadata: map[string]string{}, clientMetadata: metadata.MD{ "x-client-meta": []string{"client-value"}, }, allowedMetaKeys: []string{"x-client-meta"}, expectedResult: map[string]string{ "x-client-meta": "client-value", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() ctx = metadata.NewIncomingContext(ctx, tt.clientMetadata) result := requestMetadata(ctx, []string{}, tt.allowedMetaKeys, []string{}, tt.staticMetadata) require.Equal(t, len(tt.expectedResult), len(result), "number of metadata entries should match") for expectedKey, expectedValue := range tt.expectedResult { values := result.Get(expectedKey) require.Len(t, values, 1, "metadata key %s should have exactly one value", expectedKey) require.Equal(t, expectedValue, values[0], "metadata %s should have value %s", expectedKey, expectedValue) } }) } } // TestRequestMetadata_EmulatedVsTransport mirrors TestRequestHeaders_EmulatedVsTransport // for the gRPC proxy path: emulated headers are forwarded only via // emulated_headers, never via the http_headers or grpc_metadata allow lists. func TestRequestMetadata_EmulatedVsTransport(t *testing.T) { tests := []struct { name string emulatedHeaders map[string]string transportHeaders http.Header incomingMetadata metadata.MD allowedHeaders []string allowedMetaKeys []string allowedEmulatedHeaders []string expectedResult map[string]string }{ { name: "emulated header is NOT forwarded via http_headers", emulatedHeaders: map[string]string{"X-User-Id": "spoofed"}, allowedHeaders: []string{"x-user-id"}, expectedResult: map[string]string{}, }, { name: "emulated header is NOT forwarded via grpc_metadata", emulatedHeaders: map[string]string{"X-User-Id": "spoofed"}, allowedMetaKeys: []string{"x-user-id"}, expectedResult: map[string]string{}, }, { name: "emulated header forwarded via emulated_headers", emulatedHeaders: map[string]string{"X-User-Id": "from-client"}, allowedEmulatedHeaders: []string{"x-user-id"}, expectedResult: map[string]string{ "x-user-id": "from-client", }, }, { name: "transport http header overrides emulated when listed in both", emulatedHeaders: map[string]string{"X-User-Id": "spoofed"}, transportHeaders: http.Header{"X-User-Id": []string{"trusted"}}, allowedHeaders: []string{"x-user-id"}, allowedEmulatedHeaders: []string{"x-user-id"}, expectedResult: map[string]string{ "x-user-id": "trusted", }, }, { name: "incoming grpc metadata forwarded via grpc_metadata", incomingMetadata: metadata.MD{"x-client-meta": []string{"meta-value"}}, allowedMetaKeys: []string{"x-client-meta"}, expectedResult: map[string]string{ "x-client-meta": "meta-value", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() if tt.transportHeaders != nil { ctx = middleware.SetHeadersToContext(ctx, tt.transportHeaders) } if tt.incomingMetadata != nil { ctx = metadata.NewIncomingContext(ctx, tt.incomingMetadata) } if tt.emulatedHeaders != nil { ctx = clientcontext.SetEmulatedHeadersToContext(ctx, tt.emulatedHeaders) } result := requestMetadata(ctx, tt.allowedHeaders, tt.allowedMetaKeys, tt.allowedEmulatedHeaders, map[string]string{}) require.Equal(t, len(tt.expectedResult), len(result), "number of metadata entries should match") for expectedKey, expectedValue := range tt.expectedResult { require.Equal(t, expectedValue, result.Get(expectedKey)[0], "metadata %s should have value %s", expectedKey, expectedValue) } }) } } // TestRequestHeaders_DualSourceHeader verifies that listing the same name in // both http_headers and emulated_headers is valid (the browser-emulation // + native-client case for a backend-validated header like Authorization): the // transport value is preferred and the emulated value is used as a fallback. func TestRequestHeaders_DualSourceHeader(t *testing.T) { const allowed = "authorization" // Browser client: no transport header, emulated value is forwarded. ctx := clientcontext.SetEmulatedHeadersToContext(context.Background(), map[string]string{"Authorization": "Bearer from-emulation"}) result := requestHeaders(ctx, []string{allowed}, []string{}, []string{allowed}, map[string]string{}) require.Equal(t, "Bearer from-emulation", result.Get("Authorization")) // Native client: real transport header wins over the emulated one. ctx = middleware.SetHeadersToContext(context.Background(), http.Header{"Authorization": []string{"Bearer from-transport"}}) ctx = clientcontext.SetEmulatedHeadersToContext(ctx, map[string]string{"Authorization": "Bearer from-emulation"}) result = requestHeaders(ctx, []string{allowed}, []string{}, []string{allowed}, map[string]string{}) require.Equal(t, "Bearer from-transport", result.Get("Authorization")) }