2

I have an ASP.NET Core 2 app with Kestrel. The app is deployed to AWS Lambda/API Gateway. Everything works as expected except for a small detail that makes all the difference.

Some requests to my app need to issue multiple security-related Set-Cookie headers. Due to the way data are passed between API Gateway and Lambda, duplicate header names are joined together, which renders the Set-Cookie header invalid and the browser refuses to honor it.

A suggested solution to overcome this limitation is to use multiple headers names that vary only by casing: Set-Cookie, Set-cookie, set-cookie...

I know this is a hacky solution, but if it works it should be good enough while AWS fixes this limitation.

However, when using HttpContext.Response.Headers.Add(name, value), known header names are normalized and become regular duplicate headers.

Is it possible to go around this normalization mechanism or achieve the end goal in some other way?

Axel Zarate
  • 466
  • 4
  • 14

2 Answers2

5

When I started working on this question, I thought it would be easy. After half a day of researches (so cool that I'm on vacation), I could finally share the results.

HttpContext.Response.Headers has type of IHeaderDictionary. By default, in ASP.NET Core application on Kestrel, FrameResponseHeaders implementation is used. The main logic resides in FrameHeaders base class. This headers dictionary is highly optimized for seting / getting of frequently used standard http headers. Here is a code snippet that handles setting cookie (AddValueFast method):

if ("Set-Cookie".Equals(key, StringComparison.OrdinalIgnoreCase))
{
    if ((_bits & 67108864L) == 0)
    {
        _bits |= 67108864L;
        _headers._SetCookie = value;
        return true;
    }
    return false;
}

As far as StringComparison.OrdinalIgnoreCase is used for key comparison, you can't set another cookie header that differs only by the case. This makes sense because HTTP headers are case-insensitive. But let's try to overcome it.

The obvious solution here is to replace implementation of IHeaderDictionary with the case-sensitive one. ASP.NET Core contains a lot of seams and extensibility points for this, starting from IHttpResponseFeature that contains setable Headers property and ending with the possibility to replace implementation of HttpContext.

Unfortunately, all those replacements will not do the trick when running on Kestrel. If you check source code of Frame class that is responsible for writing HTTP response headers, you will see that it creates instance of FrameResponseHeaders by itself and does not respect any other instances set through IHttpResponseFeature or HttpContext.Response.Headers:

protected FrameResponseHeaders FrameResponseHeaders { get; } = new FrameResponseHeaders();

So we should return back to FrameResponseHeaders and its base FrameHeaders classes and try to adjust their behavior.

FrameResponseHeaders class uses fast setting of known headers (see AddValueFast above) but stores all other unknown headers in MaybeUnknown field:

protected Dictionary<string, StringValues> MaybeUnknown;

which is initialized as:

MaybeUnknown = new Dictionary<string, StringValues>(StringComparer.OrdinalIgnoreCase);

We could try to bypass fast header setting and add them directly to the MaybeUnknown dictionary. We should however replace the dictionary created with StringComparer.OrdinalIgnoreCase comparer with the default implementation that is case-sensitive.

MaybeUnknown is a protected field and again we can't make Kestrel to use our custom implementation for holding class. That's why we are forced to set this field through reflection.

I've put all this dirty code into extension class over FrameHeaders:

public static class FrameHeadersExtensions
{
    public static void MakeCaseInsensitive(this FrameHeaders target)
    {
        var fieldInfo = GetDictionaryField(target.GetType());
        fieldInfo.SetValue(target, new Dictionary<string, StringValues>());
    }

    public static void AddCaseInsensitiveHeader(this FrameHeaders target, string key, string value)
    {
        var fieldInfo = GetDictionaryField(target.GetType());
        var values = (Dictionary<string, StringValues>)fieldInfo.GetValue(target);
        values.Add(key, value);
    }

    private static FieldInfo GetDictionaryField(Type headersType)
    {
        var fieldInfo = headersType.GetField("MaybeUnknown", BindingFlags.Instance | BindingFlags.NonPublic);
        if (fieldInfo == null)
        {
            throw new InvalidOperationException("Failed to get field info");
        }

        return fieldInfo;
    }
}

MakeCaseInsensitive replaces MaybeUnknown with case-sensitive dictionary. AddCaseInsensitiveHeader adds header directly to MaybeUnknown dictionary bypassing fast header setting.

Remaining part is only to call these methods in appropriate places in the controller:

[Route("api/[controller]")]
public class TestController : Controller
{
    [NonAction]
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        var responseHeaders = (FrameResponseHeaders)HttpContext.Response.Headers;
        responseHeaders.MakeCaseInsensitive();
    }

    // GET api/values
    [HttpGet]
    public string Get()
    {
        var responseHeaders = (FrameResponseHeaders)HttpContext.Response.Headers;
        responseHeaders.AddCaseInsensitiveHeader("Set-Cookie", "Cookies1");
        responseHeaders.AddCaseInsensitiveHeader("SET-COOKIE", "Cookies2");
        return "Hello";
    }
}

