Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi-value header routing support #1494

Closed
jmezach opened this issue Jan 5, 2022 · 15 comments · Fixed by #1639
Closed

Multi-value header routing support #1494

jmezach opened this issue Jan 5, 2022 · 15 comments · Fixed by #1639
Assignees
Labels
Type: Enhancement New feature or request
Milestone

Comments

@jmezach
Copy link
Contributor

jmezach commented Jan 5, 2022

Describe the bug

We've been looking at a very weird issue in YARP's routing almost the entire day and we still haven't quite pinpointed yet what the problem is, but we can consistently reproduce the issue in our test environments.

The problem stems from the fact that we have two routes, which are mostly identical. Here is route1:

{
   "RouteId": "route1",
   "Match": {
     "Path": "/api/{**catch-all}",             
   },
   "ClusterId": "api",
   "CorsPolicy": "default",
   "Transforms": [
     {
       "PathRemovePrefix": "/api"
     },
     {
       "X-Forwarded": "Off"
     }
   ]
}

And route 2:

{
  "RouteId": "route2",
  "Match": {
    "Path": "/api/{**catch-all}",
    "Headers": [
       {
         "Name": "Cookie",
         "Values": [
           ".AspNetCore.Cookies"
         ],
         "Mode": "Contains"
        }
      ]
    },
    "ClusterId": "api",
    "AuthorizationPolicy": "CookieAuthentication",
    "CorsPolicy": "default",
    "Transforms": [
       {
         "PathRemovePrefix": "/api"
       },
       {
         "X-Forwarded": "Off"
       }
    ]
}

As you can see both routes are nearly identical, but route2 has an additional match based on Headers. This has been working fine for us so far while running on Windows and mostly using HTTP/1.1. We are now trying to migrate this to run on Linux containers on Kubernetes with HTTP/2 and that's where we are seeing something interesting. In particular route2 never seems to be chosen at least not while sending requests from Chrome and Edge. Sending a request with cURL using the exact same headers though seems to work just fine, which is what makes this such a weird issue.

For example, here is the output of the HttpRequestLogging middleware for a request done using Chrome:

{
    "@t": "2022-01-05T14:12:16.3714830Z",
    "@m": "Request:\nProtocol: HTTP/2\nMethod: GET\nScheme: https\nPathBase: \nPath: /api/user\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\nHost: services.apollo.zeus.corp\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36\n:method: [Redacted]\nAccept-Encoding: gzip, deflate, br\nAccept-Language: nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7\nCache-Control: max-age=0\nCookie: .AspNetCore.Cookies=chunks-2,.AspNetCore.CookiesC1=<sensitive-value>,.AspNetCore.CookiesC2=<sensitive-value>\nUpgrade-Insecure-Requests: [Redacted]\nsec-ch-ua: [Redacted]\nsec-ch-ua-mobile: [Redacted]\nsec-ch-ua-platform: [Redacted]\nsec-fetch-site: [Redacted]\nsec-fetch-mode: [Redacted]\nsec-fetch-user: [Redacted]\nsec-fetch-dest: [Redacted]",
    "@i": "893de8da",
    "Protocol": "HTTP/2",
    "Method": "GET",
    "Scheme": "https",
    "PathBase": "",
    "Path": "/api/user",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
    "Host": "services.apollo.zeus.corp",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36",
    ":method": "[Redacted]",
    "Accept-Encoding": "gzip, deflate, br",
    "Accept-Language": "nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7",
    "Cache-Control": "max-age=0",
    "Cookie": ".AspNetCore.Cookies=chunks-2,.AspNetCore.CookiesC1=<sensitive-value>,.AspNetCore.CookiesC2=<sensitive-value>",
    "Upgrade-Insecure-Requests": "[Redacted]",
    "sec-ch-ua": "[Redacted]",
    "sec-ch-ua-mobile": "[Redacted]",
    "sec-ch-ua-platform": "[Redacted]",
    "sec-fetch-site": "[Redacted]",
    "sec-fetch-mode": "[Redacted]",
    "sec-fetch-user": "[Redacted]",
    "sec-fetch-dest": "[Redacted]",
    "HttpRequestLog": "Request:\nProtocol: HTTP/2\nMethod: GET\nScheme: https\nPathBase: \nPath: /api/user\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\nHost: services.apollo.zeus.corp\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36\n:method: [Redacted]\nAccept-Encoding: gzip, deflate, br\nAccept-Language: nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7\nCache-Control: max-age=0\nCookie: .AspNetCore.Cookies=chunks-2,.AspNetCore.CookiesC1=<sensitive-value>,.AspNetCore.CookiesC2=<sensitive-value>\nUpgrade-Insecure-Requests: [Redacted]\nsec-ch-ua: [Redacted]\nsec-ch-ua-mobile: [Redacted]\nsec-ch-ua-platform: [Redacted]\nsec-fetch-site: [Redacted]\nsec-fetch-mode: [Redacted]\nsec-fetch-user: [Redacted]\nsec-fetch-dest: [Redacted]",
    "EventId": {
        "Id": 1,
        "Name": "RequestLog"
    },
    "SourceContext": "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware",
    "RequestId": "0HMEGAFLMA6A6:00000001",
    "RequestPath": "/api/user",
    "ConnectionId": "0HMEGAFLMA6A6",
    "MachineName": "apigateway-f8dcddfd6-lc2gn",
    "ProcessId": 1,
    "ThreadId": 33,
    "LogicalServiceName": "API Gateway"
}

