From 0bd7f3107904f6ac6e75a1ea66258ec6ddc5adcc Mon Sep 17 00:00:00 2001 From: archandatta <35818003+archandatta@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:29:11 +0000 Subject: [PATCH 1/2] Keep the OTLP export credential on the configured endpoint The exporter's bearer credential is set in a RoundTripper so a token that changes after start, the fork-refreshed instance JWT, is resolved per request. A RoundTripper runs on every hop, which means net/http's own cross-host Authorization strip never sees the header and cannot remove it. Pin the credential to the configured endpoint host and stop following redirects. The export target is configured rather than discovered, so a redirect is never something to act on, and a host the check cannot match now sends no credential at all. Co-Authored-By: Claude Opus 5 --- server/lib/events/otlpstorage.go | 15 +++++- server/lib/events/otlpstorage_test.go | 77 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/server/lib/events/otlpstorage.go b/server/lib/events/otlpstorage.go index 7df5875db..35bfe0319 100644 --- a/server/lib/events/otlpstorage.go +++ b/server/lib/events/otlpstorage.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "sync" "time" @@ -170,14 +171,19 @@ type otlpStorage struct { // bearerRoundTripper sets the Authorization header from token() on each request, // so a credential that changes after the exporter is built (the fork-refreshed // instance JWT) is picked up per request rather than frozen at construction. +// +// A RoundTripper runs on every hop, so net/http's cross-host Authorization strip +// never sees this header. host bounds it instead: only the configured export +// endpoint is authenticated, and any other host sends no credential at all. type bearerRoundTripper struct { base http.RoundTripper token func() string + host string } func (t *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { tok := t.token() - if tok == "" { + if tok == "" || !strings.EqualFold(req.URL.Host, t.host) { return t.base.RoundTrip(req) } // RoundTrip must not mutate the caller's request; clone before setting. @@ -203,7 +209,12 @@ func newOTLPStorage(ctx context.Context, cfg OTLPConfig, log *slog.Logger) (*otl } if cfg.AuthTokenFunc != nil { opts = append(opts, otlploghttp.WithHTTPClient(&http.Client{ - Transport: &bearerRoundTripper{base: http.DefaultTransport, token: cfg.AuthTokenFunc}, + Transport: &bearerRoundTripper{base: http.DefaultTransport, token: cfg.AuthTokenFunc, host: cfg.Endpoint}, + // The export target is configured, not discovered, so a redirect is + // never something to act on. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, })) } diff --git a/server/lib/events/otlpstorage_test.go b/server/lib/events/otlpstorage_test.go index be1644deb..a5b62d76c 100644 --- a/server/lib/events/otlpstorage_test.go +++ b/server/lib/events/otlpstorage_test.go @@ -302,3 +302,80 @@ func TestOTLPStorageWriter_RefreshesAuthToken(t *testing.T) { defer stopCancel() require.NoError(t, wtr.Stop(stopCtx)) } + +// TestBearerRoundTripper_AuthenticatesOnlyConfiguredHost covers the host bound: +// net/http's cross-host strip never sees a header set in a RoundTripper. +func TestBearerRoundTripper_AuthenticatesOnlyConfiguredHost(t *testing.T) { + for _, tc := range []struct { + name string + url string + wantAuth string + }{ + {"configured host", "http://relay.example:4000/otlp-relay/v1/logs", "Bearer jwt"}, + {"same host uppercased", "http://RELAY.EXAMPLE:4000/otlp-relay/v1/logs", "Bearer jwt"}, + {"other host", "http://elsewhere.example/v1/logs", ""}, + {"same host other port", "http://relay.example:9999/v1/logs", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + var got string + rt := &bearerRoundTripper{ + base: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + got = r.Header.Get("Authorization") + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + }), + token: func() string { return "jwt" }, + host: "relay.example:4000", + } + req, err := http.NewRequest(http.MethodPost, tc.url, nil) + require.NoError(t, err) + _, err = rt.RoundTrip(req) + require.NoError(t, err) + assert.Equal(t, tc.wantAuth, got) + }) + } +} + +// TestOTLPStorageWriter_DoesNotFollowRedirects confirms a redirect from the +// endpoint surfaces as a response rather than being chased. +func TestOTLPStorageWriter_DoesNotFollowRedirects(t *testing.T) { + var redirectTargetCalls atomic.Int32 + redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + redirectTargetCalls.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer redirectTarget.Close() + + var endpointCalls atomic.Int32 + endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + endpointCalls.Add(1) + w.Header().Set("Location", redirectTarget.URL+"/v1/logs") + w.WriteHeader(http.StatusFound) + })) + defer endpoint.Close() + + es, err := NewEventStream(EventStreamConfig{RingCapacity: 64}) + require.NoError(t, err) + + cfg := OTLPConfig{ + Endpoint: strings.TrimPrefix(endpoint.URL, "http://"), + URLPath: "/otlp-relay/v1/logs", + Insecure: true, + AuthTokenFunc: func() string { return "jwt" }, + ServiceName: "kernel-browser", + ExportInterval: 20 * time.Millisecond, + } + wtr := NewOTLPStorageWriter(es, cfg, slog.Default()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + require.NoError(t, wtr.Start(ctx)) + + es.Publish(Envelope{Event: Event{Ts: 1, Type: "network_response", Category: Network, + Data: []byte(`{"method":"GET","url":"https://x","status":200}`)}}) + require.Eventually(t, func() bool { return endpointCalls.Load() > 0 }, 3*time.Second, 10*time.Millisecond, + "the export should reach the configured endpoint") + assert.Zero(t, redirectTargetCalls.Load(), "the exporter must not follow the endpoint's redirect") +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } From 89155ab15916abd56cba242aa1ba566e14373893 Mon Sep 17 00:00:00 2001 From: archandatta <35818003+archandatta@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:06:17 +0000 Subject: [PATCH 2/2] Make OTLP redirect test deterministic --- server/lib/events/otlpstorage_test.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/server/lib/events/otlpstorage_test.go b/server/lib/events/otlpstorage_test.go index a5b62d76c..3691c7bb9 100644 --- a/server/lib/events/otlpstorage_test.go +++ b/server/lib/events/otlpstorage_test.go @@ -303,8 +303,6 @@ func TestOTLPStorageWriter_RefreshesAuthToken(t *testing.T) { require.NoError(t, wtr.Stop(stopCtx)) } -// TestBearerRoundTripper_AuthenticatesOnlyConfiguredHost covers the host bound: -// net/http's cross-host strip never sees a header set in a RoundTripper. func TestBearerRoundTripper_AuthenticatesOnlyConfiguredHost(t *testing.T) { for _, tc := range []struct { name string @@ -335,8 +333,6 @@ func TestBearerRoundTripper_AuthenticatesOnlyConfiguredHost(t *testing.T) { } } -// TestOTLPStorageWriter_DoesNotFollowRedirects confirms a redirect from the -// endpoint surfaces as a response rather than being chased. func TestOTLPStorageWriter_DoesNotFollowRedirects(t *testing.T) { var redirectTargetCalls atomic.Int32 redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -356,6 +352,7 @@ func TestOTLPStorageWriter_DoesNotFollowRedirects(t *testing.T) { es, err := NewEventStream(EventStreamConfig{RingCapacity: 64}) require.NoError(t, err) + metrics := &OTLPMetrics{} cfg := OTLPConfig{ Endpoint: strings.TrimPrefix(endpoint.URL, "http://"), URLPath: "/otlp-relay/v1/logs", @@ -363,16 +360,25 @@ func TestOTLPStorageWriter_DoesNotFollowRedirects(t *testing.T) { AuthTokenFunc: func() string { return "jwt" }, ServiceName: "kernel-browser", ExportInterval: 20 * time.Millisecond, + Metrics: metrics, } wtr := NewOTLPStorageWriter(es, cfg, slog.Default()) ctx, cancel := context.WithCancel(context.Background()) - defer cancel() require.NoError(t, wtr.Start(ctx)) + t.Cleanup(func() { + cancel() + stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer stopCancel() + require.NoError(t, wtr.Stop(stopCtx)) + }) es.Publish(Envelope{Event: Event{Ts: 1, Type: "network_response", Category: Network, Data: []byte(`{"method":"GET","url":"https://x","status":200}`)}}) - require.Eventually(t, func() bool { return endpointCalls.Load() > 0 }, 3*time.Second, 10*time.Millisecond, - "the export should reach the configured endpoint") + require.Eventually(t, func() bool { return metrics.Failures() > 0 }, 3*time.Second, 10*time.Millisecond, + "the redirect response should surface as an export failure") + assert.Equal(t, uint64(1), metrics.Failures(), "the redirect response must not be retried") + assert.Zero(t, metrics.Exported(), "the redirect response must not count as a successful export") + assert.Equal(t, int32(1), endpointCalls.Load(), "the redirect response must not be retried") assert.Zero(t, redirectTargetCalls.Load(), "the exporter must not follow the endpoint's redirect") }