Here is result headers set:

enter image description here

Described solution is a very dirty hack. It will work only with Kestrel and things could change with future releases. Everything would be much easier and cleaner if Kestrel fully supports ASP.NET seams. But if you don't have any other choices for this moment, I hope this will help you.

CodeFuller
  • 30,317
  • 3
  • 63
  • 79
1

Thanks @CodeFuller for your prompt and thorough response. However, after digging into Amazon.Lambda.AspNetCoreServer source code, I realized a custom IServer implementation is used instead of Kestrel.

I located the code inside APIGatewayProxyFunction where headers are copied to the response and joined together:

foreach (var kvp in responseFeatures.Headers)
{
    if (kvp.Value.Count == 1)
    {
        response.Headers[kvp.Key] = kvp.Value[0];
    }
    else
    {
        response.Headers[kvp.Key] = string.Join(",", kvp.Value);
    }
    ...
}

But just like Kestrel, this library uses its own implementation of IHttpResponseFeature. It is inside a multi-purpose InvokeFeatures class, which is instantiated directly and cannot be replaced through configuration. However, APIGatewayProxyFunction exposes a few virtual Post* methods to modify some parts of the request/response at different points. Unfortunately, there is no method to intercept the ASP.NET core response just before it is converted into an APIGatewayProxyResponse (something like a PreMarshallResponseFeature maybe?), so the best option I could find was to add some code to PostCreateContext:

var responseFeature = context.HttpContext.Features.Get<IHttpResponseFeature>();
responseFeature.Headers = new MyHeaderDictionary(responseFeature.Headers);

MyHeaderDictionary is a wrapper around IHeaderDictionary where I override the IEnumerator<KeyValuePair<string, StringValues>> GetEnumerator() method:

class MyHeaderDictionary : IHeaderDictionary
{
    private readonly IHeaderDictionary _inner;

    public MyHeaderDictionary(IHeaderDictionary inner)
    {
        _inner = inner;
    }

    public IEnumerator<KeyValuePair<string, StringValues>> GetEnumerator()
    {
        foreach (var kvp in _inner)
        {
            if (kvp.Key.Equals(HeaderNames.SetCookie) && kvp.Value.Count > 1)
            {
                int i = 0;
                foreach (var stringValue in kvp.Value)
                {
                    // Separate values as header names that differ by case
                    yield return new KeyValuePair<string, StringValues>(ModifiedHeaderNames[i], stringValue);
                    i++;
                }
            }
            else
            {
                yield return kvp;
            }
        }
    }

    // Implement all other IHeaderDictionary members as wrappers around _inner
}

This returns different Set-Cookie headers inside the foreach (var kvp in responseFeatures.Headers) block in APIGatewayProxyFunction.

This solution was tested and seems to work so far. No edge cases or performance considerations have been taken into account, though. Suggestions and improvements are welcome.

Axel Zarate
  • 466
  • 4
  • 14