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
Make responses cacheable by only using dynamic origins when required #70
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This still checks the request's Origin
header here:
stack-cors/src/Asm89/Stack/Cors.php
Line 48 in b1bcef5
if (!$this->cors->isCorsRequest($request)) { |
By accessing the
Origin
header, all requests need to varied by it.
src/Asm89/Stack/CorsService.php
Outdated
} else { | ||
$response->headers->set('Vary', $response->headers->get('Vary') . ', Origin'); | ||
$response->headers->set('Access-Control-Allow-Origin', implode(', ', $this->options['allowedOrigins'])); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could be empty.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right, so what would expected behavior be then? If both the patterns (case above) and the origins array is empty, no Origin will be considered valid. So don't add the header?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, I missed this, but Access-Control-Allow-Origin
only supports a single value. It can either be *
or a single origin.
If allowedOrigins
and allowedOriginsPatterns
are empty, this header should not be added.
if allowedOriginsPatterns
is empty, then we know there is a possability of a static value, but the header can only be static if there is either a single value in allowedOrigins
and that value is not *
OR the value is *
and supportsCredentials
is false
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've updated it:
- Wildcard + no credentials -> just '*', no Vary
- Just 1 origin allowed, no wildcard/patterns -> Just the static value, no Vary
- Always add Vary + check if Origin is allowed. Add origin if allowed, skip otherwise.
You are right, I've removed that check. |
I've updated it based on your comments. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One small problem with all requests.
Do you want to handle the cachability of preflight requests in a seperate PR?
According to https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers it should respond with an error if the method is not supported. Does that mean we should add a Vary header? (Like https://stackoverflow.com/a/45081016/444215) |
Also, this would mean we would need to intercept ALL Options requests and add the headers. Not sure what the impact would be there. |
src/Asm89/Stack/CorsService.php
Outdated
if ($this->options['supportsCredentials']) { | ||
$allowMethods = strtoupper($request->headers->get('Access-Control-Request-Method')); | ||
$this->varyHeader('Access-Control-Request-Method'); | ||
$maxAge = -1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if either of these are required, or that maxAge is implied to be different on different headers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It should follow the same logic as the main request I think. Basically make it static if if it can be, otherwise Vary it by the header being accessed.
Yes, in this case I think it's unnecessary to change the maxAge and add a Vary. The browser will know if the request header has changed (according to the Vary
) and ignore the cache.
That's a rather odd line, I looked at the fetch spec, and it says:
So it wont make the request if the method is not included in the list or it's a
I thought about that, and I implemented that in the PR I made. I think it's safe to do that because it would intercept all uncached Options requests. In the same way that we are now (with these changes) intercepting all uncached main requests. Though I am not certain how things happen when the method isn't impelemented in the app, I assume it throws... |
I looked at https://github.com/expressjs/cors/blob/master/lib/index.js for a bit and think it's fine to skip the 403/405 errors and just add the headers for the preflight. I skipped max-age to -1 because I'm not really sure if thats good or bad. But easy enough for anyone to configure if they need to. I've made allowed-methods always static by just a list of methods instead of * when credentials are used. Also refactored a bit to avoid duplication and more logical names. But it's quite a bit more changes than I'd hoped.. |
@asm89 Do you have an opinion about removing the validity checks? |
@@ -72,134 +67,159 @@ public function isPreflightRequest(Request $request) | |||
&& $request->headers->has('Access-Control-Request-Method'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling this method requires adding Vary: Origin, Access-Control-Request-Method
, but doing so will add it to every request.
To avoid that, maybe refactor to something like this:
// Cache is already varied by method.
if ($request->getMethod() !== 'OPTIONS) {
return false;
}
// @TODO Get the response if we are **not** taking it over.
$this->addVary($response, 'Origin, Access-Control-Request-Method');
return $this->isCorsRequest($request) && $request->headers->has('Access-Control-Request-Method');
which will only Vary
the OPTIONS
requests (which is way better, imho).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
or even better would be to change this in Cors::handle()
like this:
if ($request->getMethod() === 'OPTIONS') {
if ($this->cors->isPreflightRequest($request)) {
$response = $this->cors->handlePreflightRequest($request);
} else {
$response = $this->app->handle($request, $type, $catch);
$this->cors->addActualRequestHeaders($response, $request);
}
// @TODO May need to dedupe the headers.
$this->cors->addVary($response, 'Origin, Access-Control-Request-Method');
return $response;
}
This way it's either taken over, or passed, but regardless, get the Vary
header added.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Basically we would either need to Vary origin for ALL responses, or remove the cors check everywhere?
Maybe we could just add Vary origin in the middleware always, and remove the check if the methods header is set?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Basically we would either need to Vary origin for ALL responses, or remove the cors check everywhere?
So what happens if a request gets sent like this:
OPTIONS https://example.com/
And then later:
OPTIONS https://example.com/
Origin: https://example.org/
And then later:
OPTIONS https://example.com/
Origin: https://example.org/
Access-Control-Request-Method: PATCH
Without adding Vary: Origin, Access-Control-Request-Method
to all of the OPTIONS
requests, the first would be cached, the second and third responses would result in the same response as the first and the last would be missing the Preflight headers. :(
Maybe we could just add Vary origin in the middleware always, and remove the check if the methods header is set?
I think you may have that backwards, whatever we are checking for first needs to be in the Vary
for all requests. If the thing we fail on is Access-Control-Request-Headers
instead of Origin
then we would need to add:
Vary: Access-Control-Request-Method
to all OPTIONS
requests (which would make the above example pass). We could use that header as a way to determine if it's a CORS request or not. This would result in fewer cache variants than varying by Origin
(which isn't always necessary anyways). If we then proceed to the origin check, we would need to append Vary: Origin
to all OPTIONS requests where Vary: Access-Control-Request-Method
is set.
The reason I would put Vary: Origin, Access-Control-Request-Method
with the current Origin check is because of the way the condition is ordered:
stack-cors/src/Asm89/Stack/CorsService.php
Lines 70 to 72 in df153ce
return $this->isCorsRequest($request) | |
&& $request->getMethod() === 'OPTIONS' | |
&& $request->headers->has('Access-Control-Request-Method'); |
The first thing that is checked is the
Origin
rather than Access-Control-Request-Method
. If we were to first check for Access-Control-Request-Method
then we could simply add: Vary: Access-Control-Request-Method
to all OPTIONS
requests (and only add the Vary: Origin
depending on the config like the main requests).
Though I don't think varying by the Access-Control-Request-Method
is always necessary (as we've seen from the main request).
Like the primary requests, there isn't technically a need to check that it's a CORS request at all, as I've shown here:
https://github.com/davidbarratt/stack-cors/blob/22e70f5d51903f731e4608b7061e208d56f83db4/src/Asm89/Stack/Cors.php#L48-L61
That removes the CORS check completely, but the downside is, is that the application may not handle the request, if it doesn't we should provide a (static, if possible) response anyways.
The upside is that it increases cachability of the preflight because (depending on the config) you could get a completely static response that could be cached for a long time.
The only downside is if the OPTIONS
request throws an error (rather than an simply not implemented) it would be hard to see the error. Though I suppose we could append a custom header with the error message or something so it's not completely opaque.
Sorry for taking so long at this. I'm just not really sure about adding the CORS headers on each request (eg overhead). On the one hand I kind of understand the issue with caching, but also it could be a bit of and edge case. |
I think the overhead only exists on unchached requests. Ironically, by increasing the cachability, you're decreasing the overhead, not increasing it. If the site/app doesn't cache it's requests that's a completely different problem and I think is outside the scope of this library. |
Yeah but probably most API's are dynamic, right? At least for the Preflighted requests (not simple GET requests) |
What makes you think it's being used on an API? In Drupal it's used on all requests (including HTML). |
And I would say most REST APIs are cached, which is why REST was chosen over GraphQL (or some other query based API) |
Regardless, I have a feeling adding the headers takes nanoseconds to do so we're talking about a nano optomization on uncached requests. Making it more cachable skips the operation of the entire request more often. |
I think in that scenario, the config should be Again, I think the "overhead" is effectively non-existant, but if you're really concerned about it, I would either always modify it, or never modify it, there isn't really an inbetween where you sometimes modify it since that is useless in the caching case and also useless in the non-caching case. |
I've removed it in #73 Only thing I'm not 100% sure about, it the OPTIONS request. Should we hijack the options request? Can we rely on all frameworks to handle OPTIONS properly? Some library explicitly require to send 204 + no content to avoid problems. |
Using this library at scale, I think it would be more helpful to have a config like |
I mean if their app is implementing But, if that is a concern, we could Vary all |
So like this? #74 |
Exactly like that. :) |
If a simple origin check works, leave it to the browser, instead of changing the Origin ourselves.
Fixes #65