Using ASP.NET Web API, my ExecutionContext isn't flowing in async actions
Asked Answered
S

2

23

I'm having difficulty understanding the mechanics behind ExecutionContext.

From what I've read online, context-sensitive items such as security (Thread Principal), culture, etc, should flow across asynchronous threads within the bounds of an execution unit of work.

I'm encountering very confusing and potentially dangerous bugs though. I'm noticing my thread's CurrentPrincipal is getting lost across async execution.


Here is an example ASP.NET Web API scenario:

First, let's setup a simple Web API configuration with two delegating handlers for testing purposes.

All they do is write out debug information and pass the request/response on through, except the first "DummyHandler" which sets the thread's principal as well as a piece of data to be shared across the context (the request's correlation ID).

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        config.MessageHandlers.Add(new DummyHandler());
        config.MessageHandlers.Add(new AnotherDummyHandler());

        config.Routes.MapHttpRoute(
            name: "DefaultApi",
            routeTemplate: "api/{controller}/{id}",
            defaults: new { id = RouteParameter.Optional }
        );
    }
}

public class DummyHandler : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        CallContext.LogicalSetData("rcid", request.GetCorrelationId());
        Thread.CurrentPrincipal = new ClaimsPrincipal(new ClaimsPrincipal(new ClaimsIdentity(new[]{ new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "dgdev") }, "myauthisthebest")));

        Debug.WriteLine("Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
        Debug.WriteLine("User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
        Debug.WriteLine("RCID: {0}", CallContext.LogicalGetData("rcid"));

        return base.SendAsync(request, cancellationToken)
                   .ContinueWith(task =>
                       {
                           Debug.WriteLine("Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
                           Debug.WriteLine("User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
                           Debug.WriteLine("RCID: {0}", CallContext.LogicalGetData("rcid"));

                           return task.Result;
                       });
    }
}

public class AnotherDummyHandler : MessageProcessingHandler
{
    protected override HttpRequestMessage ProcessRequest(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Debug.WriteLine("  Another Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
        Debug.WriteLine("  User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
        Debug.WriteLine("  RCID: {0}", CallContext.LogicalGetData("rcid"));

        return request;
    }

    protected override HttpResponseMessage ProcessResponse(HttpResponseMessage response, CancellationToken cancellationToken)
    {
        Debug.WriteLine("  Another Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
        Debug.WriteLine("  User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
        Debug.WriteLine("  RCID: {0}", CallContext.LogicalGetData("rcid"));

        return response;
    }
}

Simple enough. Next let's add a single ApiController to handle an HTTP POST, as if you were uploading files.

public class UploadController : ApiController
{
    public async Task<HttpResponseMessage> PostFile()
    {
        Debug.WriteLine("    Thread: {0}", Thread.CurrentThread.ManagedThreadId);
        Debug.WriteLine("    User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
        Debug.WriteLine("    RCID: {0}", CallContext.LogicalGetData("rcid"));

        if (!Request.Content.IsMimeMultipartContent())
        {
            throw new HttpResponseException(HttpStatusCode.UnsupportedMediaType);
        }

        try
        {
            await Request.Content.ReadAsMultipartAsync(
                new MultipartFormDataStreamProvider(
                    HttpRuntime.AppDomainAppPath + @"upload\temp"));

            Debug.WriteLine("    Thread: {0}", Thread.CurrentThread.ManagedThreadId);
            Debug.WriteLine("    User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
            Debug.WriteLine("    RCID: {0}", CallContext.LogicalGetData("rcid"));

            return new HttpResponseMessage(HttpStatusCode.Created);
        }
        catch (Exception e)
        {
            return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, e);
        }
    }
}

Upon running a test with Fiddler, this is the output I receive:

Dummy Handler Thread: 63
User: dgdev
RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

  Another Dummy Handler Thread: 63
  User: dgdev
  RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

    Thread: 63
    User: dgdev
    RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

    Thread: 77
    User:                                     <<<  PRINCIPAL IS LOST AFTER ASYNC
    RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

  Another Dummy Handler Thread: 63
  User:                                       <<<  PRINCIPAL IS STILL LOST
  RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

Dummy Handler Thread: 65
User: dgdev                                   <<<  PRINCIPAL IS BACK?!?
RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476

To make matters more confusing, When I append the follow to the async line:

await Request.Content.ReadAsMultipartAsync(
    new MultipartFormDataStreamProvider(..same as before..))
.ConfigureAwait(false); <<<<<<

I now receive this output:

Dummy Handler Thread: 40
User: dgdev
RCID: 8d944500-cb52-4362-8537-dab405fa12a2

  Another Dummy Handler Thread: 40
  User: dgdev
  RCID: 8d944500-cb52-4362-8537-dab405fa12a2

    Thread: 40
    User: dgdev
    RCID: 8d944500-cb52-4362-8537-dab405fa12a2

    Thread: 65
    User: dgdev                               <<<  PRINCIPAL IS HERE!
    RCID: 8d944500-cb52-4362-8537-dab405fa12a2

  Another Dummy Handler Thread: 65
  User:                                       <<<  PRINCIPAL IS LOST
  RCID: 8d944500-cb52-4362-8537-dab405fa12a2

Dummy Handler Thread: 40
User: dgdev
RCID: 8d944500-cb52-4362-8537-dab405fa12a2

The point here is this. The code following the async my in fact call my business logic or simply require the security context be properly set. There is a potential integrity problem going on.

Can anyone help shed some light one what is happening?

Thanks in advance.

Skirling answered 12/4, 2013 at 6:6 Comment(0)
W
30

I don't have all the answers, but I can help fill in some blanks and guess at the problem.

By default, the ASP.NET SynchronizationContext will flow, but the way it flows identity is a bit weird. It actually flows HttpContext.Current.User and then sets Thread.CurrentPrincipal to that. So if you just set Thread.CurrentPrincipal, you won't see it flow correctly.

In fact, you'll see the following behavior:

  • From the time Thread.CurrentPrincipal is set on a thread, that thread will have that same principal until it re-enters an ASP.NET context.
  • When any thread enters the ASP.NET context, Thread.CurrentPrincipal is cleared (because it's set to HttpContext.Current.User).
  • When a thread is used outside the ASP.NET context, it just retains whatever Thread.CurrentPrincipal happened to be set on it.

Applying this to your original code and output:

  • The first 3 are all reported synchronously from thread 63 after its CurrentPrincipal was explicitly set, so they all have the expected value.
  • Thread 77 is used to resume the async method, thus entering the ASP.NET context and clearing any CurrentPrincipal it may have had.
  • Thread 63 is used for ProcessResponse. It re-enters the ASP.NET context, clearing its Thread.CurrentPrincipal.
  • Thread 65 is the interesting one. It is running outside the ASP.NET context (in a ContinueWith without a scheduler), so it just retains whatever CurrentPrincipal it happened to have before. I assume that its CurrentPrincipal is just left over from an earlier test run.

The updated code changes PostFile to run its second portion outside the ASP.NET context. So it picks up thread 65, which just happens to have CurrentPrincipal set. Since it's outside the ASP.NET context, CurrentPrincipal isn't cleared.

So, it looks to me like ExecutionContext is flowing fine. I'm sure Microsoft has tested ExecutionContext flow out the wazoo; otherwise every ASP.NET app in the world would have a serious security flaw. It's important to note that in this code Thread.CurrentPrincipal just refers to the current user's claims and does not represent actual impersonation.

If my guesses are correct, then the fix is quite simple: in SendAsync, change this line:

Thread.CurrentPrincipal = new ClaimsPrincipal(new ClaimsPrincipal(new ClaimsIdentity(new[]{ new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "dgdev") }, "myauthisthebest")));

to this:

HttpContext.Current.User = new ClaimsPrincipal(new ClaimsPrincipal(new ClaimsIdentity(new[]{ new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "dgdev") }, "myauthisthebest")));
Thread.CurrentPrincipal = HttpContext.Current.User;
Withershins answered 12/4, 2013 at 13:10 Comment(5)
Stephen, I can't thank you enough for your well-articulated answer. I had read over a previous SO question you had responded in regards to ExecutionContext, as well as yours and Stephen Toub's blogs on the subject. Still couldn't determine the cause of my issue. Thanks again! Your fix worked perfectly.Skirling
Glad I could help! The key was really Scott Hanselman's blog where he showed how the ASP.NET identity flowing was a little weird.Withershins
Hi again @stephencleary, I'm noticing "inconsistencies" with ExecutionContext flow when using LogicalSetData("ecid", Guid.NewGuid()) within an HttpApplication pipeline IHttpModule (BeginRequest). I can access the "ecid" from an ApiController for all scenarios except when the request happens to be a form multipart POST. Really what I'm trying to be able to do is unify parts of our application's pipeline, caching items Per-Request, therefore I need to create a request identifier (ecid). Any idea why formmulti part would act / flow differently? I'm also seeing diff between C# 4/5 in flow.Skirling
Be sure to set UseTaskFriendlySynchronizationContext and target .NET 4.5. WebAPI may technically work in .NET 4.0 but I'd be really cautious about that setup (not sure if it's even supported). If you're still having problems, post a new question (with a minimal repro if possible).Withershins
@StephenCleary please do you think you could help me with this question #24236117Eddra
B
0

I understand that reentering the ASP.NET synchronization context will cause Thread.CurrentPrincipal to be set to HttpContext.Current.User. But I am still not seeing the behavior I expected. I didn't expect that every awaited call up the chain would set Thread.CurrentPrincipal = HttpContext.Current.User. I see this even going beyond the async void event handler that I started the async/await chain in. Is this the behavior others are seeing? I expected the calls up the chain to use their captured context to continue but they are showing the reentrant behavior.

I am not using .ContinueAwait(false) on any of my awaited calls. We have targetFramework="4.6.1" in the web.config which under the covers sets UseTaskFriendlySynchronizationContext = true, among other things. A 3rd party API client is causing the reentrant behavior at the bottom of the async/await chain.

Beauharnais answered 19/6, 2019 at 17:36 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.