Race condition in StiBlazorComponent.GetRequestParams (shared static HttpContext.Current) causes NullReferenceException

Stimulsoft Reports for Blazor discussion
Post Reply
User avatar
noob
Posts: 86
Joined: Sat Feb 05, 2022 9:03 am
Location: Iraq, Erbil

Race condition in StiBlazorComponent.GetRequestParams (shared static HttpContext.Current) causes NullReferenceException

Post by noob »

Product: Stimulsoft.Reports.Blazor
Versions: 2026.3.4 and 2026.4.1 (identical code, both affected)
Platform: Blazor Server (InteractiveServer), .NET 10.0.12, Linux (Docker)
Component: StiBlazorViewer

Summary

Stimulsoft.System.Web.HttpContext.Current is a single process-wide static object. StiBlazorComponent.GetRequestParams temporarily replaces its Request property, and StiRequestParamsHelper.Get then reads that property several times. With more than one viewer request in flight (different users' circuits, or the Task.Run'd async commands), another thread swaps Request in the middle of Get and Get throws a NullReferenceException.

Because StiBlazorViewer.Request is async void, the exception is unhandled and the user's Blazor circuit is terminated.

Production symptom

Code: Select all

System.NullReferenceException: Object reference not set to an instance of an object.
   at Stimulsoft.Report.Web.StiRequestParamsHelper.Get(HttpContext httpContext)
   at Stimulsoft.Report.Blazor.StiBlazorComponent.GetRequestParams(String url, String postData)
   at Stimulsoft.Report.Blazor.StiBlazorViewer.Request(String requestId, String url, String data)
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_0(Object state)
   at Microsoft.AspNetCore.Components.Rendering.RendererSynchronizationContext.PostAsync[TState](Task antecedent, Action`1 callback, TState state)
Logged by Blazor as "Unhandled exception in circuit". It occurred 8 times over 2 days, affecting 6 different users, while several users were rendering reports.

Root cause (from decompiling the shipped assemblies)

StiBlazorComponent.GetRequestParams (static):

Code: Select all

HttpRequest request = HttpContext.Current.Request;
HttpContext.Current.Request = new HttpRequest(url, postData);
StiRequestParams p = StiRequestParamsHelper.Get(HttpContext.Current);
HttpContext.Current.Request = request;
Stimulsoft.System.Web.HttpContext:

Code: Select all

public static HttpContext Current { get; } = new HttpContext();   // one instance per process
public HttpRequest Request { get; set; }
StiRequestParamsHelper.Get(HttpContext httpContext):

Code: Select all

foreach (string key in httpContext.Request.Params.Keys)
    if (key != null && key.StartsWith("stiweb_"))
        stiRequestParams.All[key] = httpContext.Request.Params[key].ToString();
httpContext.Request is re-read on every access. If another thread assigns a different HttpRequest to the shared HttpContext.Current.Request between Params.Keys and Params[key], then Params[key] is looked up in a different request that does not contain the key and returns null. .ToString() then throws the NullReferenceException.

The same shared state also causes:
  • The save/restore pattern is itself racy: thread B can save thread A's temporary request as "the original", so Current.Request is left pointing at a stale request.
  • HttpRequest.Params and HttpRequest.Form are lazily initialised without synchronisation, and NameValueCollection and Hashtable are not thread-safe. Under contention this produced IndexOutOfRangeException, ArgumentException from Hashtable.Insert and InvalidOperationException from NameObjectKeysEnumerator.MoveNext.
  • CreateDefaultRequestParams() also assigns HttpContext.Current.Request (and never restores it), so it can race with GetRequestParams as well.
Minimal reproduction (no Blazor needed)

Console app, net10.0, PackageReference Stimulsoft.Reports.Blazor 2026.4.1.

Code: Select all

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Stimulsoft.Report.Blazor;

var errors = new ConcurrentDictionary<string, int>();
var ok = 0;
var post = "stiweb_component=Viewer&stiweb_action=GetPages&stiweb_theme=Office2022" +
           "&stiweb_version=2026.4.1&stiweb_imagesscalingfactor=1&stiweb_usecompression=true" +
           "&stiweb_iconset=Office2022&stiweb_resourcesurl=x";

Parallel.For(0, 8, _ =>
{
    for (int i = 0; i < 3000; i++)
    {
        try
        {
            StiBlazorComponent.GetRequestParams("https://host/Requests?x=" + i, post);
            Interlocked.Increment(ref ok);
        }
        catch (Exception e)
        {
            errors.AddOrUpdate(e.GetType().Name + " @ " + e.StackTrace.Split('\n')[0].Trim(),
                               1, (_, n) => n + 1);
        }
    }
});

Console.WriteLine($"ok={ok}");
foreach (var kv in errors) Console.WriteLine($"{kv.Value} x {kv.Key}");
Result without a workaround (8 threads x 3000 calls = 24,000 calls):

Code: Select all

ok=8044
15150 x NullReferenceException @ at Stimulsoft.Report.Web.StiRequestParamsHelper.Get(HttpContext httpContext)
381   x ArgumentException @ at System.Collections.Hashtable.Insert(Object key, Object nvalue, Boolean add)
396   x InvalidOperationException @ at System.Collections.Specialized.NameObjectCollectionBase.NameObjectKeysEnumerator.MoveNext()
23    x NullReferenceException @ at System.Collections.Specialized.NameObjectCollectionBase.NameObjectKeysEnumerator.get_Current()
3     x IndexOutOfRangeException @ at System.Collections.ArrayList.Add(Object value)
3     x IndexOutOfRangeException @ at System.Collections.ArrayList.get_Item(Int32 index)
One further unpatched run did not finish within 5 minutes (threads appeared stuck in corrupted collections).

Confirmation of the cause

Wrapping GetRequestParams in a process-wide lock (a Harmony prefix doing Monitor.Enter and a finalizer doing Monitor.Exit) makes the same test complete 24,000 of 24,000 calls with zero errors.
Post Reply