Skip to content

Commit f740548

Browse files
aksOpsclaude
andcommitted
fix(security): tenant ID validation + self-instrumentation guard
Closes two production-readiness gaps from the brainstorm: 1. Tenant ID validation. SanitizeTenantID rejects empty / over-128-char / control-character values, applied at every transport boundary: - HTTP X-Tenant-ID header (api/tenant_middleware.go) - gRPC x-tenant-id metadata (ingest/otlp.go) - OTLP resource attribute tenant.id (ingest/otlp.go, trusted-resource path) Without this, a tenant ID of "foo\nbar" would inject a newline into slog structured fields, and 8KB tenant IDs would silently truncate at the GORM VARCHAR(64) layer. 2. Self-instrumentation feedback loop guard. When OTEL_EXPORTER_OTLP_ENDPOINT resolves to a loopback host (the binary's own gRPC port), the OTel SDK would otherwise emit a span on every Export call, re-entering Export and amplifying without bound. Config.GuardSelfInstrumentation auto-prepends the own service name to IngestExcludedServices and warns the operator. Both fixes are non-breaking — sanitization rejects only inputs that were never valid, and the guard only fires on loopback endpoints. Tests: 26 new test cases across config / storage / api packages covering the happy path, every rejection branch, and idempotency on re-runs. go test ./... → 438 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e8dc0ff commit f740548

8 files changed

Lines changed: 401 additions & 7 deletions

File tree

internal/api/tenant_middleware.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/RandomCodeSpace/otelcontext/internal/storage"
99
)
1010

11+
1112
// TenantHeader is the canonical HTTP header carrying the tenant ID on
1213
// read-side (query) requests. Ingest paths resolve tenant separately via gRPC
1314
// metadata / OTLP resource attributes and do not go through this middleware.
@@ -34,7 +35,11 @@ func TenantMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
3435
next.ServeHTTP(w, r)
3536
return
3637
}
37-
tenant := strings.TrimSpace(r.Header.Get(TenantHeader))
38+
// SanitizeTenantID returns "" for empty / over-length / control-char
39+
// values so they fall through to the configured default — see
40+
// storage.SanitizeTenantID. Hostile or misconfigured clients cannot
41+
// inject newlines into structured logs or overflow VARCHAR(64).
42+
tenant := storage.SanitizeTenantID(r.Header.Get(TenantHeader))
3843
if tenant == "" {
3944
tenant = defaultTenant
4045
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
9+
"github.com/RandomCodeSpace/otelcontext/internal/storage"
10+
)
11+
12+
// TestTenantMiddleware_SanitizesHeader verifies that an X-Tenant-ID header
13+
// containing control characters or excessive length is rejected back to the
14+
// configured default — preventing log injection on slog structured fields
15+
// and silent VARCHAR truncation at the GORM layer.
16+
func TestTenantMiddleware_SanitizesHeader(t *testing.T) {
17+
mw := TenantMiddleware(nil) // nil cfg → DefaultTenantID fallback
18+
cases := []struct {
19+
header string
20+
wantTenant string
21+
}{
22+
{"acme", "acme"},
23+
{"foo\nbar", storage.DefaultTenantID}, // log-injection attempt
24+
{strings.Repeat("x", 200), storage.DefaultTenantID}, // over-length
25+
{"", storage.DefaultTenantID}, // empty
26+
{" ", storage.DefaultTenantID}, // whitespace-only
27+
{"foo\x00bar", storage.DefaultTenantID}, // NUL byte
28+
}
29+
for _, tc := range cases {
30+
t.Run(tc.header, func(t *testing.T) {
31+
var got string
32+
h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
33+
got = storage.TenantFromContext(r.Context())
34+
}))
35+
r := httptest.NewRequest(http.MethodGet, "/api/foo", nil)
36+
r.Header.Set(TenantHeader, tc.header)
37+
h.ServeHTTP(httptest.NewRecorder(), r)
38+
if got != tc.wantTenant {
39+
t.Errorf("header=%q -> tenant=%q, want %q", tc.header, got, tc.wantTenant)
40+
}
41+
})
42+
}
43+
}

