Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions server/lib/events/otlpstorage.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -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.
Expand All @@ -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
},
}))
}

Expand Down
83 changes: 83 additions & 0 deletions server/lib/events/otlpstorage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,86 @@ func TestOTLPStorageWriter_RefreshesAuthToken(t *testing.T) {
defer stopCancel()
require.NoError(t, wtr.Stop(stopCtx))
}

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

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)

metrics := &OTLPMetrics{}
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,
Metrics: metrics,
}
wtr := NewOTLPStorageWriter(es, cfg, slog.Default())
ctx, cancel := context.WithCancel(context.Background())
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 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")
}

type roundTripperFunc func(*http.Request) (*http.Response, error)

func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
Loading