A client error happened on Forget password due to malicious request attempt to a Drupal-based website

Created on 12 June 2024, 7 months ago

The error occurs when the cache is rebuild and the attempt to do the malicious request attempt to a Drupal-based website on user/password page

The error can be replicated by hitting the following URL in the Browser

https://example.com/user/password?name[%23post_render][0]=passthru&name%...

๐Ÿ› Bug report
Status

Active

Version

10.2 โœจ

Component
User systemย  โ†’

Last updated 3 days ago

Created by

๐Ÿ‡ฎ๐Ÿ‡ณIndia mahakjain

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Merge Requests

Comments & Activities

  • Issue created by @mahakjain
  • Status changed to Postponed: needs info 7 months ago
  • What is the error? Add that information to the issue summary.

  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia mahakjain
  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia mahakjain
  • Issue was unassigned.
  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia mahakjain
  • Can you confirm the Drupal response is a 4xx HTTP error type? The network tab of the browser dev tools will show you that. If is is a 4xx type, Drupal is working properly here.

  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia mahakjain
  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia mahakjain
  • "A client error happened" seems to accompany 4xx responses, which should not be cached. I am trying to reproduce this from a new Drupal install.

  • I am able to reproduce the client error, which is expected as this is a 400 error. All that is required is to set /user/password?name[foo][0]=foobar.

    I did the following:

    1. Built a Drupal 10.3.0-RC1 site on http://simplytest.me
    2. Browsed to /user/password?name[foo][0]=foobar.
    3. I noted the "A client error happened" error, and checked that it is a 400 HTTP response.
    4. Browsed to Browsed to /user/password, which works normally. So the response is not cached in a new Drupal installation.

    There must be more steps to reproduce, or I misunderstand the bug report. Please list the exact steps to set up a Drupal site with this behavior, and put those in the issue summary.

  • Status changed to Active 7 months ago
  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia sourav_paul Kolkata

    @cilefen I've checked that issue & successfully reproduced it in various Drupal versions - 10.1.8, 10.2.2, 10.2.6, 10.2.7.

    Attaching SS for reference:

    1. Hitting first time with malicious URL (after clearing the site cache)

    2. hitting the right URL in second time (without clearing the cache)

    Getting this warning on dblog (after hitting malicious URL for the first time)

    I've noticed that not getting this warning in dblog for second time (even after hitting malicious URL so many times)

    I've also checked some headers, where X-Drupal-Cache: HIT & Cache-Control: must-revalidate, no-cache, private .

    Attaching SS of headers:

    It seems like a caching issue...

  • I still can't reproduce this. /user/password is a 200 response, always. I've tested on two disparate platforms, including a new install. Both sites have the "Internal Page Cache" module enabled.

    I am most interested to see the response headers for the first error page. You didn't show those.

  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia sourav_paul Kolkata

    @cilefen, Again I've tested it on fresh D10.2.2 with PHP 8.3

    After clearing the cache, I hit the malicious URL, Sharing headers with dblog for the first error page...

    headers SS:

    dblog SS:

  • Status changed to Postponed: needs info 7 months ago
  • Are there a lot of debugging options enabled? Those headers are not from a default install. What is the nginx configuration? The headers indicate this should not be cached.

  • ๐Ÿ‡บ๐Ÿ‡ธUnited States karlshea Minneapolis ๐Ÿ‡บ๐Ÿ‡ธ

    I got this on a site behind Fastly with the fastly module enabled if that might help, and clearing the caches fixed it.

    I won't be able to really try and reproduce the issue for a couple of days but when I'm back if I find anything else I'll update the issue.

  • ๐Ÿ‡บ๐Ÿ‡ธUnited States karlshea Minneapolis ๐Ÿ‡บ๐Ÿ‡ธ

    I was able to reproduce behind Fastly, response is a 400 both from Fastly and hitting the app server directly.

    Here's the maybe interesting part: /user/password?name[foo][0]=foobar does not trigger the page getting cached, but /user/password?name[%23post_render][0]=passthru does. Maybe because ?name[foo][0]=foobar doesn't redirect? The latter redirects back to the page without any query parameters so I wonder if the redirect is triggering the cache.

    Response headers from Fastly when cached:

    Accept-Ranges: bytes
    Alt-Svc: h3=":443";ma=86400,h3-29=":443";ma=86400,h3-27=":443";ma=86400
    Cache-Control: no-store, no-cache, must-revalidate, max-age=0
    Content-Language: en
    Content-Type: text/html; charset=UTF-8
    Date: Mon, 24 Jun 2024 18:40:41 GMT
    Expires: Sun, 19 Nov 1978 05:00:00 GMT
    Strict-Transport-Security: max-age=300
    X-Cache: MISS, MISS
    X-Cache-Hits: 0, 0
    X-Content-Type-Options: nosniff
    X-Drupal-Dynamic-Cache: HIT
    X-Frame-Options: SAMEORIGIN
    X-Served-By: cache-dfw-kdfw8210044-DFW, cache-chi-klot8100063-CHI
    X-Timer: S1719254442.751705,VS0,VE95
    

    Response headers directly from app server:

    Cache-Control: must-revalidate, no-cache, private
    Connection: keep-alive
    Content-Language: en
    Content-Type: text/html; charset=UTF-8
    Date: Mon, 24 Jun 2024 18:40:19 GMT
    Expires: Sun, 19 Nov 1978 05:00:00 GMT
    Fastly-Drupal-Html: YES
    Server: nginx
    Surrogate-Control: no-store, content="BigPipe/1.0"
    Surrogate-Key: eNqK igJ3 2tce mWTg juT3 F8oS PGx9 3F+3 grmK ojvY axoF eNI2 bpNa XtKW C6oq +QS4 pCAc
    Transfer-Encoding: chunked
    X-Content-Type-Options: nosniff
    X-Drupal-Dynamic-Cache: HIT
    X-Frame-Options: SAMEORIGIN
    X-Generator: Drupal 10 (https://www.drupal.org)
    
  • ๐Ÿ‡บ๐Ÿ‡ธUnited States karlshea Minneapolis ๐Ÿ‡บ๐Ÿ‡ธ

    /user/password?name[foo][0]=foobar

    First request after cache clear:

    HTTP/2 301
    retry-after: 0
    cache-control: max-age=86400
    location: https://xxxx/user/password?name[foo][0]=foobar
    accept-ranges: bytes
    date: Mon, 24 Jun 2024 19:07:31 GMT
    x-served-by: cache-chi-klot8100127-CHI
    x-cache: HIT
    x-cache-hits: 0
    x-timer: S1719256052.788341,VS0,VE0
    strict-transport-security: max-age=300
    content-length: 0
    
    HTTP/2 400
    content-type: text/html; charset=UTF-8
    x-drupal-dynamic-cache: UNCACHEABLE
    content-language: en
    x-content-type-options: nosniff
    x-frame-options: SAMEORIGIN
    expires: Sun, 19 Nov 1978 05:00:00 GMT
    accept-ranges: bytes
    date: Mon, 24 Jun 2024 19:07:32 GMT
    x-served-by: cache-dfw-kdfw8210068-DFW, cache-chi-klot8100138-CHI
    x-cache: MISS, MISS
    x-cache-hits: 0, 0
    x-timer: S1719256052.863280,VS0,VE366
    cache-control: no-store, no-cache, must-revalidate, max-age=0
    strict-transport-security: max-age=300
    alt-svc: h3=":443";ma=86400,h3-29=":443";ma=86400,h3-27=":443";ma=86400
    

    (This is not cached, hitting /user/password works fine)

    /user/password?name[%23post_render][0]=passthru

    First request after cache clear:

    HTTP/2 301
    content-type: text/html; charset=utf-8
    location: https://xxxx/user/password
    x-drupal-route-normalizer: 1
    content-language: en
    x-content-type-options: nosniff
    x-frame-options: SAMEORIGIN
    accept-ranges: bytes
    age: 0
    date: Mon, 24 Jun 2024 19:11:16 GMT
    x-served-by: cache-dfw-kdfw8210117-DFW, cache-chi-klot8100046-CHI
    x-cache: MISS, MISS
    x-cache-hits: 0, 0
    x-timer: S1719256276.102347,VS0,VE88
    cache-control: no-store, no-cache, must-revalidate, max-age=0
    strict-transport-security: max-age=300
    alt-svc: h3=":443";ma=86400,h3-29=":443";ma=86400,h3-27=":443";ma=86400
    content-length: 386
    
    HTTP/2 400
    content-type: text/html; charset=UTF-8
    x-drupal-dynamic-cache: UNCACHEABLE
    content-language: en
    x-content-type-options: nosniff
    x-frame-options: SAMEORIGIN
    expires: Sun, 19 Nov 1978 05:00:00 GMT
    accept-ranges: bytes
    date: Mon, 24 Jun 2024 19:11:16 GMT
    x-served-by: cache-dfw-kdfw8210044-DFW, cache-chi-klot8100046-CHI
    x-cache: MISS, MISS
    x-cache-hits: 0, 0
    x-timer: S1719256276.201582,VS0,VE385
    cache-control: no-store, no-cache, must-revalidate, max-age=0
    strict-transport-security: max-age=300
    alt-svc: h3=":443";ma=86400,h3-29=":443";ma=86400,h3-27=":443";ma=86400
    

    Request to /user/password afterwards:

    HTTP/2 400
    content-type: text/html; charset=UTF-8
    x-drupal-dynamic-cache: HIT
    content-language: en
    x-content-type-options: nosniff
    x-frame-options: SAMEORIGIN
    expires: Sun, 19 Nov 1978 05:00:00 GMT
    accept-ranges: bytes
    date: Mon, 24 Jun 2024 19:12:33 GMT
    x-served-by: cache-dfw-kdfw8210044-DFW, cache-chi-klot8100170-CHI
    x-cache: MISS, MISS
    x-cache-hits: 0, 0
    x-timer: S1719256353.239181,VS0,VE129
    cache-control: no-store, no-cache, must-revalidate, max-age=0
    strict-transport-security: max-age=300
    alt-svc: h3=":443";ma=86400,h3-29=":443";ma=86400,h3-27=":443";ma=86400
    

    Notice the Location header differences, that might be why one is getting cached.

  • Status changed to Active 6 months ago
  • ๐Ÿ‡ญ๐Ÿ‡บHungary mxr576 Hungary

    It seems to me that enough information was provided in the previous comment to continue the investigation.

  • Assigned to mxr576
  • ๐Ÿ‡ญ๐Ÿ‡บHungary mxr576 Hungary

    I also found this Symfony (non-Drupal!) HTTP exception in logs which could be related to the problem - in the past weeks I worked on caching issue and I remember seeing something related to how Symfony's own HTTP exceptions can cause problems with cacheability bubble up - like in this context they do no bubble up url.query_args cache context.

    That is my hunch and I try to persuade that.

  • I guess the query string names actually matter for reproducing. Is that correct?

  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia sourav_paul Kolkata

    yes @cilefen , you are correct...

    It only occurred when we were using "name" query parameter..

  • Issue was unassigned.
  • ๐Ÿ‡ญ๐Ÿ‡บHungary mxr576 Hungary

    Okay, this should be something else...

    $form['name']['#default_value'] = $this->getRequest()->query->get('name'); fails in \Drupal\user\Form\UserPasswordForm::buildForm() after

    /user/password?name[%23post_render][0]=passthru
    

    was opened on /user/password because on that page the "current request" still contains the name query parameter and it is an array, which triggers the Input value "name" contains a non-scalar value. (\Symfony\Component\HttpFoundation\Exception\BadRequestException).

    So how it could happen that a new request to /user/password still returns a query parameter from a previous request?

  • ๐Ÿ‡ญ๐Ÿ‡บHungary mxr576 Hungary

    $form['name']['#default_value'] = \Drupal::service('request_stack')->query->get('name');

    yield a different result, it fails because there is no query... so my assumption that $this->requestStack gets serialized and stored and resurrected.

    It also reveals another problem to me, the user.pass/code> route must deny access when the <code>name query parameter is missing, or handle it leniently.

  • ๐Ÿ‡ญ๐Ÿ‡บHungary mxr576 Hungary
    \Symfony\Component\HttpKernel\EventListener\RouterListener::onKernelRequest()
    \Drupal\Core\Routing\AccessAwareRouter::matchRequest()
    \Drupal\Core\Routing\Router::matchRequest()
    \Drupal\Core\Routing\Router::getInitialRouteCollection()
    \Drupal\Core\Routing\RouteProvider::getRouteCollectionForRequest()
    \Drupal\Core\Routing\RouteProvider::getRouteCollectionCacheId() # DOES NOT CONSIDER QUERY PARAMETERS WHEN GENERATES A CID.
    \Drupal\Core\Routing\RouteProvider::getRouteCollectionForRequest() # modifies $request and overrides query parameters on, WHY??!?!
    

    So this is how first a request to /user/password?name[%23post_render][0]=passthru can cause that a request to user/password afterwards also has a name query parameter...

  • ๐Ÿ‡ญ๐Ÿ‡บHungary mxr576 Hungary

    \Drupal\Core\Routing\RouteProvider::getRouteCollectionCacheId() generates route:[language]=en:/user/password: as CID, notice that name query param is not included in the cid.

  • Status changed to Needs work 6 months ago
  • ๐Ÿ‡ญ๐Ÿ‡บHungary mxr576 Hungary

    Now I think we have repro steps, I even have a draft fix for the problem with a todo.

  • Pipeline finished with Failed
    6 months ago
    Total: 162s
    #216737
  • Pipeline finished with Canceled
    6 months ago
    Total: 38s
    #216738
  • Pipeline finished with Failed
    6 months ago
    Total: 590s
    #216739
  • ๐Ÿ‡ญ๐Ÿ‡บHungary mxr576 Hungary
  • ๐Ÿ‡ญ๐Ÿ‡บHungary mxr576 Hungary

    @cilefen could be right in #10, Drupal 10.3.0+ could have a fix for that already...

    https://git.drupalcode.org/project/drupal/-/commit/fbe023ba6ddbf719ee8d4...

  • Status changed to Closed: outdated 6 months ago
  • ๐Ÿ‡ญ๐Ÿ‡บHungary mxr576 Hungary

    Yep, 10.3.0 has the fix, so the suggested step is update from prior version or grab the fix from the referenced issue.

  • ๐Ÿ‡ง๐Ÿ‡ชBelgium siemen_hermans

    We encountered the exact same issue today on one of our websites that's currently running on Drupal core 10.3.1.
    Clearing the cache however did resolve the issue.

    Sadly enough this does mean that the changes present in Drupal 10.3.1 do not resolve the issue.

  • Status changed to Active 2 months ago
  • ๐Ÿ‡ฒ๐Ÿ‡ฉMoldova marjina-constantin

    I am using Drupal 10.3.5 and the error still occurs.
    Steps to reproduce:

    • Using anonymous user, on cold cache, visit "/user/password?name[%23post_render][0]=passthru"
    • "A client error happened" error message appears, which is expected.
    • Visit "/user/password" without any query parameters and confirm that "A client error happened" still appears.
  • ๐Ÿ‡ฒ๐Ÿ‡ฉMoldova marjina-constantin

    After some investigation, I found that starting with Symfony 6.0, the InputBag::get() method now throws a BadRequestException when attempting to retrieve non-scalar values. In our project, this affects the UserPasswordForm.php file, where InputBag::get() is used to retrieve the default value for the "name" field. When this exception occurs, it gets cached and displayed to all subsequent anonymous requests.

    Symfony's documentation recommends using InputBag::all() to retrieve arrays instead. While we arenโ€™t expecting an array for "name" here, switching from get to all should prevent issues with this specific form. The root cause may be related to a broader query parameter caching issue between requests, but this change will safeguard us in this particular context.

    Regarding security, this change looks safe. The RequestSanitizer class handles potentially unsafe keys in query parameters by removing them from the request object.

    Iโ€™m attaching a patch with this adjustment for review.

  • ๐Ÿ‡ณ๐Ÿ‡ฟNew Zealand quietone

    Changes are made on on 11.x (our main development branch) first, and are then back ported as needed according to our policies.

Production build 0.71.5 2024