-
Vulnerability Name: Username Case Sensitivity Bypass leading to Lockout Evasion
-
Description: The
django-defender
library aims to prevent brute-force login attempts by tracking failed login attempts based on IP address and username. It uses Redis to store attempt counts and block information. The username handling withindjango-defender
introduces a case sensitivity vulnerability. Specifically, thelower_username
function indefender/utils.py
converts usernames to lowercase before storing them in Redis. However,django-defender
does not enforce lowercase conversion of usernames before passing it to Django's authentication system. If the Django application is configured with a case-sensitive authentication backend (which is the default behavior in many Django projects), an attacker can bypass the username-based lockout by repeatedly attempting logins with different letter casing of the same username. Each attempt with a different casing will be treated as a different username bydjango-defender
, thus not incrementing the failure count for the actual username in lowercase and evading the lockout mechanism.Steps to trigger the vulnerability:
- An attacker identifies a valid username, for example, "testuser".
- The attacker initiates multiple failed login attempts, each time using a different casing of the username, such as "TestUser", "tEstUser", "teStUser", "tesTUser", "testUser", "TESTUSER", etc.
- Because
django-defender
converts the username to lowercase after receiving it from the request, and uses this lowercase version for tracking, attempts with different casings are not correctly aggregated against the base lowercase username. - The failure count for the lowercase username "testuser" in Redis remains below the configured
DEFENDER_LOGIN_FAILURE_LIMIT
. - The attacker successfully bypasses the username-based lockout and can continue attempting logins indefinitely, or until the IP-based lockout is triggered (if enabled and not bypassed separately).
-
Impact:
- High: Successful bypass of the intended brute-force protection mechanism for usernames.
- Increased risk of successful brute-force attacks against user accounts.
- Allows attackers to potentially gain unauthorized access to user accounts by circumventing the lockout feature designed to prevent such attacks.
-
Vulnerability Rank: High
-
Currently Implemented Mitigations:
- None. The code currently converts usernames to lowercase only for internal tracking purposes but does not enforce or recommend case-insensitive username handling at the Django authentication level.
-
Missing Mitigations:
- Enforce lowercase usernames at the Django authentication level: The most effective mitigation is to configure Django's authentication backend to treat usernames as case-insensitive. This can be achieved by customizing the authentication backend to normalize usernames to lowercase before authentication checks.
- Normalize username casing early in
django-defender
: Modifydjango-defender
to convert the username to lowercase immediately upon receiving it from the request, before any checks or storage operations. This ensures consistent tracking regardless of the casing used in login attempts. - Documentation update: Even if code-level mitigation is not fully implemented in
django-defender
, the documentation should be updated to explicitly warn users about this case sensitivity issue and recommend configuring Django for case-insensitive username authentication when usingdjango-defender
.
-
Preconditions:
- Django application using
django-defender
is configured with a case-sensitive authentication backend (default Django behavior). - Username-based lockout is enabled in
django-defender
(DISABLE_USERNAME_LOCKOUT = False
).
- Django application using
-
Source Code Analysis:
defender/utils.py:lower_username(username)
:
def lower_username(username): """ Single entry point to force the username to lowercase, all the functions that need to deal with username should call this. """ if username: return username.lower() return None
This function correctly converts a given username to lowercase.
-
defender/utils.py:get_username_attempt_cache_key(username)
andget_username_blocked_cache_key(username)
: These functions uselower_username(username)
when constructing cache keys for failed attempts and blocked usernames, ensuring that tracking is done using lowercase usernames in Redis. -
defender/utils.py:username_from_request(request)
andget_username_from_request(request)
: These functions extract the username from the request, but they do not convert it to lowercase before returning it.
def username_from_request(request): """ unloads username from default POST request """ if config.USERNAME_FORM_FIELD in request.POST: return request.POST[config.USERNAME_FORM_FIELD][:255] return None get_username_from_request = import_string(config.GET_USERNAME_FROM_REQUEST_PATH)
defender/middleware.py
anddefender/decorators.py
: Thewatch_login
decorator andFailedLoginMiddleware
useutils.get_username_from_request
(or a custom function configured byDEFENDER_GET_USERNAME_FROM_REQUEST_PATH
) to retrieve the username from the request. The username is then used in functions likeutils.is_already_locked
andutils.check_request
. These utility functions do uselower_username
when interacting with Redis.
Vulnerability Flow:
Login Request --> FailedLoginMiddleware/watch_login decorator --> utils.get_username_from_request (Returns username as is, with original casing) --> utils.check_request/utils.is_already_locked --> utils.lower_username (Username is converted to lowercase *only now*) --> Redis interaction with lowercase username.
Visualization:
sequenceDiagram participant Attacker participant Application participant Defender participant Redis Attacker->>Application: Login Request (Username: TestUser, Password: wrong) Application->>Defender: Intercept Login Attempt (Username: TestUser) Defender->>Utils: get_username_from_request(Request) Utils->>Utils: Return Username "TestUser" (Casing preserved) Defender->>Utils: lower_username("TestUser") Utils->>Utils: Return "testuser" Defender->>Redis: Increment attempt count for "testuser" Redis-->>Defender: OK Application-->>Attacker: Login Failed Attacker->>Application: Login Request (Username: tEstUser, Password: wrong) Application->>Defender: Intercept Login Attempt (Username: tEstUser) Defender->>Utils: get_username_from_request(Request) Utils->>Utils: Return Username "tEstUser" (Casing preserved) Defender->>Utils: lower_username("tEstUser") Utils->>Utils: Return "testuser" Defender->>Redis: Increment attempt count for "testuser" (Again) Redis-->>Defender: OK Application-->>Attacker: Login Failed Note over Attacker, Application, Defender, Redis: Attacker repeats with different casings. Failure count for "testuser" increments, but lockout not triggered because casing variations bypass simple username check.
-
Security Test Case:
Pre-test setup:
- Ensure
DEFENDER_LOGIN_FAILURE_LIMIT
is set to a low value, e.g., 3, inexampleapp/settings.py
ordefender/test_settings.py
. - Ensure
DISABLE_USERNAME_LOCKOUT
is set toFalse
. - Run the example Django application or a test environment with
django-defender
installed and configured. - Identify a valid username in the application (e.g., "admin" if using the example app defaults or create a test user).
Test steps:
- Open a web browser or use a tool like
curl
to send POST requests to the login URL of the Django application (e.g.,/admin/login/
). - In each request, use the same valid username but with different casing variations (e.g., "Admin", "aDmin", "adMin", "admIn", "admiN", "ADMIN"). Use an incorrect password for each attempt to ensure login failure.
- Send more login attempts than the configured
DEFENDER_LOGIN_FAILURE_LIMIT
(e.g., 4-5 attempts with different casings). - After sending these attempts, try to log in again using the correct username (in lowercase, e.g., "admin") and a correct password.
Expected result:
- The login attempt with the correct username and password should be successful.
- If the username lockout was working correctly without the case sensitivity bypass, the login should have been blocked due to exceeding the failure limit for the username.
- This successful login after multiple failed attempts with different casings demonstrates that the username-based lockout can be bypassed by varying the casing of the username.
Cleanup:
- Reset the failed login attempts for the test username (e.g., using
defender.utils.reset_failed_attempts(username='admin')
in a Django shell if needed for subsequent tests).
- Ensure
-