From f0f44e6841b656c3b51607610e4e348dcd3041af Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:15:21 +0200 Subject: [PATCH 1/3] Fix OtlpExporterOptionsExtensions.GetHeaders incorrectly throwing for comma in header value --- .../OtlpExporterOptionsExtensions.cs | 44 +++++++++++++------ .../OtlpExporterOptionsExtensionsTests.cs | 27 +++++------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs index 218b4721caa..e08f6d846d5 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs @@ -33,29 +33,47 @@ public static THeaders GetHeaders(this OtlpExporterOptions options, Ac optionHeaders = Uri.UnescapeDataString(optionHeaders); ReadOnlySpan headersSpan = optionHeaders.AsSpan(); + var nextEqualIndex = headersSpan.IndexOf('='); + + if (nextEqualIndex == -1) + { + throw new ArgumentException("Headers provided in an invalid format."); + } + while (!headersSpan.IsEmpty) { - int commaIndex = headersSpan.IndexOf(','); - ReadOnlySpan pair; - if (commaIndex == -1) + var key = headersSpan.Slice(0, nextEqualIndex).Trim().ToString(); + + headersSpan = headersSpan.Slice(nextEqualIndex + 1); + + nextEqualIndex = headersSpan.IndexOf('='); + + string value; + if (nextEqualIndex == -1) { - pair = headersSpan; + // Everything until the end of the string can be considered the value. + value = headersSpan.Trim().ToString(); headersSpan = []; } else { - pair = headersSpan.Slice(0, commaIndex); - headersSpan = headersSpan.Slice(commaIndex + 1); - } + // If we have another = we need to backtrack from it + // and try to find the last comma and consider that as the delimiter. + var potentialValue = headersSpan.Slice(0, nextEqualIndex); + var lastComma = potentialValue.LastIndexOf(','); - int equalIndex = pair.IndexOf('='); - if (equalIndex == -1) - { - throw new ArgumentException("Headers provided in an invalid format."); + if (lastComma == -1) + { + throw new ArgumentException("Headers provided in an invalid format."); + } + + potentialValue = potentialValue.Slice(0, lastComma); + + value = potentialValue.Trim().ToString(); + headersSpan = headersSpan.Slice(lastComma + 1); + nextEqualIndex -= potentialValue.Length + 1; } - var key = pair.Slice(0, equalIndex).Trim().ToString(); - var value = pair.Slice(equalIndex + 1).Trim().ToString(); addHeader(headers, key, value); } } diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs index 8e0c24601a4..d27645689dc 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs @@ -49,12 +49,16 @@ public void GetHeaders_InvalidOptionHeaders_ThrowsArgumentException(string input [InlineData("key1=value1", "key1=value1")] [InlineData("key1=value1,key2=value2", "key1=value1,key2=value2")] [InlineData("key1=value1,key2=value2,key3=value3", "key1=value1,key2=value2,key3=value3")] + [InlineData("key1=value1,value2", "key1=value1,value2")] + [InlineData("key1=value1,value2,key2=value3", "key1=value1,value2,key2=value3")] [InlineData(" key1 = value1 , key2=value2 ", "key1=value1,key2=value2")] [InlineData("key1= value with spaces ,key2=another value", "key1=value with spaces,key2=another value")] [InlineData("=value1", "=value1")] [InlineData("key1=", "key1=")] [InlineData("key1=value1%2Ckey2=value2", "key1=value1,key2=value2")] [InlineData("key1=value1%2Ckey2=value2%2Ckey3=value3", "key1=value1,key2=value2,key3=value3")] + [InlineData("key1=value1%2Cvalue2", "key1=value1,value2")] + [InlineData("key1=value1%2Cvalue2%2Ckey2=value3", "key1=value1,value2,key2=value3")] public void GetHeaders_ValidAndUrlEncodedHeaders_ReturnsCorrectHeaders(string inputOptionHeaders, string expectedNormalizedOptional) { VerifyHeaders(inputOptionHeaders, expectedNormalizedOptional); @@ -175,27 +179,16 @@ private static void VerifyHeaders(string inputOptionHeaders, string expectedNorm } var headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); - var expectedOptional = new Dictionary(); - if (!string.IsNullOrEmpty(expectedNormalizedOptional)) - { - foreach (var segment in expectedNormalizedOptional.Split([','], StringSplitOptions.RemoveEmptyEntries)) - { - var parts = segment.Split(['='], 2); - expectedOptional.Add(parts[0].Trim(), parts[1].Trim()); - } - } + var actual = string.Join(",", headers.Select(h => $"{h.Key}={h.Value}")); - Assert.Equal(OtlpExporterOptions.StandardHeaders.Length + expectedOptional.Count, headers.Count); - - foreach (var kvp in expectedOptional) + var expected = expectedNormalizedOptional; + if (expected.Length > 0) { - Assert.Contains(headers, h => h.Key == kvp.Key && h.Value == kvp.Value); + expected += ','; } + expected += string.Join(",", OtlpExporterOptions.StandardHeaders.Select(h => $"{h.Key}={h.Value}")); - foreach (var std in OtlpExporterOptions.StandardHeaders) - { - Assert.Contains(headers, h => h.Key == std.Key && h.Value == std.Value); - } + Assert.Equal(expected, actual); } } From 4e1b47142e8d41159242670cdbb996844e5e27b7 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:05:50 +0200 Subject: [PATCH 2/3] Get rid of dictionary abstraction --- .../Implementation/ExportClient/OtlpExportClient.cs | 2 +- .../OtlpExporterOptionsExtensions.cs | 9 ++++----- .../OtlpExporterOptionsExtensionsTests.cs | 7 +++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs index 24fc1551cc9..7223260da17 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs @@ -42,7 +42,7 @@ protected OtlpExportClient(OtlpExporterOptions options, HttpClient httpClient, s } this.Endpoint = new UriBuilder(exporterEndpoint).Uri; - this.Headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); + this.Headers = options.GetHeaders(); this.HttpClient = httpClient; } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs index e08f6d846d5..cd42d12a408 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs @@ -22,11 +22,10 @@ internal static class OtlpExporterOptionsExtensions private const string MetricsHttpServicePath = "v1/metrics"; private const string LogsHttpServicePath = "v1/logs"; - public static THeaders GetHeaders(this OtlpExporterOptions options, Action addHeader) - where THeaders : new() + public static IReadOnlyDictionary GetHeaders(this OtlpExporterOptions options) { var optionHeaders = options.Headers; - var headers = new THeaders(); + var headers = new Dictionary(); if (!string.IsNullOrEmpty(optionHeaders)) { // According to the specification, URL-encoded headers must be supported. @@ -74,13 +73,13 @@ public static THeaders GetHeaders(this OtlpExporterOptions options, Ac nextEqualIndex -= potentialValue.Length + 1; } - addHeader(headers, key, value); + headers.Add(key, value); } } foreach (var header in OtlpExporterOptions.StandardHeaders) { - addHeader(headers, header.Key, header.Value); + headers.Add(header.Key, header.Value); } return headers; diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs index d27645689dc..49c5aa5ff83 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs @@ -24,7 +24,7 @@ public void GetHeaders_NoOptionHeaders_ReturnsStandardHeaders(string? optionHead Headers = optionHeaders, }; - var headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); + var headers = options.GetHeaders(); Assert.Equal(OtlpExporterOptions.StandardHeaders.Length, headers.Count); @@ -173,12 +173,11 @@ private static void VerifyHeaders(string inputOptionHeaders, string expectedNorm if (expectException) { - Assert.Throws(() => - options.GetHeaders>((d, k, v) => d.Add(k, v))); + Assert.Throws(() => options.GetHeaders()); return; } - var headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); + var headers = options.GetHeaders(); var actual = string.Join(",", headers.Select(h => $"{h.Key}={h.Value}")); From 226edef3306205a6888f8af244fc190b06d6160d Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:41:45 +0200 Subject: [PATCH 3/3] Fix other tests --- .../OtlpExporterOptionsExtensions.cs | 25 ++++++++++++++++--- .../OtlpExporterOptionsExtensionsTests.cs | 7 +++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs index cd42d12a408..29528787591 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs @@ -36,13 +36,27 @@ public static IReadOnlyDictionary GetHeaders(this OtlpExporterOp if (nextEqualIndex == -1) { - throw new ArgumentException("Headers provided in an invalid format."); + throw CreateInvalidHeaderFormatException(); + } + + // Skip any leading commas. + var leadingCommaIndex = headersSpan.Slice(0, nextEqualIndex).LastIndexOf(','); + if (leadingCommaIndex != -1) + { + headersSpan = headersSpan.Slice(leadingCommaIndex + 1); + nextEqualIndex -= leadingCommaIndex + 1; } while (!headersSpan.IsEmpty) { var key = headersSpan.Slice(0, nextEqualIndex).Trim().ToString(); + // HTTP header field-names can not be empty: https://www.rfc-editor.org/rfc/rfc7230#section-3.2 + if (key.Length < 1) + { + throw CreateInvalidHeaderFormatException(); + } + headersSpan = headersSpan.Slice(nextEqualIndex + 1); nextEqualIndex = headersSpan.IndexOf('='); @@ -51,7 +65,7 @@ public static IReadOnlyDictionary GetHeaders(this OtlpExporterOp if (nextEqualIndex == -1) { // Everything until the end of the string can be considered the value. - value = headersSpan.Trim().ToString(); + value = headersSpan.TrimEnd(',').Trim().ToString(); headersSpan = []; } else @@ -63,12 +77,12 @@ public static IReadOnlyDictionary GetHeaders(this OtlpExporterOp if (lastComma == -1) { - throw new ArgumentException("Headers provided in an invalid format."); + throw CreateInvalidHeaderFormatException(); } potentialValue = potentialValue.Slice(0, lastComma); - value = potentialValue.Trim().ToString(); + value = potentialValue.TrimEnd(',').Trim().ToString(); headersSpan = headersSpan.Slice(lastComma + 1); nextEqualIndex -= potentialValue.Length + 1; } @@ -85,6 +99,9 @@ public static IReadOnlyDictionary GetHeaders(this OtlpExporterOp return headers; } + private static ArgumentException CreateInvalidHeaderFormatException() + => new("Headers provided in an invalid format. Use: header=value,header=value"); + public static OtlpExporterTransmissionHandler GetExportTransmissionHandler(this OtlpExporterOptions options, ExperimentalOptions experimentalOptions, OtlpSignalType otlpSignalType) { var exportClient = GetExportClient(options, otlpSignalType); diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs index 49c5aa5ff83..eacbefb4fed 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsExtensionsTests.cs @@ -36,8 +36,8 @@ public void GetHeaders_NoOptionHeaders_ReturnsStandardHeaders(string? optionHead [Theory] [InlineData(" ")] - [InlineData(",key1=value1,key2=value2,")] - [InlineData(",,key1=value1,,key2=value2,,")] + [InlineData(",")] + [InlineData("=value1")] [InlineData("key1")] public void GetHeaders_InvalidOptionHeaders_ThrowsArgumentException(string inputOptionHeaders) { @@ -53,12 +53,13 @@ public void GetHeaders_InvalidOptionHeaders_ThrowsArgumentException(string input [InlineData("key1=value1,value2,key2=value3", "key1=value1,value2,key2=value3")] [InlineData(" key1 = value1 , key2=value2 ", "key1=value1,key2=value2")] [InlineData("key1= value with spaces ,key2=another value", "key1=value with spaces,key2=another value")] - [InlineData("=value1", "=value1")] [InlineData("key1=", "key1=")] [InlineData("key1=value1%2Ckey2=value2", "key1=value1,key2=value2")] [InlineData("key1=value1%2Ckey2=value2%2Ckey3=value3", "key1=value1,key2=value2,key3=value3")] [InlineData("key1=value1%2Cvalue2", "key1=value1,value2")] [InlineData("key1=value1%2Cvalue2%2Ckey2=value3", "key1=value1,value2,key2=value3")] + [InlineData(",key1=value1,key2=value2,", "key1=value1,key2=value2")] + [InlineData(",,key1=value1,,key2=value2,,", "key1=value1,key2=value2")] public void GetHeaders_ValidAndUrlEncodedHeaders_ReturnsCorrectHeaders(string inputOptionHeaders, string expectedNormalizedOptional) { VerifyHeaders(inputOptionHeaders, expectedNormalizedOptional);