From e8112adf92a0e9d9c4243f9c9346e2a6a60448cb Mon Sep 17 00:00:00 2001 From: serhiy-bzhezytskyy Date: Tue, 18 Aug 2026 16:13:47 +0300 Subject: [PATCH 1/3] fix: Enforce baggage limits on the Jaeger extract path The Jaeger propagator extracts every uberctx- header into baggage with no bound. The Jaeger format defines no limits, so borrow the W3C Baggage spec limits (180 entries / 4096 bytes per entry / 8192 bytes total, byte-denominated): over-limit members are dropped once a limit is reached and the earlier ones are kept. opentelemetry-java caps its Jaeger propagator the same way, for the same reason (CVE-2026-45292). Adds four extract tests (entry count, per-entry bytes, byte-vs-char, total bytes); each fails without the change. Assisted-By: Claude Fable 5 --- .../propagator/jaeger/text_map_propagator.rb | 27 +++++++++--- .../jaeger/test/text_map_propagator_test.rb | 43 +++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb b/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb index bb402bbac8..5c55100499 100644 --- a/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb +++ b/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb @@ -26,10 +26,17 @@ class TextMapPropagator FIELDS = [IDENTITY_KEY].freeze TRACE_SPAN_IDENTITY_REGEX = /\A(?(?:[0-9a-f]){1,32}):(?(?:[0-9a-f]){1,16}):(?:[0-9a-f]){1,16}:(?[0-9a-f]{1,2})\z/ ZERO_ID_REGEX = /^0+$/ + BAGGAGE_KEY_PREFIX = 'uberctx-' + # The Jaeger format defines no baggage limits, so borrow the W3C Baggage + # spec limits (bytes) to bound an unbounded inbound carrier on extract. + MAX_BAGGAGE_ENTRIES = 180 + MAX_BAGGAGE_ENTRY_BYTES = 4096 + MAX_BAGGAGE_TOTAL_BYTES = 8192 private_constant \ :IDENTITY_KEY, :DEFAULT_FLAG_BIT, :SAMPLED_FLAG_BIT, :DEBUG_FLAG_BIT, - :FIELDS, :TRACE_SPAN_IDENTITY_REGEX, :ZERO_ID_REGEX + :FIELDS, :TRACE_SPAN_IDENTITY_REGEX, :ZERO_ID_REGEX, :BAGGAGE_KEY_PREFIX, + :MAX_BAGGAGE_ENTRIES, :MAX_BAGGAGE_ENTRY_BYTES, :MAX_BAGGAGE_TOTAL_BYTES # Extract trace context from the supplied carrier. # If extraction fails, the original context will be returned @@ -103,15 +110,25 @@ def build_span(match, sampling_flags) end def context_with_extracted_baggage(carrier, context, getter) - baggage_key_prefix = 'uberctx-' OpenTelemetry::Baggage.build(context: context) do |b| + count = 0 + total_bytes = 0 getter.keys(carrier).each do |carrier_key| - baggage_key = carrier_key.start_with?(baggage_key_prefix) && carrier_key[baggage_key_prefix.length..] + break unless count < MAX_BAGGAGE_ENTRIES + + baggage_key = carrier_key.start_with?(BAGGAGE_KEY_PREFIX) && carrier_key[BAGGAGE_KEY_PREFIX.length..] next unless baggage_key raw_value = getter.get(carrier, carrier_key) - value = URI.decode_uri_component(raw_value) - b.set_value(baggage_key, value) + # W3C limits are byte-denominated; measure bytes so a multibyte + # value cannot exceed the budget under a smaller character count. + entry_bytes = baggage_key.bytesize + raw_value.bytesize + next unless entry_bytes <= MAX_BAGGAGE_ENTRY_BYTES && + total_bytes + entry_bytes <= MAX_BAGGAGE_TOTAL_BYTES + + b.set_value(baggage_key, URI.decode_uri_component(raw_value)) + count += 1 + total_bytes += entry_bytes end end end diff --git a/propagator/jaeger/test/text_map_propagator_test.rb b/propagator/jaeger/test/text_map_propagator_test.rb index dafdaae69c..7e5387c667 100644 --- a/propagator/jaeger/test/text_map_propagator_test.rb +++ b/propagator/jaeger/test/text_map_propagator_test.rb @@ -140,6 +140,49 @@ def extracted_context_must_equal_parent_context(header) _(OpenTelemetry::Baggage.value('key-2', context: context)).must_equal('value2') end + it 'enforces the max of 180 baggage entries, keeping the first ones' do + carrier = { 'uber-trace-id' => '80f198ee56343ba864fe8b2a57d3eff7:e457b5a2e4d86bd1:0:1' } + 200.times { |i| carrier["uberctx-k#{i}"] = "v#{i}" } + context = propagator.extract(carrier, context: OpenTelemetry::Context.empty) + _(OpenTelemetry::Baggage.values(context: context).size).must_equal(180) + _(OpenTelemetry::Baggage.value('k0', context: context)).must_equal('v0') + _(OpenTelemetry::Baggage.value('k180', context: context)).must_be_nil + end + + it 'drops a baggage entry over 4096 bytes, keeping the rest' do + carrier = { + 'uber-trace-id' => '80f198ee56343ba864fe8b2a57d3eff7:e457b5a2e4d86bd1:0:1', + 'uberctx-ok' => 'value', + 'uberctx-big' => 'x' * 5000 + } + context = propagator.extract(carrier, context: OpenTelemetry::Context.empty) + _(OpenTelemetry::Baggage.value('ok', context: context)).must_equal('value') + _(OpenTelemetry::Baggage.value('big', context: context)).must_be_nil + end + + it 'measures the per-entry limit in bytes, not characters' do + carrier = { + 'uber-trace-id' => '80f198ee56343ba864fe8b2a57d3eff7:e457b5a2e4d86bd1:0:1', + 'uberctx-ok' => 'value', + # 2100 multibyte chars = 4200 bytes: under 4096 chars, over 4096 bytes + 'uberctx-u' => 'é' * 2100 + } + context = propagator.extract(carrier, context: OpenTelemetry::Context.empty) + _(OpenTelemetry::Baggage.value('ok', context: context)).must_equal('value') + _(OpenTelemetry::Baggage.value('u', context: context)).must_be_nil + end + + it 'enforces the max total of 8192 bytes, keeping the earlier entries' do + carrier = { 'uber-trace-id' => '80f198ee56343ba864fe8b2a57d3eff7:e457b5a2e4d86bd1:0:1' } + 100.times { |i| carrier["uberctx-k#{i}"] = 'y' * 200 } # ~100 * ~205 bytes = ~20k + context = propagator.extract(carrier, context: OpenTelemetry::Context.empty) + size = OpenTelemetry::Baggage.values(context: context).size + _(size).must_be(:positive?) + _(size).must_be(:<, 100) + _(OpenTelemetry::Baggage.value('k0', context: context)).wont_be_nil + _(OpenTelemetry::Baggage.value('k99', context: context)).must_be_nil + end + it 'handles trace ids and span ids that are too long' do extracted_context_must_equal_parent_context( '80f198ee56343ba864fe8b2a57d3eff7eff7:e457b5a2e4d86bd1:0:1' From c0c2ec6d42b48e2cb3b808d8e2cf3409249d3f70 Mon Sep 17 00:00:00 2001 From: serhiy-bzhezytskyy Date: Tue, 18 Aug 2026 17:56:28 +0300 Subject: [PATCH 2/3] Trim the comments on the Jaeger baggage caps Keep only the byte-vs-character note, which is not obvious from the code; the rationale for the limit values lives in the PR description. Assisted-By: Claude Fable 5 --- .../opentelemetry/propagator/jaeger/text_map_propagator.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb b/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb index 5c55100499..228cf9d5d5 100644 --- a/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb +++ b/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb @@ -27,8 +27,6 @@ class TextMapPropagator TRACE_SPAN_IDENTITY_REGEX = /\A(?(?:[0-9a-f]){1,32}):(?(?:[0-9a-f]){1,16}):(?:[0-9a-f]){1,16}:(?[0-9a-f]{1,2})\z/ ZERO_ID_REGEX = /^0+$/ BAGGAGE_KEY_PREFIX = 'uberctx-' - # The Jaeger format defines no baggage limits, so borrow the W3C Baggage - # spec limits (bytes) to bound an unbounded inbound carrier on extract. MAX_BAGGAGE_ENTRIES = 180 MAX_BAGGAGE_ENTRY_BYTES = 4096 MAX_BAGGAGE_TOTAL_BYTES = 8192 @@ -120,8 +118,7 @@ def context_with_extracted_baggage(carrier, context, getter) next unless baggage_key raw_value = getter.get(carrier, carrier_key) - # W3C limits are byte-denominated; measure bytes so a multibyte - # value cannot exceed the budget under a smaller character count. + # Limits are byte-denominated, not character-denominated. entry_bytes = baggage_key.bytesize + raw_value.bytesize next unless entry_bytes <= MAX_BAGGAGE_ENTRY_BYTES && total_bytes + entry_bytes <= MAX_BAGGAGE_TOTAL_BYTES From 406be3762f7691d3e94dd0e95a0504ab3fcd680e Mon Sep 17 00:00:00 2001 From: serhiy-bzhezytskyy Date: Tue, 18 Aug 2026 20:25:31 +0300 Subject: [PATCH 3/3] Skip Jaeger baggage headers that carry no value getter.get returns nil for a carrier key whose value is nil, so the byte accounting raised NoMethodError before any limit was applied. Guard on the value and cover it with a test that fails without the guard. The extra branch tripped Metrics/CyclomaticComplexity (8/7), and extracting the limit check tripped Metrics/ClassLength (101/100), so the check now lives in within_baggage_limits? and the private_constant list is reflowed. Assisted-By: Claude Fable 5 --- .../propagator/jaeger/text_map_propagator.rb | 17 ++++++++++------- .../jaeger/test/text_map_propagator_test.rb | 11 +++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb b/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb index 228cf9d5d5..6e614a4bf7 100644 --- a/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb +++ b/propagator/jaeger/lib/opentelemetry/propagator/jaeger/text_map_propagator.rb @@ -31,10 +31,9 @@ class TextMapPropagator MAX_BAGGAGE_ENTRY_BYTES = 4096 MAX_BAGGAGE_TOTAL_BYTES = 8192 - private_constant \ - :IDENTITY_KEY, :DEFAULT_FLAG_BIT, :SAMPLED_FLAG_BIT, :DEBUG_FLAG_BIT, - :FIELDS, :TRACE_SPAN_IDENTITY_REGEX, :ZERO_ID_REGEX, :BAGGAGE_KEY_PREFIX, - :MAX_BAGGAGE_ENTRIES, :MAX_BAGGAGE_ENTRY_BYTES, :MAX_BAGGAGE_TOTAL_BYTES + private_constant :IDENTITY_KEY, :DEFAULT_FLAG_BIT, :SAMPLED_FLAG_BIT, :DEBUG_FLAG_BIT, :FIELDS, + :TRACE_SPAN_IDENTITY_REGEX, :ZERO_ID_REGEX, :BAGGAGE_KEY_PREFIX, + :MAX_BAGGAGE_ENTRIES, :MAX_BAGGAGE_ENTRY_BYTES, :MAX_BAGGAGE_TOTAL_BYTES # Extract trace context from the supplied carrier. # If extraction fails, the original context will be returned @@ -118,10 +117,10 @@ def context_with_extracted_baggage(carrier, context, getter) next unless baggage_key raw_value = getter.get(carrier, carrier_key) - # Limits are byte-denominated, not character-denominated. + next unless raw_value + entry_bytes = baggage_key.bytesize + raw_value.bytesize - next unless entry_bytes <= MAX_BAGGAGE_ENTRY_BYTES && - total_bytes + entry_bytes <= MAX_BAGGAGE_TOTAL_BYTES + next unless within_baggage_limits?(entry_bytes, total_bytes) b.set_value(baggage_key, URI.decode_uri_component(raw_value)) count += 1 @@ -130,6 +129,10 @@ def context_with_extracted_baggage(carrier, context, getter) end end + def within_baggage_limits?(entry_bytes, total_bytes) + entry_bytes <= MAX_BAGGAGE_ENTRY_BYTES && total_bytes + entry_bytes <= MAX_BAGGAGE_TOTAL_BYTES + end + def to_jaeger_flags(context, span_context) if span_context.trace_flags == Trace::TraceFlags::SAMPLED if Jaeger.debug?(context) diff --git a/propagator/jaeger/test/text_map_propagator_test.rb b/propagator/jaeger/test/text_map_propagator_test.rb index 7e5387c667..43ed910987 100644 --- a/propagator/jaeger/test/text_map_propagator_test.rb +++ b/propagator/jaeger/test/text_map_propagator_test.rb @@ -183,6 +183,17 @@ def extracted_context_must_equal_parent_context(header) _(OpenTelemetry::Baggage.value('k99', context: context)).must_be_nil end + it 'skips a baggage header carrying no value' do + carrier = { + 'uber-trace-id' => '80f198ee56343ba864fe8b2a57d3eff7:e457b5a2e4d86bd1:0:1', + 'uberctx-ok' => 'value', + 'uberctx-blank' => nil + } + context = propagator.extract(carrier, context: OpenTelemetry::Context.empty) + _(OpenTelemetry::Baggage.value('ok', context: context)).must_equal('value') + _(OpenTelemetry::Baggage.value('blank', context: context)).must_be_nil + end + it 'handles trace ids and span ids that are too long' do extracted_context_must_equal_parent_context( '80f198ee56343ba864fe8b2a57d3eff7eff7:e457b5a2e4d86bd1:0:1'