Here is the same request done using cURL (by copying the request from Chrome Developer Tools as a cURL command):

{
    "@t": "2022-01-05T14:10:13.6241996Z",
    "@m": "Request:\nProtocol: HTTP/2\nMethod: GET\nScheme: https\nPathBase: \nPath: /api/user\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\nHost: services.apollo.zeus.corp\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36\n:method: [Redacted]\nAccept-Encoding: deflate, gzip, br, zstd\nAccept-Language: nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7\nCache-Control: max-age=0\nCookie: .AspNetCore.Cookies=chunks-2,.AspNetCore.CookiesC1=<sensitive-value>,.AspNetCore.CookiesC2=<sensitive-value>\nUpgrade-Insecure-Requests: [Redacted]\nauthority: [Redacted]\nsec-ch-ua-mobile: [Redacted]\nsec-ch-ua-platform: [Redacted]\nsec-fetch-site: [Redacted]\nsec-fetch-mode: [Redacted]\nsec-fetch-user: [Redacted]\nsec-fetch-dest: [Redacted]",
    "@i": "893de8da",
    "Protocol": "HTTP/2",
    "Method": "GET",
    "Scheme": "https",
    "PathBase": "",
    "Path": "/api/user",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
    "Host": "services.apollo.zeus.corp",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36",
    ":method": "[Redacted]",
    "Accept-Encoding": "deflate, gzip, br, zstd",
    "Accept-Language": "nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7",
    "Cache-Control": "max-age=0",
    "Cookie": ".AspNetCore.Cookies=chunks-2,.AspNetCore.CookiesC1=<sensitive-value>,.AspNetCore.CookiesC2=<sensitive-value>",
    "Upgrade-Insecure-Requests": "[Redacted]",
    "authority": "[Redacted]",
    "sec-ch-ua-mobile": "[Redacted]",
    "sec-ch-ua-platform": "[Redacted]",
    "sec-fetch-site": "[Redacted]",
    "sec-fetch-mode": "[Redacted]",
    "sec-fetch-user": "[Redacted]",
    "sec-fetch-dest": "[Redacted]",
    "HttpRequestLog": "Request:\nProtocol: HTTP/2\nMethod: GET\nScheme: https\nPathBase: \nPath: /api/user\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\nHost: services.apollo.zeus.corp\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36\n:method: [Redacted]\nAccept-Encoding: deflate, gzip, br, zstd\nAccept-Language: nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7\nCache-Control: max-age=0\nCookie: .AspNetCore.Cookies=chunks-2,.AspNetCore.CookiesC1=<sensitive-value>,.AspNetCore.CookiesC2=<sensitive-value>\nUpgrade-Insecure-Requests: [Redacted]\nauthority: [Redacted]\nsec-ch-ua-mobile: [Redacted]\nsec-ch-ua-platform: [Redacted]\nsec-fetch-site: [Redacted]\nsec-fetch-mode: [Redacted]\nsec-fetch-user: [Redacted]\nsec-fetch-dest: [Redacted]",
    "EventId": {
        "Id": 1,
        "Name": "RequestLog"
    },
    "SourceContext": "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware",
    "RequestId": "0HMEGAGUUK0CE:00000001",
    "RequestPath": "/api/user",
    "ConnectionId": "0HMEGAGUUK0CE",
    "MachineName": "apigateway-f8dcddfd6-bfznx",
    "ProcessId": 1,
    "ThreadId": 41,
    "LogicalServiceName": "API Gateway"
}

For that first request route1 is chosen as the endpoint, while for the second request route2 is chosen. So far we haven't been able to figure out what makes these requests different.

To Reproduce

We've tried to create a small repro, but so far haven't been able to. Even with a minimal .NET 6 project with YARP added to it and putting that into a Docker container and doing a request with cURL works just fine.

Further technical details

  • Include the version of the packages you are using: 1.0.0 on .NET 6.0.1
  • The platform (Linux/macOS/Windows): Linux (containers, alpine)
