Files
upstream-centrifugo/internal/proxy/proxy_test.go
upstream-mirror b352a9a30d
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s
snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:17:21 +05:00

448 lines
16 KiB
Go

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"))
}