From 8faa8834c75c31cbb88b3ee055c10a2fcb81382d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Mon, 26 Oct 2026 12:15:20 +0100 Subject: [PATCH 1/5] [API] Isolate ambient baggage updates across execution contexts Scope baggage replacement and every static mutation to the current execution context by default. Add a startup AppContext switch to restore unsafe legacy sharing temporarily, with migration guidance and regression coverage for both modes and cached configuration. The default behavior change still requires maintainer release approval. Discovered by Anthropic's OSS Scanner, as bug ANT-2026-YXEDG1G8. --- src/OpenTelemetry.Api/Baggage.cs | 53 +++- src/OpenTelemetry.Api/CHANGELOG.md | 6 + src/OpenTelemetry.Api/README.md | 86 ++++++ .../BaggageConfigurationTests.cs | 158 +++++++++++ test/OpenTelemetry.Api.Tests/BaggageTests.cs | 252 +++++++++++++++++- 5 files changed, 543 insertions(+), 12 deletions(-) create mode 100644 test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs diff --git a/src/OpenTelemetry.Api/Baggage.cs b/src/OpenTelemetry.Api/Baggage.cs index d3592dcc71d..726870b4d24 100644 --- a/src/OpenTelemetry.Api/Baggage.cs +++ b/src/OpenTelemetry.Api/Baggage.cs @@ -15,8 +15,10 @@ namespace OpenTelemetry; /// public readonly struct Baggage : IEquatable { - private static readonly RuntimeContextSlot RuntimeContextSlot = RuntimeContext.RegisterSlot("otel.baggage"); + private static readonly RuntimeContextSlot RuntimeContextSlot = RuntimeContext.RegisterSlot("otel.baggage"); + private static readonly Dictionary EmptyBaggage = []; + private static readonly bool UseSafeFlow = !IsUnsafeLegacyFlowEnabled(); private readonly Dictionary baggage; @@ -56,11 +58,35 @@ internal Baggage(Dictionary baggage) /// // the 'newKey1' change is lost. /// Baggage.Current = newBaggage; /// + /// + /// By default, updates affect only the current execution context and contexts + /// subsequently forked from it. Changes made in an awaited asynchronous method + /// do not flow back to its caller. + /// + /// Setting the OpenTelemetry.Baggage.AllowUnsafeLegacyFlow AppContext + /// switch to true before the first use of restores + /// legacy sharing of updates across inherited contexts. An unset or + /// false switch keeps the isolated behavior. + /// Legacy sharing is insecure for independent operations because their baggage + /// can overwrite each other's. The setting is read once and is intended only + /// as a temporary migration aid. /// public static Baggage Current { get => RuntimeContextSlot.Get()?.Baggage ?? default; - set => EnsureBaggageHolder().Baggage = value; + set + { + if (UseSafeFlow) + { + // Inherited contexts share the holder reference. Publish a replacement + // instead of mutating it so existing contexts keep their own baggage. + RuntimeContextSlot.Set(value.Count == 0 ? null : new BaggageHolder { Baggage = value }); + } + else + { + EnsureBaggageHolder().Baggage = value; + } + } } /// @@ -147,6 +173,11 @@ public static Dictionary.Enumerator GetEnumerator(Baggage baggag [SuppressMessage("roslyn", "RS0026", Justification = "TODO: fix APIs that violate the backcompt requirement - multiple overloads with optional parameters: https://github.com/dotnet/roslyn/blob/main/docs/Adding%20Optional%20Parameters%20in%20Public%20API.md.")] public static Baggage SetBaggage(string name, string? value, Baggage baggage = default) { + if (UseSafeFlow) + { + return Current = (baggage == default ? Current : baggage).SetBaggage(name, value); + } + var baggageHolder = EnsureBaggageHolder(); lock (baggageHolder) { @@ -166,6 +197,11 @@ public static Baggage SetBaggage(string name, string? value, Baggage baggage = d [SuppressMessage("roslyn", "RS0026", Justification = "TODO: fix APIs that violate the backcompt requirement - multiple overloads with optional parameters: https://github.com/dotnet/roslyn/blob/main/docs/Adding%20Optional%20Parameters%20in%20Public%20API.md.")] public static Baggage SetBaggage(IEnumerable> baggageItems, Baggage baggage = default) { + if (UseSafeFlow) + { + return Current = (baggage == default ? Current : baggage).SetBaggage(baggageItems); + } + var baggageHolder = EnsureBaggageHolder(); lock (baggageHolder) { @@ -184,6 +220,11 @@ public static Baggage SetBaggage(IEnumerable> bagg /// Note: The returned will be set as the new instance. public static Baggage RemoveBaggage(string name, Baggage baggage = default) { + if (UseSafeFlow) + { + return Current = (baggage == default ? Current : baggage).RemoveBaggage(name); + } + var baggageHolder = EnsureBaggageHolder(); lock (baggageHolder) { @@ -201,6 +242,11 @@ public static Baggage RemoveBaggage(string name, Baggage baggage = default) /// Note: The returned will be set as the new instance. public static Baggage ClearBaggage(Baggage baggage = default) { + if (UseSafeFlow) + { + return Current = default; + } + var baggageHolder = EnsureBaggageHolder(); lock (baggageHolder) { @@ -423,6 +469,9 @@ public override int GetHashCode() return hash; } + private static bool IsUnsafeLegacyFlowEnabled() + => AppContext.TryGetSwitch("OpenTelemetry.Baggage.AllowUnsafeLegacyFlow", out var enabled) && enabled; + private static BaggageHolder EnsureBaggageHolder() { var baggageHolder = RuntimeContextSlot.Get(); diff --git a/src/OpenTelemetry.Api/CHANGELOG.md b/src/OpenTelemetry.Api/CHANGELOG.md index 001d801adf9..91439a383dc 100644 --- a/src/OpenTelemetry.Api/CHANGELOG.md +++ b/src/OpenTelemetry.Api/CHANGELOG.md @@ -6,6 +6,12 @@ Notes](../../RELEASENOTES.md). ## Unreleased +* **Breaking Change**: Isolate ambient baggage updates between execution contexts. + Changes made in awaited async methods no longer update their caller's baggage. + Enable the `OpenTelemetry.Baggage.AllowUnsafeLegacyFlow` AppContext switch at + startup to temporarily restore insecure legacy sharing. + ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/pull/TODO)) + ## 1.20.1 Released 2026-Oct-20 diff --git a/src/OpenTelemetry.Api/README.md b/src/OpenTelemetry.Api/README.md index fcaea931073..3dde35cb17f 100644 --- a/src/OpenTelemetry.Api/README.md +++ b/src/OpenTelemetry.Api/README.md @@ -106,6 +106,92 @@ Baggage.ClearBaggage(); The recommended way to add Baggage is to use the `Baggage.SetBaggage()` API. OpenTelemetry users should not use the `Activity.AddBaggage` method. +#### Baggage flow across asynchronous calls + +By default, `Baggage.Current` flows into child execution contexts as a snapshot. +Assigning `Baggage.Current` or calling the static `SetBaggage`, `RemoveBaggage`, +or `ClearBaggage` methods updates the current execution context and contexts +subsequently forked from it. Previously forked contexts keep their own baggage. +The same behavior applies when a static method receives an explicit `Baggage` +argument. This describes the default `AsyncLocal` runtime context carrier; +applications that select another `RuntimeContext.ContextSlotType` retain that +carrier's propagation behavior. + +Changes made inside an awaited asynchronous method do not update the caller's +baggage. To propagate enrichment back to a caller, return the updated baggage +and assign it in the caller's context: + +```csharp +Baggage.Current = await EnrichBaggageAsync(Baggage.Current); +await SendRequestAsync(); + +static async Task EnrichBaggageAsync(Baggage baggage) +{ + var tenantId = await LookupTenantIdAsync(); + return baggage.SetBaggage("tenant.id", tenantId); +} +``` + +Returning a new `Baggage` value from an instance method still requires assigning +that value if it should become current. Synchronous helper calls update their +caller's current execution context as before. + +##### Temporary compatibility option: insecure legacy flow + +Earlier versions shared a mutable baggage holder between inherited execution +contexts. That allowed awaited helpers and parallel tasks to update their +caller's baggage. Applications that require time to migrate can restore that +behavior by enabling the `OpenTelemetry.Baggage.AllowUnsafeLegacyFlow` +AppContext switch before the first use of `Baggage`. + +For SDK-style .NET applications, add the switch to the application's project +file so the host configures it at startup: + +```xml + + + +``` + +This generates an entry in the application's `.runtimeconfig.json` +`runtimeOptions.configProperties`. For .NET Framework applications, configure +the switch in the application's `App.config`: + +```xml + + + + + +``` + +Applications can also set the switch in startup code before any component +uses `Baggage`: + +```csharp +AppContext.SetSwitch("OpenTelemetry.Baggage.AllowUnsafeLegacyFlow", true); +``` + +An unset or `false` switch keeps the isolated behavior. The selected mode is +cached when `Baggage` initializes. Configure it at startup and restart the +application after changing it. SDK configuration, dependency injection, and +later `AppContext.SetSwitch` calls do not update the cached mode. + +> [!WARNING] +> Legacy flow is insecure for independent operations. A write can change baggage +> seen by an operation that is already running, its parent, or its siblings. +> This can mix baggage belonging to different users and propagate it with the +> wrong operation. The compatibility option restores this risk; it does not +> provide isolation between requests or messages. Use it only temporarily while +> migrating applications, then remove it. + +This changes the default behavior of existing applications. The compatibility +option provides a migration path; enabling it does not make the new default +backward compatible. + ## Introduction to OpenTelemetry .NET Tracing API .NET runtime had `Activity` class for a long time, which was meant to be used diff --git a/test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs b/test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs new file mode 100644 index 00000000000..146f6fbc088 --- /dev/null +++ b/test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs @@ -0,0 +1,158 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +#if NET +using System.Text.Json.Nodes; +#else +using System.Xml; +#endif + +namespace OpenTelemetry.Tests; + +public class BaggageConfigurationTests +{ + private const string AppContextSwitch = "OpenTelemetry.Baggage.AllowUnsafeLegacyFlow"; + + [Theory] + [InlineData(null)] + [InlineData(false)] + [InlineData(true)] + public Task FlowModeIsSelectedAtProcessStartup(bool? enabled) + => RunChildProcess(enabled); + + private static async Task RunChildProcess(bool? appContextValue) + { + var directory = Path.Combine(Path.GetTempPath(), $"OpenTelemetry.BaggageTests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + await RunChildProcess(CreateStartInfo(directory, appContextValue), appContextValue); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static async Task RunChildProcess(ProcessStartInfo startInfo, bool? appContextValue) + { + using var process = Process.Start(startInfo); + Assert.NotNull(process); +#if NET + var output = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken); + var error = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken); +#else + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); +#endif + + try + { +#if NET + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await process.WaitForExitAsync(timeout.Token); +#else + Assert.True(process.WaitForExit(30_000), "Baggage child test process timed out."); +#endif + Assert.True( + process.ExitCode == 0, + $"Baggage AppContext '{appContextValue?.ToString() ?? ""}' failed:\n{await output}\n{await error}"); + } + finally + { + if (!process.HasExited) + { + process.Kill(); +#if NET + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await process.WaitForExitAsync(timeout.Token); +#else + Assert.True(process.WaitForExit(5_000), "Baggage child test process did not exit after termination."); +#endif + } + } + } + + private static ProcessStartInfo CreateStartInfo(string directory, bool? enabled) + { + var assemblyPath = typeof(BaggageTests).Assembly.Location; +#if NET + var config = JsonNode.Parse(File.ReadAllText(Path.ChangeExtension(assemblyPath, ".runtimeconfig.json")))!; + var options = config["runtimeOptions"]!; + var properties = options["configProperties"]?.AsObject(); + if (properties == null) + { + properties = []; + options["configProperties"] = properties; + } + + properties.Remove(AppContextSwitch); + if (enabled.HasValue) + { + properties[AppContextSwitch] = enabled.Value; + } + + var configPath = Path.Combine(directory, "test.runtimeconfig.json"); + File.WriteAllText(configPath, config.ToJsonString()); + var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"); + startInfo.ArgumentList.Add("exec"); + startInfo.ArgumentList.Add("--runtimeconfig"); + startInfo.ArgumentList.Add(configPath); + startInfo.ArgumentList.Add(assemblyPath); + startInfo.ArgumentList.Add("--filter-class"); + startInfo.ArgumentList.Add(typeof(BaggageTests).FullName!); +#else + // Framework loads configuration next to the executable. Give the child + // its own copy without changing the parent's executable or configuration. + foreach (var file in Directory.EnumerateFiles(Path.GetDirectoryName(assemblyPath)!)) + { + File.Copy(file, Path.Combine(directory, Path.GetFileName(file))); + } + + var childAssemblyPath = Path.Combine(directory, Path.GetFileName(assemblyPath)); + var configPath = childAssemblyPath + ".config"; + var config = new XmlDocument { XmlResolver = null }; + using (var reader = XmlReader.Create(configPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null })) + { + config.Load(reader); + } + + var runtime = config.SelectSingleNode("/configuration/runtime")!; + var overrides = (XmlElement?)runtime.SelectSingleNode("AppContextSwitchOverrides"); + if (overrides == null) + { + overrides = config.CreateElement("AppContextSwitchOverrides"); + runtime.AppendChild(overrides); + } + + var switches = overrides.GetAttribute("value").Split(';') + .Where(value => !string.IsNullOrWhiteSpace(value) + && !value.StartsWith(AppContextSwitch + "=", StringComparison.Ordinal)) + .ToList(); + if (enabled.HasValue) + { + switches.Add($"{AppContextSwitch}={enabled.Value}"); + } + + if (switches.Count == 0) + { + runtime.RemoveChild(overrides); + } + else + { + overrides.SetAttribute("value", string.Join(";", switches)); + } + + config.Save(configPath); + var startInfo = new ProcessStartInfo(childAssemblyPath) + { + Arguments = $"--filter-class {typeof(BaggageTests).FullName}", + }; +#endif + startInfo.UseShellExecute = false; + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + return startInfo; + } +} diff --git a/test/OpenTelemetry.Api.Tests/BaggageTests.cs b/test/OpenTelemetry.Api.Tests/BaggageTests.cs index c7815c37103..afdcf3bb674 100644 --- a/test/OpenTelemetry.Api.Tests/BaggageTests.cs +++ b/test/OpenTelemetry.Api.Tests/BaggageTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Globalization; +using OpenTelemetry.Context.Propagation; namespace OpenTelemetry.Tests; @@ -15,6 +16,13 @@ public class BaggageTests private const string V2 = "Value2"; private const string V3 = "Value3"; + // Each flow mode is tested in a fresh process with this setting established at startup. + // The caching test initializes Baggage before changing the switch. Tests in + // this class run serially; other classes use the already-cached flow mode. + private static bool AllowUnsafeLegacyFlow => + AppContext.TryGetSwitch("OpenTelemetry.Baggage.AllowUnsafeLegacyFlow", out var enabled) + && enabled; + [Fact] public void EmptyTest() { @@ -399,42 +407,266 @@ public void GetHashCodeTests() } [Fact] - public async Task AsyncLocalTests() + public async Task SynchronousTaskReturningHelperUpdatesCurrent() + { + Baggage.Current = Baggage.Create(new Dictionary { [K1] = V1 }); + + await Enrich(); + + Assert.Equal(2, Baggage.Current.Count); + Assert.Equal(V1, Baggage.GetBaggage(K1)); + Assert.Equal(V2, Baggage.GetBaggage(K2)); + + static Task Enrich() + { + Baggage.SetBaggage(K2, V2); + return Task.CompletedTask; + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AsyncLocalTests(bool yield) { Baggage.SetBaggage("key1", "value1"); - await InnerTask(); + await InnerTask(yield); Baggage.SetBaggage("key4", "value4"); - Assert.Equal(4, Baggage.Current.Count); + Assert.Equal(AllowUnsafeLegacyFlow ? 4 : 2, Baggage.Current.Count); Assert.Equal("value1", Baggage.GetBaggage("key1")); - Assert.Equal("value2", Baggage.GetBaggage("key2")); - Assert.Equal("value3", Baggage.GetBaggage("key3")); + Assert.Equal(AllowUnsafeLegacyFlow ? "value2" : null, Baggage.GetBaggage("key2")); + Assert.Equal(AllowUnsafeLegacyFlow ? "value3" : null, Baggage.GetBaggage("key3")); Assert.Equal("value4", Baggage.GetBaggage("key4")); - static async Task InnerTask() + static async Task InnerTask(bool yield) { Baggage.SetBaggage("key2", "value2"); - await Task.Yield(); + if (yield) + { + await Task.Yield(); + } Baggage.SetBaggage("key3", "value3"); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CurrentAssignmentDoesNotChangePreviouslyForkedContext(bool clear) + { + var original = Baggage.Create(new Dictionary { [K1] = V1 }); + var replacement = clear ? default : Baggage.Create(new Dictionary { [K1] = V2 }); + Baggage.Current = original; + + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var child = Task.Run(async () => + { + await WaitForCompletion(release.Task); + return Baggage.Current; + }); + + Baggage.Current = replacement; + release.SetResult(true); + + Assert.Equal(AllowUnsafeLegacyFlow ? replacement : original, await WaitForCompletion(child)); + Assert.Equal(replacement, Baggage.Current); + } + + [Theory] + [InlineData("Set", false)] + [InlineData("Set", true)] + [InlineData("SetMany", false)] + [InlineData("SetMany", true)] + [InlineData("Remove", false)] + [InlineData("Remove", true)] + [InlineData("Clear", false)] + [InlineData("Clear", true)] + public async Task StaticMutatorDoesNotChangePreviouslyForkedContext(string operation, bool explicitBaggage) + { + var original = Baggage.Create(new Dictionary { [K1] = V1, [K2] = V2 }); + Baggage.Current = original; + + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var child = Task.Run(async () => + { + await WaitForCompletion(release.Task); + return Baggage.Current; + }); + + var source = explicitBaggage + ? Baggage.Create(new Dictionary { [K1] = V3, [K2] = V2 }) + : default; + var replacement = operation switch + { + "Set" => Baggage.SetBaggage(K3, V3, source), + "SetMany" => Baggage.SetBaggage(new Dictionary { [K3] = V3 }, source), + "Remove" => Baggage.RemoveBaggage(K2, source), + "Clear" => Baggage.ClearBaggage(source), + _ => throw new InvalidOperationException(operation), + }; + release.SetResult(true); + + Assert.Equal(AllowUnsafeLegacyFlow ? replacement : original, await WaitForCompletion(child)); + Assert.Equal(replacement, Baggage.Current); + } + + [Fact] + public async Task SiblingHandlersDoNotShareCurrent() + { + // An empty assignment must not cause later handlers to share mutable state. + Baggage.Current = default; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var first = HandleAsync(V1); + var second = HandleAsync(V2); + release.SetResult(true); + + Assert.Equal(AllowUnsafeLegacyFlow ? V2 : V1, await WaitForCompletion(first)); + Assert.Equal(V2, await WaitForCompletion(second)); + Assert.Equal(AllowUnsafeLegacyFlow ? V2 : null, Baggage.GetBaggage(K1)); + + async Task HandleAsync(string value) + { + Baggage.Current = Baggage.Create(new Dictionary { [K1] = value }); + await WaitForCompletion(release.Task); + return Baggage.GetBaggage(K1); + } + } + + [Fact] + public async Task SuppressedFlowDoesNotShareBaggageWithParent() + { + Baggage.Current = Baggage.Create(new Dictionary { [K1] = V1 }); - // key2 & key3 changes don't flow backward automatically + Task child; + using (ExecutionContext.SuppressFlow()) + { + child = Task.Run( + static () => + { + Assert.Empty(Baggage.Current.GetBaggage()); + Baggage.SetBaggage(K2, V2); + Assert.Equal(V2, Baggage.GetBaggage(K2)); + Assert.Null(Baggage.GetBaggage(K1)); + }, + TestContext.Current.CancellationToken); } + + await WaitForCompletion(child); + + Assert.Equal(1, Baggage.Current.Count); + Assert.Equal(V1, Baggage.GetBaggage(K1)); + Assert.Null(Baggage.GetBaggage(K2)); + } + + [Fact] + public async Task AwaitedCurrentAssignmentHonorsFlowMode() + { + Baggage.Current = Baggage.Create(new Dictionary { [K1] = V1 }); + await EnrichAsync(); + + Assert.Equal(V1, Baggage.GetBaggage(K1)); + Assert.Equal(AllowUnsafeLegacyFlow ? V2 : null, Baggage.GetBaggage(K2)); + + static async Task EnrichAsync() + { + await Task.Yield(); + Baggage.Current = Baggage.Current.SetBaggage(K2, V2); + Assert.Equal(V2, Baggage.GetBaggage(K2)); + } + } + + [Fact] + public async Task InjectUsesBaggageInheritedByTheHandler() + { + var propagator = new BaggagePropagator(); + var firstHeaders = new Dictionary { ["baggage"] = "tenant=first" }; + var firstContext = propagator.Extract(default, firstHeaders, static (carrier, key) => [carrier[key]]); + Baggage.Current = firstContext.Baggage; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var child = Task.Run(async () => + { + await WaitForCompletion(release.Task); + var headers = new Dictionary(); + propagator.Inject( + new PropagationContext(default, Baggage.Current), + headers, + static (carrier, key, value) => carrier[key] = value); + return headers["baggage"]; + }); + + var secondHeaders = new Dictionary { ["baggage"] = "tenant=second" }; + var secondContext = propagator.Extract(default, secondHeaders, static (carrier, key) => [carrier[key]]); + Baggage.Current = secondContext.Baggage; + release.SetResult(true); + + Assert.Equal(AllowUnsafeLegacyFlow ? "tenant=second" : "tenant=first", await WaitForCompletion(child)); } [Fact] public void ThreadSafetyTest() { - Baggage.SetBaggage("rootKey", "rootValue"); // Note: Required to establish a root ExecutionContext containing the BaggageHolder we use as a lock + // Establish the parent context before forking parallel operations. + Baggage.SetBaggage("rootKey", "rootValue"); Parallel.For(0, 100, (i) => { Baggage.SetBaggage($"key{i}", $"value{i}"); }); - Assert.Equal(101, Baggage.Current.Count); + Assert.Equal(AllowUnsafeLegacyFlow ? 101 : 1, Baggage.Current.Count); + } + + [Fact] + public async Task FlowModeDoesNotChangeAfterInitialization() + { + var enabled = AllowUnsafeLegacyFlow; + var original = Baggage.Create(new Dictionary { [K1] = V1 }); + var replacement = Baggage.Create(new Dictionary { [K1] = V2 }); + Baggage.Current = original; + + try + { + AppContext.SetSwitch("OpenTelemetry.Baggage.AllowUnsafeLegacyFlow", !enabled); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var child = Task.Run(async () => + { + await WaitForCompletion(release.Task); + return Baggage.Current; + }); + Baggage.Current = replacement; + release.SetResult(true); + + Assert.Equal(enabled ? replacement : original, await WaitForCompletion(child)); + } + finally + { + AppContext.SetSwitch("OpenTelemetry.Baggage.AllowUnsafeLegacyFlow", enabled); + } + } + + private static async Task WaitForCompletion(Task task) + { + using var timeout = new CancellationTokenSource(); + var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(10), timeout.Token)); + Assert.Same(task, completed); +#if NET + await timeout.CancelAsync(); +#else + timeout.Cancel(); +#endif + await task; + } + + private static async Task WaitForCompletion(Task task) + { + await WaitForCompletion((Task)task); + return await task; } } From 20ada3a09ffb06364aa79d8d86b081f37472d67e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Mon, 26 Oct 2026 12:52:47 +0100 Subject: [PATCH 2/5] PR number in changelog --- src/OpenTelemetry.Api/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenTelemetry.Api/CHANGELOG.md b/src/OpenTelemetry.Api/CHANGELOG.md index 91439a383dc..025692658f3 100644 --- a/src/OpenTelemetry.Api/CHANGELOG.md +++ b/src/OpenTelemetry.Api/CHANGELOG.md @@ -10,7 +10,7 @@ Notes](../../RELEASENOTES.md). Changes made in awaited async methods no longer update their caller's baggage. Enable the `OpenTelemetry.Baggage.AllowUnsafeLegacyFlow` AppContext switch at startup to temporarily restore insecure legacy sharing. - ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/pull/TODO)) + ([#8042](https://github.com/open-telemetry/opentelemetry-dotnet/pull/8042)) ## 1.20.1 From e440effcc10738032075172ba91efdbd6049e52b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Tue, 27 Oct 2026 11:11:44 +0100 Subject: [PATCH 3/5] Reuse shared temporary directory in baggage configuration tests Use the existing TemporaryDirectory helper for isolated startup-mode test directories and link its source into the API test project. Discovered by Anthropic's OSS Scanner, as bug ANT-2026-YXEDG1G8. --- .../BaggageConfigurationTests.cs | 12 ++---------- .../OpenTelemetry.Api.Tests.csproj | 1 + 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs b/test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs index 146f6fbc088..713b3a1f7f0 100644 --- a/test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs +++ b/test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs @@ -23,16 +23,8 @@ public Task FlowModeIsSelectedAtProcessStartup(bool? enabled) private static async Task RunChildProcess(bool? appContextValue) { - var directory = Path.Combine(Path.GetTempPath(), $"OpenTelemetry.BaggageTests-{Guid.NewGuid():N}"); - Directory.CreateDirectory(directory); - try - { - await RunChildProcess(CreateStartInfo(directory, appContextValue), appContextValue); - } - finally - { - Directory.Delete(directory, recursive: true); - } + using var directory = new TemporaryDirectory(); + await RunChildProcess(CreateStartInfo(directory.Path, appContextValue), appContextValue); } private static async Task RunChildProcess(ProcessStartInfo startInfo, bool? appContextValue) diff --git a/test/OpenTelemetry.Api.Tests/OpenTelemetry.Api.Tests.csproj b/test/OpenTelemetry.Api.Tests/OpenTelemetry.Api.Tests.csproj index aba8c8ec166..75445c7af77 100644 --- a/test/OpenTelemetry.Api.Tests/OpenTelemetry.Api.Tests.csproj +++ b/test/OpenTelemetry.Api.Tests/OpenTelemetry.Api.Tests.csproj @@ -21,6 +21,7 @@ + From 7636bc4cd4f73fc9c696a1ab3a9c8aa88081e6a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Tue, 27 Oct 2026 12:22:33 +0100 Subject: [PATCH 4/5] Remove baggage AppContext-specific test harness Remove startup-mode and caching tests and their unused helper include. Keep the baggage regressions focused on the safe default behavior. Discovered by Anthropic's OSS Scanner, as bug ANT-2026-YXEDG1G8. --- .../BaggageConfigurationTests.cs | 150 ------------------ test/OpenTelemetry.Api.Tests/BaggageTests.cs | 57 ++----- .../OpenTelemetry.Api.Tests.csproj | 1 - 3 files changed, 11 insertions(+), 197 deletions(-) delete mode 100644 test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs diff --git a/test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs b/test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs deleted file mode 100644 index 713b3a1f7f0..00000000000 --- a/test/OpenTelemetry.Api.Tests/BaggageConfigurationTests.cs +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -using System.Diagnostics; -#if NET -using System.Text.Json.Nodes; -#else -using System.Xml; -#endif - -namespace OpenTelemetry.Tests; - -public class BaggageConfigurationTests -{ - private const string AppContextSwitch = "OpenTelemetry.Baggage.AllowUnsafeLegacyFlow"; - - [Theory] - [InlineData(null)] - [InlineData(false)] - [InlineData(true)] - public Task FlowModeIsSelectedAtProcessStartup(bool? enabled) - => RunChildProcess(enabled); - - private static async Task RunChildProcess(bool? appContextValue) - { - using var directory = new TemporaryDirectory(); - await RunChildProcess(CreateStartInfo(directory.Path, appContextValue), appContextValue); - } - - private static async Task RunChildProcess(ProcessStartInfo startInfo, bool? appContextValue) - { - using var process = Process.Start(startInfo); - Assert.NotNull(process); -#if NET - var output = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken); - var error = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken); -#else - var output = process.StandardOutput.ReadToEndAsync(); - var error = process.StandardError.ReadToEndAsync(); -#endif - - try - { -#if NET - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await process.WaitForExitAsync(timeout.Token); -#else - Assert.True(process.WaitForExit(30_000), "Baggage child test process timed out."); -#endif - Assert.True( - process.ExitCode == 0, - $"Baggage AppContext '{appContextValue?.ToString() ?? ""}' failed:\n{await output}\n{await error}"); - } - finally - { - if (!process.HasExited) - { - process.Kill(); -#if NET - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - await process.WaitForExitAsync(timeout.Token); -#else - Assert.True(process.WaitForExit(5_000), "Baggage child test process did not exit after termination."); -#endif - } - } - } - - private static ProcessStartInfo CreateStartInfo(string directory, bool? enabled) - { - var assemblyPath = typeof(BaggageTests).Assembly.Location; -#if NET - var config = JsonNode.Parse(File.ReadAllText(Path.ChangeExtension(assemblyPath, ".runtimeconfig.json")))!; - var options = config["runtimeOptions"]!; - var properties = options["configProperties"]?.AsObject(); - if (properties == null) - { - properties = []; - options["configProperties"] = properties; - } - - properties.Remove(AppContextSwitch); - if (enabled.HasValue) - { - properties[AppContextSwitch] = enabled.Value; - } - - var configPath = Path.Combine(directory, "test.runtimeconfig.json"); - File.WriteAllText(configPath, config.ToJsonString()); - var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"); - startInfo.ArgumentList.Add("exec"); - startInfo.ArgumentList.Add("--runtimeconfig"); - startInfo.ArgumentList.Add(configPath); - startInfo.ArgumentList.Add(assemblyPath); - startInfo.ArgumentList.Add("--filter-class"); - startInfo.ArgumentList.Add(typeof(BaggageTests).FullName!); -#else - // Framework loads configuration next to the executable. Give the child - // its own copy without changing the parent's executable or configuration. - foreach (var file in Directory.EnumerateFiles(Path.GetDirectoryName(assemblyPath)!)) - { - File.Copy(file, Path.Combine(directory, Path.GetFileName(file))); - } - - var childAssemblyPath = Path.Combine(directory, Path.GetFileName(assemblyPath)); - var configPath = childAssemblyPath + ".config"; - var config = new XmlDocument { XmlResolver = null }; - using (var reader = XmlReader.Create(configPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null })) - { - config.Load(reader); - } - - var runtime = config.SelectSingleNode("/configuration/runtime")!; - var overrides = (XmlElement?)runtime.SelectSingleNode("AppContextSwitchOverrides"); - if (overrides == null) - { - overrides = config.CreateElement("AppContextSwitchOverrides"); - runtime.AppendChild(overrides); - } - - var switches = overrides.GetAttribute("value").Split(';') - .Where(value => !string.IsNullOrWhiteSpace(value) - && !value.StartsWith(AppContextSwitch + "=", StringComparison.Ordinal)) - .ToList(); - if (enabled.HasValue) - { - switches.Add($"{AppContextSwitch}={enabled.Value}"); - } - - if (switches.Count == 0) - { - runtime.RemoveChild(overrides); - } - else - { - overrides.SetAttribute("value", string.Join(";", switches)); - } - - config.Save(configPath); - var startInfo = new ProcessStartInfo(childAssemblyPath) - { - Arguments = $"--filter-class {typeof(BaggageTests).FullName}", - }; -#endif - startInfo.UseShellExecute = false; - startInfo.RedirectStandardOutput = true; - startInfo.RedirectStandardError = true; - return startInfo; - } -} diff --git a/test/OpenTelemetry.Api.Tests/BaggageTests.cs b/test/OpenTelemetry.Api.Tests/BaggageTests.cs index afdcf3bb674..b6ca3283bbe 100644 --- a/test/OpenTelemetry.Api.Tests/BaggageTests.cs +++ b/test/OpenTelemetry.Api.Tests/BaggageTests.cs @@ -16,13 +16,6 @@ public class BaggageTests private const string V2 = "Value2"; private const string V3 = "Value3"; - // Each flow mode is tested in a fresh process with this setting established at startup. - // The caching test initializes Baggage before changing the switch. Tests in - // this class run serially; other classes use the already-cached flow mode. - private static bool AllowUnsafeLegacyFlow => - AppContext.TryGetSwitch("OpenTelemetry.Baggage.AllowUnsafeLegacyFlow", out var enabled) - && enabled; - [Fact] public void EmptyTest() { @@ -435,10 +428,10 @@ public async Task AsyncLocalTests(bool yield) Baggage.SetBaggage("key4", "value4"); - Assert.Equal(AllowUnsafeLegacyFlow ? 4 : 2, Baggage.Current.Count); + Assert.Equal(2, Baggage.Current.Count); Assert.Equal("value1", Baggage.GetBaggage("key1")); - Assert.Equal(AllowUnsafeLegacyFlow ? "value2" : null, Baggage.GetBaggage("key2")); - Assert.Equal(AllowUnsafeLegacyFlow ? "value3" : null, Baggage.GetBaggage("key3")); + Assert.Null(Baggage.GetBaggage("key2")); + Assert.Null(Baggage.GetBaggage("key3")); Assert.Equal("value4", Baggage.GetBaggage("key4")); static async Task InnerTask(bool yield) @@ -473,7 +466,7 @@ public async Task CurrentAssignmentDoesNotChangePreviouslyForkedContext(bool cle Baggage.Current = replacement; release.SetResult(true); - Assert.Equal(AllowUnsafeLegacyFlow ? replacement : original, await WaitForCompletion(child)); + Assert.Equal(original, await WaitForCompletion(child)); Assert.Equal(replacement, Baggage.Current); } @@ -511,7 +504,7 @@ public async Task StaticMutatorDoesNotChangePreviouslyForkedContext(string opera }; release.SetResult(true); - Assert.Equal(AllowUnsafeLegacyFlow ? replacement : original, await WaitForCompletion(child)); + Assert.Equal(original, await WaitForCompletion(child)); Assert.Equal(replacement, Baggage.Current); } @@ -526,9 +519,9 @@ public async Task SiblingHandlersDoNotShareCurrent() var second = HandleAsync(V2); release.SetResult(true); - Assert.Equal(AllowUnsafeLegacyFlow ? V2 : V1, await WaitForCompletion(first)); + Assert.Equal(V1, await WaitForCompletion(first)); Assert.Equal(V2, await WaitForCompletion(second)); - Assert.Equal(AllowUnsafeLegacyFlow ? V2 : null, Baggage.GetBaggage(K1)); + Assert.Null(Baggage.GetBaggage(K1)); async Task HandleAsync(string value) { @@ -565,13 +558,13 @@ public async Task SuppressedFlowDoesNotShareBaggageWithParent() } [Fact] - public async Task AwaitedCurrentAssignmentHonorsFlowMode() + public async Task AwaitedCurrentAssignmentDoesNotChangeCaller() { Baggage.Current = Baggage.Create(new Dictionary { [K1] = V1 }); await EnrichAsync(); Assert.Equal(V1, Baggage.GetBaggage(K1)); - Assert.Equal(AllowUnsafeLegacyFlow ? V2 : null, Baggage.GetBaggage(K2)); + Assert.Null(Baggage.GetBaggage(K2)); static async Task EnrichAsync() { @@ -606,7 +599,7 @@ public async Task InjectUsesBaggageInheritedByTheHandler() Baggage.Current = secondContext.Baggage; release.SetResult(true); - Assert.Equal(AllowUnsafeLegacyFlow ? "tenant=second" : "tenant=first", await WaitForCompletion(child)); + Assert.Equal("tenant=first", await WaitForCompletion(child)); } [Fact] @@ -620,35 +613,7 @@ public void ThreadSafetyTest() Baggage.SetBaggage($"key{i}", $"value{i}"); }); - Assert.Equal(AllowUnsafeLegacyFlow ? 101 : 1, Baggage.Current.Count); - } - - [Fact] - public async Task FlowModeDoesNotChangeAfterInitialization() - { - var enabled = AllowUnsafeLegacyFlow; - var original = Baggage.Create(new Dictionary { [K1] = V1 }); - var replacement = Baggage.Create(new Dictionary { [K1] = V2 }); - Baggage.Current = original; - - try - { - AppContext.SetSwitch("OpenTelemetry.Baggage.AllowUnsafeLegacyFlow", !enabled); - var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var child = Task.Run(async () => - { - await WaitForCompletion(release.Task); - return Baggage.Current; - }); - Baggage.Current = replacement; - release.SetResult(true); - - Assert.Equal(enabled ? replacement : original, await WaitForCompletion(child)); - } - finally - { - AppContext.SetSwitch("OpenTelemetry.Baggage.AllowUnsafeLegacyFlow", enabled); - } + Assert.Equal(1, Baggage.Current.Count); } private static async Task WaitForCompletion(Task task) diff --git a/test/OpenTelemetry.Api.Tests/OpenTelemetry.Api.Tests.csproj b/test/OpenTelemetry.Api.Tests/OpenTelemetry.Api.Tests.csproj index 75445c7af77..aba8c8ec166 100644 --- a/test/OpenTelemetry.Api.Tests/OpenTelemetry.Api.Tests.csproj +++ b/test/OpenTelemetry.Api.Tests/OpenTelemetry.Api.Tests.csproj @@ -21,7 +21,6 @@ - From e53872af1f801db69a2357a7bde3d291edc1f646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Tue, 27 Oct 2026 12:38:36 +0100 Subject: [PATCH 5/5] Clarify baggage compatibility switch documentation Link the AppContext documentation, add the ASP.NET Web.config example, and clarify the warning about insecure legacy baggage flow. Discovered by Anthropic's OSS Scanner, as bug ANT-2026-YXEDG1G8. Co-authored-by: Martin Costello --- src/OpenTelemetry.Api/README.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/OpenTelemetry.Api/README.md b/src/OpenTelemetry.Api/README.md index 3dde35cb17f..44e067da902 100644 --- a/src/OpenTelemetry.Api/README.md +++ b/src/OpenTelemetry.Api/README.md @@ -142,7 +142,8 @@ Earlier versions shared a mutable baggage holder between inherited execution contexts. That allowed awaited helpers and parallel tasks to update their caller's baggage. Applications that require time to migrate can restore that behavior by enabling the `OpenTelemetry.Baggage.AllowUnsafeLegacyFlow` -AppContext switch before the first use of `Baggage`. +[AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) +before the first use of `Baggage`. For SDK-style .NET applications, add the switch to the application's project file so the host configures it at startup: @@ -156,8 +157,8 @@ file so the host configures it at startup: ``` This generates an entry in the application's `.runtimeconfig.json` -`runtimeOptions.configProperties`. For .NET Framework applications, configure -the switch in the application's `App.config`: +`runtimeOptions.configProperties`. For .NET Framework applications that use +`App.config`, configure the switch as follows: ```xml @@ -168,6 +169,18 @@ the switch in the application's `App.config`: ``` +For ASP.NET applications on .NET Framework, configure the switch in the +application's `Web.config` using `appSettings`: + +```xml + + + + + +``` + Applications can also set the switch in startup code before any component uses `Baggage`: @@ -181,8 +194,8 @@ application after changing it. SDK configuration, dependency injection, and later `AppContext.SetSwitch` calls do not update the cached mode. > [!WARNING] -> Legacy flow is insecure for independent operations. A write can change baggage -> seen by an operation that is already running, its parent, or its siblings. +> The legacy flow is insecure for independent operations. A write can change +> baggage seen by an operation that is already running, its parent, or its siblings. > This can mix baggage belonging to different users and propagate it with the > wrong operation. The compatibility option restores this risk; it does not > provide isolation between requests or messages. Use it only temporarily while