@jmezach jmezach added the Type: Bug Something isn't working label Jan 5, 2022
@jmezach
Copy link
Contributor Author

jmezach commented Jan 5, 2022

To clarify the only real difference seems to be in the client being used, ie. cURL vs Chrome. Other than that both requests seem to be identical.

@karelz
Copy link
Member

karelz commented Jan 5, 2022

Based on offline discussion, @jmezach was able to reproduce the same client-dependent weirdness when there are only these 2 routes in the config.
@Tratcher is there any debug logging they could enable to root cause it?

@jmezach
Copy link
Contributor Author

jmezach commented Jan 6, 2022

We've enabled Trace logging for the Microsoft source to see if that gives us any additional pointers to go on. Here is the logging for a Chrome requests:

06 Jan 2022 10:00:29.351
 Request matched endpoint '"route1"'
06 Jan 2022 10:00:29.348
 Endpoint '"route1"' with route pattern '"/api/{**catch-all}"' is valid for the request path '"/api/user"'
06 Jan 2022 10:00:29.348
 Endpoint '"route2"' with route pattern '"/api/{**catch-all}"' is valid for the request path '"/api/user"'
06 Jan 2022 10:00:29.341
 2 candidate(s) found for the request path '"/api/user"'
06 Jan 2022 10:00:29.311
 All hosts are allowed.
06 Jan 2022 10:00:29.311
 Request starting HTTP/2 GET https://services.apollo.zeus.corp/api/user - -

And the cURL requests give me the following logging:

06 Jan 2022 10:11:05.168
 Request matched endpoint '"route2"'
06 Jan 2022 10:11:05.168
 Endpoint '"route1"' with route pattern '"/api/{**catch-all}"' is valid for the request path '"/api/user"'
06 Jan 2022 10:11:05.168
 Endpoint '"route2"' with route pattern '"/api/{**catch-all}"' is valid for the request path '"/api/user"'
06 Jan 2022 10:11:05.167
 2 candidate(s) found for the request path '"/api/user"'
06 Jan 2022 10:11:05.162
 All hosts are allowed.
06 Jan 2022 10:11:05.162
 Request starting HTTP/2 GET https://services.apollo.zeus.corp/api/user - -

As you can see the logs are nearly identical, except that in the first scenario it selects route1 and in the second scenario it takes route2. To be honest, this is starting to drive my crazy ;).

@jmezach
Copy link
Contributor Author

jmezach commented Jan 6, 2022

As this thing was driving me crazy we ended up building our own Yarp version with some additional logging in the HeaderMatcherPolicy class. As it turns out it seems that for the Chrome requests the value of the Cookie header is treated as multiple values, while for our cURL requests this does not seem to be the case.

Specifically we've added an else branch to this else if and added a log statement there. For our Chrome requests we hit that else branch, while for the cURL requests we do not.

This is probably not related to YARP, but more of an issue downstream in the runtime somewhere. Maybe @davidfowl has a clue of what's going on here?

@karelz
Copy link
Member

karelz commented Jan 6, 2022

That is weird, I wonder what is the difference of the request on the wire. Can you check it out in Wireshark or Fiddler easily?

@karelz
Copy link
Member

karelz commented Jan 6, 2022

Triage: It seems the problem is in format of multiple values -- either 1 header with comma-separated values or multiple headers.
We do not currently support multi-value headers matching in the policy regardless of the format. However, this shows it is not clear and super confusing.
@Tratcher will add his comments about matching Cookie headers on top of that (similar to query matching).

We should likely figure out design for multi-value headers matching. We will decide on 1.1 vs. later based on complexity.

@Tratcher
Copy link
Member

Tratcher commented Jan 6, 2022

You're hitting two issues:

  1. Kestrel should merge HTTP/2 request Cookie headers dotnet/aspnetcore#26461, Cookie is supposed to be a single header, but HTTP/2 allows clients to split it and Kestrel doesn't put it back together.
  2. HeaderMatcherPolicy isn't designed for multi-value headers (either multiple headers or comma separated values).
    // Multi-value headers are not supported.
    // Note a single entry may also contain multiple values, we don't distinguish, we only match on the whole header.
    else if (requestHeaderValues.Count == 1)

1 will likely be addressed in 7.0. 2 would be expanded functionality in YARP. The interesting question there is what would it take?

Exists should already work.

if (StringValues.IsNullOrEmpty(requestHeaderValues))
{
// A non-empty value is required for a match.
}
else if (matcher.Mode == HeaderMatchMode.Exists)
{
// We were asked to match as long as the header exists, and it *does* exist
matched = true;
}

For each of the others I think we'd want to enumerate each header, as well as split on commas, and then apply the match condition to the individual values. This is a lot slower and more expensive, so it might be opt-in.

