Problem/Motivation
\Drupal\Core\EventSubscriber\FinishResponseSubscriber::setResponseCacheable()
guards the Vary: Cookie append with:
if (!$response->hasVary() && !Settings::get('omit_vary_cookie')) { $response->setVary('Cookie', FALSE); }
The intent is "don't stomp existing Vary values." The implementation is
"if anyone has set Vary, skip Cookie entirely." When any other
subscriber has already added a Vary value, hasVary() returns
TRUE and Cookie is silently dropped.
The most common trigger in the wild is asm89/stack-cors. When
cors.config is configured with multiple allowedOrigins
(and typically supportsCredentials: true),
CorsService::configureAllowedOrigin() unconditionally calls
varyHeader($response, 'Origin') on every response — even
non-CORS requests. That is spec-correct (the ACAO header echoes the
requester's Origin, so caches must Vary on it), but it trips Drupal's
guard.
Impact: anonymous cacheable responses (including 403s
on /admin, /node/<id>/edit, and any other
access-denied path) are emitted as
Cache-Control: public, max-age=<cache.page.max_age> with a
Vary that does not include Cookie. Shared caches (Varnish, Akamai,
Cloudfront, etc.) have no signal to segment by session and will serve the
anonymous body to authenticated users. This is an auth-bypass hazard for
any site using multi-origin CORS behind a shared cache.
Related history:
- #3128982
—asm89/stack-cors1.x → 2.x upgrade. Only fixes wildcard
and single-origin configs; the multi-origin branch still adds
Vary: Originunconditionally, by spec design. - #3001809
— closed as duplicate of #3128982; the guard interaction was raised
in comments but never patched.
Steps to reproduce
- Configure
cors.configinservices.ymlwith
two or moreallowedOriginsand
supportsCredentials: true. Example:parameters: cors.config: enabled: true allowedHeaders: ['*'] allowedMethods: ['*'] allowedOrigins: - 'https://example.com' - 'https://preview.example.com' supportsCredentials: true - Rebuild the container.
- As an anonymous user, request any 403 route. E.g.
curl -sSI https://example.com/admin. - Observe the response headers:
HTTP/1.1 403 Forbidden Cache-Control: max-age=43200, public Vary: OriginCookieis absent fromVary. Any shared cache
in front of the origin will now serve this 403 to authenticated users on
subsequent requests to the same URL.
Proposed resolution
Check specifically whether Cookie is already present in the
Vary header, instead of gating on any Vary being set:
// Before if (!$response->hasVary() && !Settings::get('omit_vary_cookie')) { $response->setVary('Cookie', FALSE); } // After if (!in_array('Cookie', $response->getVary(), TRUE) && !Settings::get('omit_vary_cookie')) { $response->setVary('Cookie', FALSE); }
This preserves the original intent (don't duplicate an existing
Cookie value; don't touch responses where the site has opted
out via omit_vary_cookie) while ensuring Cookie is present
whenever any other subscriber has already added an unrelated Vary value.
Remaining tasks
- Review patch.
- Add a unit/kernel test asserting that
Cookieends up in
VaryaftersetResponseCacheable()runs on a
response that already hasVary: Origin. - Confirm the change doesn't regress the
omit_vary_cookie
opt-out path. - Commit.
User interface changes
None.
Introduced terminology
None.
API changes
None. The change is confined to the internal Vary-header composition
logic in FinishResponseSubscriber. Behaviour changes only when
a response already has a non-Cookie Vary value and
omit_vary_cookie is not set — in which case Cookie
is now correctly appended.
Data model changes
None.
Release notes snippet
Anonymous cacheable responses now correctly include Cookie
in their Vary header even when other subscribers (such as the
CORS middleware in multi-origin configurations) have already added an
unrelated Vary value. Previously, Vary: Cookie was silently
dropped in that case, which could cause shared HTTP caches to serve
anonymous responses to authenticated users.
| Comment | File | Size | Author |
|---|---|---|---|
| #3 | 3612557-1.patch | 2.29 KB | jhedstrom |
Issue fork drupal-3612557
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
jhedstromAdding a patch for use with composer.
Comment #4
smustgrave commentedTHanks for reporting! Could you update the MR for main please. We can backport from there.