Skip to content

Commit 8b8a88f

Browse files
authored
fix(observability): mask ?apikey= in the http.url span attribute (#1790)
## Bug When OTLP tracing is enabled (`observability.tracing.enabled`), `TracingManager.HTTPMiddleware` wraps the whole REST API router and records `r.URL.String()` as the `http.url` attribute of every span. That string includes the query. `?apikey=` is an accepted credential (`resolveAuth`). The Web UI's SSE stream connects to `/events?apikey=<admin key>` (`frontend/src/services/api.ts`), and the tray sends the admin key the same way. So the admin API key lands in span attributes and is exported to the configured collector (Jaeger, Tempo, a shared OTel pipeline). Anyone who can read traces can then drive the REST API as admin. `httpLoggingMiddleware` already masks the same query before it writes to http.log (SEC-01). The tracing middleware sees the same requests and was missed. ## Trigger Enable tracing, then open the Web UI. Every SSE (re)connect is a `GET /events?apikey=…` request, and each sampled one exports the key. ## Fix Build `http.url` from a copy of the request URL whose `RawQuery` has been passed through `oauth.LogSafeQueryString`, the same renderer http.log uses. The path and the non-secret parameters stay as they were. `http.target` and the span name already use only the path. `internal/oauth` does not import `internal/observability`, so the new import adds no cycle. ## Test `TestHTTPMiddleware_MasksAPIKeyInSpanURL` sends `/events?apikey=<64-hex key>&limit=5` through the middleware with an in-memory span recorder. The recorded `http.url` must not contain the key, and must keep `/events?` and `limit=5`. ## Commands run - `CGO_ENABLED=0 go test -count=1 -run 'Test' ./internal/observability/`: ok - On current `main`, and again with the fix reverted, the new test fails with `http.url carries the API key: "/events?apikey=3f9a…b5c6&limit=5"`. - `go vet ./internal/observability/`: ok
1 parent 9ac8a99 commit 8b8a88f

2 files changed

Lines changed: 68 additions & 1 deletion

File tree

‎internal/observability/tracing.go‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"net/http"
7+
"net/url"
78

89
"go.opentelemetry.io/otel"
910
"go.opentelemetry.io/otel/attribute"
@@ -15,6 +16,8 @@ import (
1516
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
1617
oteltrace "go.opentelemetry.io/otel/trace"
1718
"go.uber.org/zap"
19+
20+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth"
1821
)
1922

2023
// OTLP transport protocols.
@@ -169,7 +172,7 @@ func (tm *TracingManager) HTTPMiddleware() func(http.Handler) http.Handler {
169172
ctx, span := tm.tracer.Start(ctx, spanName,
170173
oteltrace.WithAttributes(
171174
semconv.HTTPMethodKey.String(r.Method),
172-
semconv.HTTPURLKey.String(r.URL.String()),
175+
semconv.HTTPURLKey.String(traceSafeURL(r.URL)),
173176
semconv.HTTPSchemeKey.String(r.URL.Scheme),
174177
semconv.HTTPHostKey.String(r.Host),
175178
semconv.HTTPTargetKey.String(r.URL.Path),
@@ -200,6 +203,16 @@ func (tm *TracingManager) HTTPMiddleware() func(http.Handler) http.Handler {
200203
}
201204
}
202205

206+
// traceSafeURL renders the request URL for the http.url span attribute with
207+
// credential-bearing query parameters masked. ?apikey= is an accepted
208+
// credential (the Web UI's SSE stream and the tray send the admin key that
209+
// way), and spans are exported to an external collector.
210+
func traceSafeURL(u *url.URL) string {
211+
safe := *u
212+
safe.RawQuery = oauth.LogSafeQueryString(u.RawQuery)
213+
return safe.String()
214+
}
215+
203216
// tracingResponseWriter wraps http.ResponseWriter to capture status code for tracing
204217
type tracingResponseWriter struct {
205218
http.ResponseWriter
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package observability
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
9+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
10+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
11+
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
12+
"go.uber.org/zap"
13+
)
14+
15+
// The Web UI's SSE stream and the tray send the admin key as ?apikey=, so the
16+
// http.url span attribute must not carry it to the OTLP collector.
17+
func TestHTTPMiddleware_MasksAPIKeyInSpanURL(t *testing.T) {
18+
recorder := tracetest.NewSpanRecorder()
19+
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
20+
t.Cleanup(func() { _ = provider.Shutdown(t.Context()) })
21+
22+
tm := &TracingManager{
23+
logger: zap.NewNop().Sugar(),
24+
enabled: true,
25+
tracer: provider.Tracer("test"),
26+
}
27+
28+
const key = "3f9a0c1d2e4b5a6978c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6"
29+
handler := tm.HTTPMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
30+
w.WriteHeader(http.StatusOK)
31+
}))
32+
req := httptest.NewRequest(http.MethodGet, "/events?apikey="+key+"&limit=5", nil)
33+
handler.ServeHTTP(httptest.NewRecorder(), req)
34+
35+
spans := recorder.Ended()
36+
if len(spans) != 1 {
37+
t.Fatalf("got %d ended spans, want 1", len(spans))
38+
}
39+
var gotURL string
40+
for _, kv := range spans[0].Attributes() {
41+
if kv.Key == semconv.HTTPURLKey {
42+
gotURL = kv.Value.AsString()
43+
}
44+
}
45+
if gotURL == "" {
46+
t.Fatal("span has no http.url attribute")
47+
}
48+
if strings.Contains(gotURL, key) {
49+
t.Fatalf("http.url carries the API key: %q", gotURL)
50+
}
51+
if !strings.HasPrefix(gotURL, "/events?") || !strings.Contains(gotURL, "limit=5") {
52+
t.Fatalf("http.url lost its path or non-secret parameters: %q", gotURL)
53+
}
54+
}

0 commit comments

Comments
 (0)