@jmezach as for workarounds you have two options.
A) use middleware to reformat the cookie headers to a single header
B) use a custom matcher policy specific for cookies. E.g. something like QueryParameterMatcherPolicy that uses the parsed cookie collection rather than sniffing the raw headers.
https://github.com/microsoft/reverse-proxy/blob/main/src/ReverseProxy/Routing/QueryParameterMatcherPolicy.cs

@Tratcher Tratcher removed their assignment Jan 6, 2022
@Tratcher Tratcher changed the title Weird routing issue in a very specific scenario Multi-value header routing support Jan 6, 2022
@Tratcher Tratcher added Type: Enhancement New feature or request and removed Type: Bug Something isn't working labels Jan 6, 2022
@jmezach
Copy link
Contributor Author

jmezach commented Jan 7, 2022

We've added a middleware which basically boils down to the following:

if (context.Request.Headers.TryGetValue(HeaderNames.Cookie, out var requestHeaderValues))
{
    // Only do something if there is more than one value
    if (requestHeaderValues.Count > 1)
    {
        // This might not be the most efficient way of doing this, but let's see if it works first
        context.Request.Headers[HeaderNames.Cookie] = requestHeaderValues.ToString();
    }
}

That seems to resolve our immediate issue for now. We've added some #if's so that when we'll eventually upgrade to .NET 7 we'll be triggered to re-evaluate whether this is still needed or not.

One thing we were wondering though is whether this is the most optimal way to do this. Obviously we're allocating a string here. Maybe there's a more performant way of doing this that we're not aware of?

@Tratcher Thanks for the quick response by the way.

@Tratcher
Copy link
Member

Tratcher commented Jan 7, 2022

Note cookies should be ; separated, not the default , separated that StringValues.ToString() gives you.
https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1

The perf should be OK unless you're expecting many/large cookies. You could switch to a StringBuilder.

@karelz karelz added this to the YARP 1.1.0 milestone Jan 20, 2022
@MihaZupan
Copy link
Member

The behavior that makes some intuitive sense to me would such that multiple request values act as OR.

  • Exact: Matches if any of the request values exactly matches any of the expected values
  • Prefix: Matches if any of the request values starts with any of the expected values
  • Contains: Matches if any of the request values contain any of the expected values
  • NotContains: Matches if none of the request values contain none of the expected values

Preudo-code:

var tryMatchMode = matchMode == NotContains ? Contains : matchMode;

foreach (var requestValue in requestHeaderValues)
{
    foreach (var expectedValue in expectedHeaderValues)
    {
        if (TryMatch(requestValue, expectedValue, tryMatchMode, comparison))
        {
            return matchMode != NotContains;
        }
    }
}
return matchMode == NotContains;

Examples:

  • Exact, Foo, new[] { "abc", "def" }
    would match

    Foo: ab
    Foo: def
    
  • Contains, Foo, new[] { "def", "ab" }
    would match

    Foo: bar
    Foo: abc
    

Note: everything here most likely applies to QueryParameterMatcherPolicy as well, as the logic is practically the same.

@Tratcher thoughts?

@Tratcher
Copy link
Member

Foo: ab, def is equivalent to Foo: ab \r\nFoo: def but requires additional parsing. Since that parsing comes at some overhead cost, matching against multi-value headers should be opt-in. How would we indicate that in the API?

This might not apply to queries since those come pre-parsed.

@MihaZupan
Copy link
Member

Yeah, you are right, we should treat these two the same.
That most likely also means keeping in mind which separator to split on based on the name.

I'll try it out and see what kind of a perf penalty this actually is so we can determine if having the flag is worth the effort.

@Tratcher
Copy link
Member

That most likely also means keeping in mind which separator to split on based on the name.

IHeaderDictionary.GetCommaSeparatedValues handles the necessary parsing, including comma splitting and quoted strings. The only header that should need to be special cased is Cookie, if we wanted to support that.
https://github.com/dotnet/aspnetcore/blob/ea2b6d0af43aeaba39243d59d96eedd85089a1a2/src/Http/Http.Abstractions/src/Extensions/HeaderDictionaryExtensions.cs#L42

@MihaZupan
Copy link
Member

The only header that should need to be special cased is Cookie, if we wanted to support that.

Cookies is what I had in mind.

Looking at the signature string[] GetCommaSeparatedValues, this doesn't sound ideal - we should be able to avoid substring allocations here without too much trouble.

@Tratcher
Copy link
Member

Looking at the signature string[] GetCommaSeparatedValues, this doesn't sound ideal - we should be able to avoid substring allocations here without too much trouble.

Yeah, but you might want to use it to write the first version and tests before optimizing 😁.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Type: Enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants