448 lines
16 KiB
Go
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"))
|
|
}
|