internal/config/selfinstr_guard.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package config
2+
3+
import (
4+
"log/slog"
5+
"net"
6+
"strings"
7+
)
8+
9+
// SelfServiceName is the OTel service.name attribute the binary attaches to
10+
// its own self-instrumentation spans. Mirrors the literal in
11+
// main.initTracerProvider — keep the two in sync.
12+
const SelfServiceName = "otelcontext"
13+
14+
// GuardSelfInstrumentation prevents an amplification loop when
15+
// OTEL_EXPORTER_OTLP_ENDPOINT points at the binary's own gRPC port. Without
16+
// this, every span the OTel SDK emits would re-enter Export, generate more
17+
// spans (one per Export call), and re-enter again — unbounded fan-out.
18+
//
19+
// Strategy: when the configured endpoint resolves to a loopback address, the
20+
// own service name is auto-added to IngestExcludedServices so the ingest
21+
// filter drops self-emitted batches. Operators can still override by setting
22+
// the variable explicitly — the guard only ADDS, never removes.
23+
//
24+
// No-op when self-instrumentation is disabled (empty endpoint) or the
25+
// endpoint is non-loopback (a separate collector, the operator's responsibility).
26+
func (c *Config) GuardSelfInstrumentation() {
27+
if c == nil || c.OTelExporterEndpoint == "" {
28+
return
29+
}
30+
host := hostFromEndpoint(c.OTelExporterEndpoint)
31+
if !isLoopbackHost(host) {
32+
return
33+
}
34+
if hasService(c.IngestExcludedServices, SelfServiceName) {
35+
return
36+
}
37+
if c.IngestExcludedServices == "" {
38+
c.IngestExcludedServices = SelfServiceName
39+
} else {
40+
c.IngestExcludedServices = SelfServiceName + "," + c.IngestExcludedServices
41+
}
42+
slog.Warn("self-instrumentation guard: auto-excluded own service from ingest to break feedback loop",
43+
"endpoint", c.OTelExporterEndpoint,
44+
"self_service", SelfServiceName,
45+
"ingest_excluded_services", c.IngestExcludedServices,
46+
)
47+
}
48+
49+
// hostFromEndpoint extracts the host portion of an OTLP endpoint string.
50+
// Tolerates "host", "host:port", and "scheme://host:port" forms — the OTel
51+
// SDK accepts all three. Returns the lowercase host or "" on parse failure.
52+
func hostFromEndpoint(endpoint string) string {
53+
endpoint = strings.TrimSpace(endpoint)
54+
if endpoint == "" {
55+
return ""
56+
}
57+
// Strip scheme if present (e.g. "http://localhost:4317").
58+
if i := strings.Index(endpoint, "://"); i >= 0 {
59+
endpoint = endpoint[i+3:]
60+
}
61+
// Drop path component if present.
62+
if i := strings.Index(endpoint, "/"); i >= 0 {
63+
endpoint = endpoint[:i]
64+
}
65+
host, _, err := net.SplitHostPort(endpoint)
66+
if err != nil {
67+
// No port — treat the whole thing as host.
68+
host = endpoint
69+
}
70+
return strings.ToLower(strings.Trim(host, "[]"))
71+
}
72+
73+
// isLoopbackHost reports whether host is one of the well-known loopback
74+
// names or a literal loopback IP. The empty host is treated as loopback
75+
// because OTel SDKs fall back to "localhost" when the endpoint is bare.
76+
func isLoopbackHost(host string) bool {
77+
switch host {
78+
case "", "localhost":
79+
return true
80+
}
81+
if ip := net.ParseIP(host); ip != nil {
82+
return ip.IsLoopback()
83+
}
84+
return false
85+
}
86+
87+
// hasService reports whether the comma-separated list contains the given
88+
// service name. Whitespace around list entries is tolerated so the same
89+
// helper can validate operator-supplied lists.
90+
func hasService(list, service string) bool {
91+
if list == "" || service == "" {
92+
return false
93+
}
94+
for _, s := range strings.Split(list, ",") {
95+
if strings.TrimSpace(s) == service {
96+
return true
97+
}
98+
}
99+
return false
100+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package config
2+
3+
import "testing"
4+
5+
func TestHostFromEndpoint(t *testing.T) {
6+
cases := []struct {
7+
in string
8+
want string
9+
}{
10+
{"localhost:4317", "localhost"},
11+
{"127.0.0.1:4317", "127.0.0.1"},
12+
{"[::1]:4317", "::1"},
13+
{"http://localhost:4317", "localhost"},
14+
{"https://collector.example.com:4317", "collector.example.com"},
15+
{"otelcollector:4317", "otelcollector"},
16+
{"localhost", "localhost"},
17+
{" ", ""},
18+
{"", ""},
19+
{"http://localhost:4317/v1/traces", "localhost"},
20+
}
21+
for _, tc := range cases {
22+
if got := hostFromEndpoint(tc.in); got != tc.want {
23+
t.Errorf("hostFromEndpoint(%q) = %q, want %q", tc.in, got, tc.want)
24+
}
25+
}
26+
}
27+
28+
func TestIsLoopbackHost(t *testing.T) {
29+
loopback := []string{"", "localhost", "127.0.0.1", "127.1.2.3", "::1"}
30+
for _, h := range loopback {
31+
if !isLoopbackHost(h) {
32+
t.Errorf("isLoopbackHost(%q) = false, want true", h)
33+
}
34+
}
35+
notLoopback := []string{"otelcollector", "10.0.0.1", "collector.example.com", "192.168.1.1"}
36+
for _, h := range notLoopback {
37+
if isLoopbackHost(h) {
38+
t.Errorf("isLoopbackHost(%q) = true, want false", h)
39+
}
40+
}
41+
}
42+
43+
func TestHasService(t *testing.T) {
44+
t.Parallel()
45+
cases := []struct {
46+
list, service string
47+
want bool
48+
}{
49+
{"", "otelcontext", false},
50+
{"otelcontext", "otelcontext", true},
51+
{"a,b,otelcontext,c", "otelcontext", true},
52+
{"a, otelcontext , c", "otelcontext", true},
53+
{"a,b,c", "otelcontext", false},
54+
{"otelcontextual", "otelcontext", false},
55+
}
56+
for _, tc := range cases {
57+
if got := hasService(tc.list, tc.service); got != tc.want {
58+
t.Errorf("hasService(%q, %q) = %v, want %v", tc.list, tc.service, got, tc.want)
59+
}
60+
}
61+
}
62+
63+
func TestGuardSelfInstrumentation(t *testing.T) {
64+
t.Run("NoOpWhenEndpointEmpty", func(t *testing.T) {
65+
c := &Config{IngestExcludedServices: "foo"}
66+
c.GuardSelfInstrumentation()
67+
if c.IngestExcludedServices != "foo" {
68+
t.Fatalf("modified excluded list when endpoint empty: %q", c.IngestExcludedServices)
69+
}
70+
})
71+
72+
t.Run("AutoAddsWhenLoopback", func(t *testing.T) {
73+
c := &Config{OTelExporterEndpoint: "localhost:4317"}
74+
c.GuardSelfInstrumentation()
75+
if !hasService(c.IngestExcludedServices, SelfServiceName) {
76+
t.Fatalf("self service not auto-added: %q", c.IngestExcludedServices)
77+
}
78+
})
79+
80+
t.Run("PrependsToExistingList", func(t *testing.T) {
81+
c := &Config{
82+
OTelExporterEndpoint: "127.0.0.1:4317",
83+
IngestExcludedServices: "noisy-svc",
84+
}
85+
c.GuardSelfInstrumentation()
86+
want := SelfServiceName + ",noisy-svc"
87+
if c.IngestExcludedServices != want {
88+
t.Fatalf("got %q, want %q", c.IngestExcludedServices, want)
89+
}
90+
})
91+
92+
t.Run("IdempotentWhenAlreadyExcluded", func(t *testing.T) {
93+
c := &Config{
94+
OTelExporterEndpoint: "[::1]:4317",
95+
IngestExcludedServices: "a," + SelfServiceName + ",b",
96+
}
97+
before := c.IngestExcludedServices
98+
c.GuardSelfInstrumentation()
99+
if c.IngestExcludedServices != before {
100+
t.Fatalf("guard mutated already-excluded list: %q -> %q", before, c.IngestExcludedServices)
101+
}
102+
})
103+
104+
t.Run("NoOpForRemoteEndpoint", func(t *testing.T) {
105+
c := &Config{
106+
OTelExporterEndpoint: "collector.example.com:4317",
107+
IngestExcludedServices: "foo",
108+
}
109+
c.GuardSelfInstrumentation()
110+
if hasService(c.IngestExcludedServices, SelfServiceName) {
111+
t.Fatalf("guard fired on remote endpoint: %q", c.IngestExcludedServices)
112+
}
113+
})
114+
115+
t.Run("NilSafe", func(t *testing.T) {
116+
var c *Config
117+
c.GuardSelfInstrumentation()
118+
})
119+
}

internal/ingest/otlp.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@ func tenantFromContext(ctx context.Context) string {
4949
return storage.TenantFromContext(ctx)
5050
}
5151
if md, ok := metadata.FromIncomingContext(ctx); ok {
52-
if vals := md.Get(tenantHeader); len(vals) > 0 && vals[0] != "" {
53-
return vals[0]
52+
if vals := md.Get(tenantHeader); len(vals) > 0 {
53+
// Reject empty, over-length, or control-char values via shared
54+
// sanitizer so HTTP and gRPC paths apply identical input-safety
55+
// rules. Empty return falls through to the configured default.
56+
if t := storage.SanitizeTenantID(vals[0]); t != "" {
57+
return t
58+
}
5459
}
5560
}
5661
return ""
@@ -84,11 +89,13 @@ func resolveTenant(ctx context.Context, resourceAttrs []*commonpb.KeyValue, fall
8489

8590
// tenantFromResource looks for an OTLP resource attribute "tenant.id".
8691
// Only consulted when cfg.TrustResourceTenant=true (off by default) —
87-
// see resolveTenant.
92+
// see resolveTenant. The value is run through SanitizeTenantID so a
93+
// compromised SDK cannot smuggle control characters or oversized strings
94+
// even on the trusted-resource path.
8895
func tenantFromResource(attrs []*commonpb.KeyValue) string {
8996
for _, kv := range attrs {
9097
if kv.Key == "tenant.id" {
91-
return kv.Value.GetStringValue()
98+
return storage.SanitizeTenantID(kv.Value.GetStringValue())
9299
}
93100
}
94101
return ""

internal/storage/tenant_ctx.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,46 @@
11
package storage
22

3-
import "context"
3+
import (
4+
"context"
5+
"strings"
6+
"unicode"
7+
)
8+
9+
// MaxTenantIDLength caps the length of an accepted tenant ID. Tenant IDs are
10+
// stored in a VARCHAR(64) column on every domain row plus propagate into
11+
// structured logs and Prometheus labels. The cap is a defense in depth against
12+
// silent VARCHAR truncation at insert time and unbounded label cardinality
13+
// from a hostile or misconfigured client.
14+
const MaxTenantIDLength = 128
15+
16+
// SanitizeTenantID validates and normalizes a tenant ID supplied by an HTTP
17+
// header, gRPC metadata key, or OTLP resource attribute. It returns the empty
18+
// string for any value the caller should reject (and substitute with their
19+
// configured default), so the rejection contract is uniform across transports.
20+
//
21+
// Rejection criteria:
22+
// - empty after TrimSpace
23+
// - length exceeds MaxTenantIDLength after trim
24+
// - contains a Unicode control character (\n, \r, \t, NUL, escape codes)
25+
//
26+
// On the happy path it returns the trimmed value verbatim — no case folding,
27+
// no allowlist, since legitimate tenant IDs may be UUIDs, slugs, or
28+
// organisation names in non-ASCII scripts.
29+
func SanitizeTenantID(s string) string {
30+
s = strings.TrimSpace(s)
31+
if s == "" {
32+
return ""
33+
}
34+
if len(s) > MaxTenantIDLength {
35+
return ""
36+
}
37+
for _, r := range s {
38+
if unicode.IsControl(r) {
39+
return ""
40+
}
41+
}
42+
return s
43+
}
444

545
// tenantCtxKey is the private context key used to carry the resolved tenant ID
646
// through an HTTP (or gRPC) request down into the repository layer.

0 commit comments

Comments
 (0)