Problem/Motivation
In Symfony 2.8.13 and 3.1.5, the \Symfony\Component\HttpFoundation\Request::isMethodSafe() method has been changed to return true for 'OPTIONS' and 'TRACE' HTTP methods in addition to 'GET' and 'HEAD'. This causes \Drupal\Core\PageCache\RequestPolicy\CommandLineOrUnsafeMethod::check() to pass for OPTIONS requests, and can allow some responses to be erroneously returned from the page cache. More specifically, a cached GET response for the same URL will be returned in response to the OPTIONS request, which breaks CORS preflight handshakes.
Proposed resolution
Modify \Drupal\Core\PageCache\RequestPolicy\CommandLineOrUnsafeMethod::check() to explicitly specify the HTTP methods that may be cached, instead of relying on the Symfony library's isMethodSafe() method which varies between versions.
Remaining tasks
None
Original report by Lennard Westerveld
[Problem/Motivation]
I was working with REST on D8 and enabled cors.config in services.yml (see configuration below).
What i noticed that the Options request get cached with the result of the GET request so when you its the first call everything is working fine but the next call isn't working because it returns the result without the Headers: Access-Control-Allow-*.
Also you can see at the response headers that there was a HIT on the cache
While figuring out it seems that it is the class Asm89\Stack\Cors the test it i added to Cors::handle "\Drupal::service('page_cache_kill_switch')->trigger();" and everything starting to work fine.
[Proposed resolution]
Adding \Drupal::service('page_cache_kill_switch')->trigger(); to Cors::handle (maybe is there a better way to disable cache on specific page?)
[Remaining tasks]
- Solution / Patch
- Determine if its a caching system bug or rewrite of the Cors middleware to opt-out of caching
Configuration i used
cors.config:
enabled: true
# Specify allowed headers, like 'x-allowed-header'.
allowedHeaders: ['X-API-KEY', 'ACCEPT', 'Content-Type']
# Specify allowed request methods, specify ['*'] to allow all possible ones.
allowedMethods: ['GET']
# Configure requests allowed from specific origins.
allowedOrigins: ['*']
# Sets the Access-Control-Expose-Headers header.
exposedHeaders: false
# Sets the Access-Control-Max-Age header.
maxAge: false
# Sets the Access-Control-Allow-Credentials header.
supportsCredentials: false
Curl example:
curl 'http://192.168.99.100/node/3?_format=json' -X OPTIONS -H 'Pragma: no-cache' -H 'Access-Control-Request-Method: GET' -H 'Origin: http://localhost:3000' -H 'Accept-Encoding: gzip, deflate, sdch' -H 'Accept-Language: en-US,en;q=0.8,nl;q=0.6' -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.71 Safari/537.36' -H 'Accept: */*' -H 'Cache-Control: no-cache' -H 'Referer: http://localhost:3000/home' -H 'Connection: keep-alive' -H 'DNT: 1' -H 'Access-Control-Request-Headers: content-type, x-api-key' --compressed
PHP: 7.0.6
Drupal version tested on: 8.2.1
| Comment | File | Size | Author |
|---|---|---|---|
| #20 | d83_options_request_for-2823687-20.patch | 2.52 KB | slasher13 |
| #16 | options_reuest_for-2823687-16.patch | 2.51 KB | slasher13 |
Comments
Comment #2
hampercm commentedReproduced with 8.2.2 on PHP 5.6.19 browsing from Chrome 54.
Escalating this to Major as it potentially breaks decoupled systems. Also, as OPTIONS requests are not cacheable (and thus should never be retrieved from cache) there is potential to conflict with the HTTP/1.1 specs: https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
From stepping through the code, it appears that the page_cache module is returning the cached response for the GET request at the same URL, instead of the OPTIONS response. The OPTIONS response is never actually cached from what I can tell, but rather the wrong response is being returned.
\Drupal\Core\PageCache\RequestPolicy\CommandLineOrUnsafeMethod::check()should be returning DENY if the request will produce a response that should not be cached due to its HTTP METHOD. From that class's PHPDocs:However, the code in check() uses
\Symfony\Component\HttpFoundation\Request::isMethodSafe()to check the HTTP method, but that method returns true if the HTTP method is 'GET', 'HEAD', 'OPTIONS', or 'TRACE'. As a result, the check() can pass and allow the Drupal bootstrap to continue on to load the response from the page cache. The cached response for a previous GET request at the same URL is then pulled from the page cache, and erroneously returned as the response to the OPTIONS request.It would seem the correct method to use in the check() is actually
\Symfony\Component\HttpFoundation\Request::isMethodCacheable(), which only returns true if the HTTP method is "GET' or 'HEAD'.Comment #3
hampercm commentedComment #4
hampercm commentedDigging deeper, it looks like this bug cropped up as a result of my doing a recent install using the drupal-composer project, which caused Composer to use the latest version of the Symfony libraries (2.8.13) that have a different implementation of isMethodSafe() than what is included in the official tarball release (2.8.4). This library version update can also happen if a
composer updateis run on a Drupal codebase.Comment #5
hampercm commentedComment #6
hampercm commentedComment #7
hampercm commentedUpdating issue summary based on my findings. Apologies if I'm overstepping, but this seemed like the cleanest way to tackle this issue.
A workaround for this currently is to downgrade your site's Symfony libraries to 2.8.12 or earlier (Drupal Core uses 2.8.4 out of the box).
Comment #8
lennard westerveldNice research hampercm.
Next week i think i have some time if needed to maybe create a patch for this :)
Comment #9
hampercm commentedThis patch will ensure correct caching behavior for all existing 2.8.x (and 3.1.x) versions of the Symfony library. Test coverage already provided by
\Drupal\Tests\Core\PageCache\CommandLineOrUnsafeMethodTestComment #10
wim leersSo Symfony simply broke BC in a patch release. WTF!?
Introduced at https://github.com/symfony/symfony/commit/140460707201665bec2d3e625e93b0... — https://github.com/symfony/symfony/pull/19321 (July 9, 2016)
https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html specifically listsIt is correct, but it's still a BC break for a patch release.HEADandGETas the only safe methods.OPTIONSandTRACEare idempotent methods. That's not the same. Therefore Symfony is clearly violating the HTTP spec.(They BTW also added
Request::isMethodIdempotent(): https://github.com/symfony/symfony/commit/44df6a4677f1c36549103406cf2101... — https://github.com/symfony/symfony/pull/19322.)Fun fact: Symfony also discovered this is a problem, and that is why they introduced
Request::isMethodCacheable()shortly after:https://github.com/symfony/symfony/commit/c43de7f21a587649bb42ca08bce9ad... — https://github.com/symfony/symfony/pull/20205 (October 13, 2016)
When pointing out that this broke BC, the answer was simply . That's fine for a major release. Maybe even a minor. But definitely not a patch release.
This should be reverted from the Symfony 2.8.x branch. Creating an issue for that.
Comment #11
wim leersSymfony issue: https://github.com/symfony/symfony/issues/20562.
Comment #12
wim leersSimilar issue at #2770673: Prevent Composer from installing symfony/http-foundation to 2.8.8 & 2.8.9 due to a BC break.
Comment #13
wim leersDuplicate of this issue at #2776367: Drupal\Tests\Core\PageCache\CommandLineOrUnsafeMethodTest::testHttpMethod Failed asserting that null is identical to 'deny'..
Comment #14
wim leersComment #15
klausiI don't think they are going to revert this upstream in Symfony. Can we update Symfony and use Request::isMethodCacheable() instead in CommandLineOrUnsafeMethod.php?
Comment #16
slasher13- use Request::isMethodCacheable()
- change constraint in composer.json
- update symfony/http-foundation
Comment #17
klausiThanks, exactly what I had in mind!
Comment #18
klausidefinitely 8.3.x material, not sure about 8.2.x.
Comment #19
wim leersThe 8.3.x patch did not apply, yet it's only RTBC'd for 8.3.x … that doesn't work :P Needs a reroll.
Also, this can also affect 8.2.x installations, because
composer.jsonspecifies~2.8. What about them? How do we guarantee it doesn't break for them? That's the bigger issue.Comment #20
slasher13re-roll
Why can't we do it like symfony and fix it in a patch release?
The other way is to use #9 for 8.2.x
Comment #21
wim leersWe can do that. But Symfony violated semver. So now Drupal core and Drupal contrib modules need to be updated to require a certain patch release of Symfony HTTP Foundation and a certain patch release of Drupal core, respectively.
Comment #22
klausiSymfony did not violate Semver because they did not break their API. The behavior of a method changed because it was buggy before and did not adhere to the RFC.
Semver only guarantees that function, method, class signatures etc. do not change. Of course you are allowed to change subtle behavior details, that is the point of patch releases. Otherwise you would not be able to ever fix bugs, because the fix changes behavior in some way.
Comment #23
wim leersBug fixes are fine, definitely. But `Request::isMethodCacheable()` is unquestionably a new API. Semantically, for many use cases,
isMethodCacheable()is the replacement forisMethodSafe(). If that happens in a patch release (like it did here), then that is a BC break.Comment #24
klausiI think they aim for maximum compatibility by providing the new method as early as possible. I guess they don't consider API additions BC breaks.
Anyway, patch looks good and is the right approach for at least 8.3.x.
Comment #25
wim leersAPI additions aren't BC breaks. But effectively renaming a method is a BC break.
Patch looks fine for 8.3.x for sure! :)
Curious what core committers think we should do for 8.2.x.
Comment #26
wim leersHaving had this discussion here has allowed me to explain this more clearly. See https://github.com/symfony/symfony/issues/20562#issuecomment-262474462.
Verbatim copy of that comment:
Comment #27
catchI think we should explicitly check GET/HEAD in 8.2.x and not use either method. This is the only option we would have had if isMethodSafe() had been like this in the first place. The fact that Symfony broke HttpCache which was relying on the same behaviour shows it should not have happened in a patch release. Not that we don't accidentally break things in patch releases too, but we usually own up to it.
For 8.3.x I didn't want to postpone #2712647: Update Symfony components to ~3.2 on this issue or this issue on that one, but since they're both RTBC at the same time I'd like to give the 3.1 update a chance to get committed - it explicitly fixes this bug too now. If this needs to go into 8.2.x, I'd also consider committing that quick fix to 8.3.x since there's no library change involved.
Comment #28
wim leers+1. Then it doesn't matter which Symfony version you use.
Amen.
Exactly, that's what I argued in #26.
Comment #29
hampercm commented@catch and @wimleers the patch from #9 does just such an explicit check against GET/HEAD, and could be applied to 8.2.x.
Comment #30
catchNote Niklas Grekas opened https://github.com/symfony/symfony/pull/20602 and https://github.com/symfony/symfony/pull/20603 against Symfony to revert the bc break and deprecate instead.
Comment #31
slasher13Symfony has reverted the BC break:
https://github.com/symfony/symfony/pull/20602
Comment #32
wim leersNice.
Comment #33
catchSo given at the moment this only affects sites installed via composer, I think we can wait for a new Symfony release to get tagged rather than working around.
However we should switch to isMethodCacheable() in 8.3.x where we can. That might end up getting rolled into #2712647: Update Symfony components to ~3.2 but moving to CNW for now.
Comment #34
wim leersThis was rolled into #2712647: Update Symfony components to ~3.2, which got committed earlier today.