This issue the backport follow-up of #2983395: user module's flood controls should do better logging - the description below is related to D7 version of user.module.
Problem/Motivation
At present if an attempt to authenticate via the user login form is blocked by flood control, nothing useful is logged.
The form gets an error set on it, but the response is still a 200 which means that access log entries cannot be identified by anyone interested in whether a site is being brute-forced.
e.g. modules/user/user.module (from user_login_authenticate_validate() and user_login_final_validate())
if (!flood_is_allowed('failed_login_attempt_ip', variable_get('user_failed_login_ip_limit', 50), variable_get('user_failed_login_ip_window', 3600))) {
$form_state['flood_control_triggered'] = 'ip';
return;
}
...and then:
if (isset($form_state['flood_control_triggered'])) {
if ($form_state['flood_control_triggered'] == 'user') {
form_set_error('name', format_plural(variable_get('user_failed_login_user_limit', 5), 'Sorry, there has been more than one failed login attempt for this account. It is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', 'Sorry, there have been more than @count failed login attempts for this account. It is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', array('@url' => url('user/password'))));
}
else {
// We did not find a uid, so the limit is IP-based.
form_set_error('name', t('Sorry, too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', array('@url' => url('user/password'))));
}
}
Similarly like in D8, we always get only a form error that is not informative as it returns with a 200.
At present we see something like this when the login form is brute-forced:
==> syslog <==
Aug 2 12:47:29 xenial-php drupal: http://drupal7x.xp|1533214049|user|10.0.3.1|http://drupal7x.xp/user|http://drupal7x.xp/user|0||Login attempt failed for foo.
==> access.log <==
drupal7x.xp 10.0.3.1 - - [02/Aug/2018:12:47:29 +0000] "POST /user HTTP/1.1" 200 2308
..repeated a few times, until flood control kicks in, at which point we just see:
==> access.log <==
drupal7x.xp 10.0.3.1 - - [02/Aug/2018:12:47:39 +0000] "POST /user HTTP/1.1" 200
drupal7x.xp 10.0.3.1 - - [02/Aug/2018:12:47:47 +0000] "POST /user HTTP/1.1" 200
drupal7x.xp 10.0.3.1 - - [02/Aug/2018:12:53:52 +0000] "POST /user HTTP/1.1" 200
Proposed resolution
The referenced D8 issue introduces logging solution for the above cases that in D7 should be implemented by calling watchdog.
Remaining tasks
* Implement logging with similar messages like in the referenced issue.
* Implement tests and review the solution.
User interface changes
New added strings?
API changes
None.
Data model changes
None.
Comment | File | Size | Author |
---|---|---|---|
#39 | 2989985-39.patch | 9.03 KB | mcdruid |
#39 | interdiff-2989985-34-39.txt | 2.14 KB | mcdruid |
Comments
Comment #2
tatarbjI'm attaching a first backported solution from the d8 patch. Currently no tests are implemented in it.
Comment #3
tatarbjI'm attaching a new patch with the backported test updates.
Comment #5
mcdruidComment #6
mcdruidThanks Balazs!
I've updated the IS with log snippets from D7 (which are almost identical anyway).
I think we'll need to change the calls to
form_set_error()
todrupal_set_message()
as when the access denied page is returned, there is no login form displayed - which is probably why tests will fail when they check for the error text. This was the same in the D8 version.Comment #7
tatarbjThe tests are failing because of usage of assertEquals that doesn't exist in d7 used phpunit version. I wouldn't change the logic from form_set_error to drupal_set_message to let user know about the issue to be honest as it could break other things, maybe even other tests that should not be the case of a backported solution.
I'll take a deeper look on why tests are failing today afternoon/evening.
Thanks Drew for the feedback!
Cheers,
Balazs.
Comment #8
tatarbjComment #9
colorfulCoder CreditAttribution: colorfulCoder commentedFound out together with #tatarbj that assertEqual is deprecated in D7. Therefore, the D8 function assertEquals is most likely the function that replaced D7's assertEqual.
Changed assertEquals to assertEqual and will add the patch to this issue to see if the test will succeed this time.
Comment #10
colorfulCoder CreditAttribution: colorfulCoder commentedComment #11
colorfulCoder CreditAttribution: colorfulCoder commentedComment #12
tatarbjComment #13
tatarbjBecause of failed tests my mentee @Merel plans to work on it later!
Thanks!
Comment #14
colorfulCoder CreditAttribution: colorfulCoder as a volunteer commentedThe changes in this patch should fix the unexpected error 200.
Also, according to my mentor, the text for line 38 should be different, so I adjusted that aswell.
Comment #15
colorfulCoder CreditAttribution: colorfulCoder as a volunteer commentedMy mentor suggested I'd make an interdiff.
I followed the tutorial "easy mode" on this page:
https://www.drupal.org/documentation/git/interdiff#comment-9688981
I don't know if I did it right. I don't think so, because it shows more then my last changes.
Comment #16
tatarbjAttaching the interdiff between #9 and #14. I think somehow you had different versions and it caused the disturbance in the force.
Comment #18
colorfulCoder CreditAttribution: colorfulCoder as a volunteer commentedAdded @count to t variables.
Tried to add interdiff again, but again, I don't think it worked correctly. I think it's because I apply the patch with phpStorm because git apply is being a b*tch.
Comment #19
tatarbjThe interdiff is attached now. I think @Emmezali, you miss somewhere an important step from creating interdiff - let's get back to this topic later today in slack to see how you exactly do it and where it produces yours instead of the one that should be :)
Comment #21
colorfulCoder CreditAttribution: colorfulCoder as a volunteer commentedThank you so much for your help, @tatarbj! I appreciate it :)
Comment #22
colorfulCoder CreditAttribution: colorfulCoder as a volunteer commentedIssue wasn't put on "Needs review" due to a Drupal ci error. The testserver was stuck, and even though the tests all succeeded, the build failed, and thus, the testserver put this issue on "Needs work".
Later, @mixologic reran the tests and this time it did succeed. But the issue was not put to "Needs review", so that's what I'm doing right now.
Comment #23
mcdruidI think
drupal_access_denied()
was in slightly the wrong place so it was being called when an incorrect password was supplied.Furthermore, I think we need to call
drupal_exit()
afterwards as otherwise, the form API carries on processing the form submission and we end up with Drupal effectively trying to emit two responses; as reflected by warnings like these in the log:There are other examples of calling
drupal_exit()
afterdrupal_access_denied()
in core, e.g. elsewhere in the user module:https://api.drupal.org/api/drupal/modules%21user%21user.pages.inc/functi... / https://cgit.drupalcode.org/drupal/tree/modules/user/user.pages.inc?h=7....
...and these in the contact module (quite a similar case in that they're both straight after flood control has blocked a submission):
https://api.drupal.org/api/drupal/modules%21contact%21contact.pages.inc/... / https://cgit.drupalcode.org/drupal/tree/modules/contact/contact.pages.in...
https://api.drupal.org/api/drupal/modules%21contact%21contact.pages.inc/... / https://cgit.drupalcode.org/drupal/tree/modules/contact/contact.pages.in...
With this updated patch in place, we get the 403 in the logs as intended but without the "headers already sent" warnings:
Testing it out in the browser by repeatedly giving the wrong password in the /user form, the 403 page displays the flood control message correctly.
However, triggering flood control via the login block on another page seems to result in an infinite loop (AFAICS the form validation happens again when Drupal tries to render the login block on the access denied page)... so we'll need to fix that.
Comment #24
mcdruidAlso looking more closely at the flood control log entry:
...which comes from:
We may be better just using
ip_address()
rather than$form_state['flood_control_user_identifier']
if the IP is all that we want to log.Comment #26
mcdruidI think the test failures were a result of moving
drupal_access_denied()
.I don't think the tests should actually be checking for a 403 when flood control has not been triggered.
This new patch should resolve that.
However, I think even if tests pass, there's still a big problem in that if Drupal tries to render a login form (e.g. the login block) on the access denied page, we never get as far as the
drupal_exit()
and we have an infinite loop.What's happening is drupal_deliver_html_page(MENU_ACCESS_DENIED) is called and that renders a 403 page:
...and if the login block is included on that page, validation happens again and we end up back at user_login_final_validate() again.
So we need to rethink this...
Perhaps it's as simple as... instead of calling
drupal_access_denied()
and trying to exit, just set the 403 header and let the form processing finish. The 403 response is really what we're trying to achieve.One problem with that approach is that a site with a custom 403 page set up will not have that invoked in this case...
Submitting a patch which should just fix the previous test failures, then I'll work on making the outlined change (i.e. emit a 403 but don't call
drupal_access_denied()
).Comment #28
mcdruidNot sure what happened there; I've tried to queue a retest.
In the meantime, here's a patch which doesn't call
drupal_access_denied()
in order to avoid the infinite loop. Instead it adds the 403 header.This seems to work AFAICS.
We could also add another call to watchdog for consistency i.e. reproduce this:
...from
drupal_deliver_html_page()
.That would ensure that anyone monitoring for those "access denied" watchdog messages would pick these up. Otherwise, there may only be the 403s in the access log. On the other hand, perhaps that's sufficient, and we're already calling watchdog to record the flood control blocking the login attempt.
This patch also tweaks those calls to watchdog to remove
$form_state['flood_control_user_identifier']
and simply useip_address()
.Comment #29
tatarbjComment #30
mcdruidI've updated this backport to add a new hook to emulate the events we're adding in the D8/9 version.
This also adds tests for the new hook.
We'd want a Change Record for this new hook along similar lines to https://www.drupal.org/node/3067148
Modules could use the new hook to do things like send a notification when a user account is blocked because of excessive failed login attempts, or add an IP to firewall rule, for example.
Comment #31
paulocsI'll provide a change record for it
Comment #32
paulocsJust created the change record.
Please review it.
Comment #33
mcdruidGreat, thanks @paulocs - I've made a couple of small tweaks to the CR but I think it's pretty much ready.
One more thing we need to do is add an entry to
modules/user/user.api.php
for the newhook_user_flood_control()
.Setting to NW for that.
Comment #34
mcdruidI've added
hook_user_flood_control()
tomodules/user/user.api.php
as mentioned.In doing so, I also realised that the user module could use this hook itself to do the logging - which is pretty much how this works in D9:
https://git.drupalcode.org/project/drupal/-/blob/9.1.x/core/modules/user...
So you could argue that there's now some duplication in the tests as we're specifically testing the hook and checking for the log entries which will have come from an implementation of the hook also. However, the implementation could change and it's seldom a bad thing to add more test coverage.
Comment #35
mcdruidNote to self: trailing whitespace in the user.api.php entry, which could be fixed on commit.
Comment #36
mcdruidComment #37
Fabianx CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting commentednit - wrong white space added
----
It should be possible to opt-out of the default logging behavior at run-time however else when someone is using DB logging they can use a DOS attempt via the flood logging mechanism.
Comment #38
Fabianx CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting commentedPer mcdruid in D9 it's already possible to opt-out via settings, so let's port that here as well.
Comment #39
mcdruidGood catch!
Added in an opt-out variable similar to the D9 implementation.
Comment #41
mcdruidFixed (with a couple of tweaks to comments that I'd added in the last few patches).
Thanks everyone that contributed; it's great to get this in - we started work on it in Drupal Dev Days Lisbon 2018!