From 8efaba3d28860ba52f4667ccd31699cc9774c159 Mon Sep 17 00:00:00 2001 From: rumaisa a Date: Wed, 28 Oct 2026 20:50:00 +0530 Subject: [PATCH] Keep ROPC password hashes out of http_cache ThrottledHttpClient keys cached HTTP 400 token responses by an unsalted SHA-256 of the request body. For the password grant, the password is the only input in that body an attacker does not already know, so a persisted http_cache allows an offline dictionary attack on it. ROPC returns 400 with the correct password whenever MFA or consent is required. Skip the 400 cache for the password grant, as is already done for device flow. 429/5xx/Retry-After throttling for ROPC is unchanged. --- msal/throttled_http_client.py | 4 ++++ tests/test_throttled_http_client.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/msal/throttled_http_client.py b/msal/throttled_http_client.py index 8cabe261..eddcc9bb 100644 --- a/msal/throttled_http_client.py +++ b/msal/throttled_http_client.py @@ -164,6 +164,10 @@ def __init__(self, *args, default_throttle_time=None, **kwargs): isinstance(kwargs.get("data"), dict) and kwargs["data"].get("grant_type") == DEVICE_AUTH_GRANT ) + and not( # Exclude ROPC, otherwise its key would be a password hash + isinstance(kwargs.get("data"), dict) + and kwargs["data"].get("grant_type") == "password" + ) and RetryAfterParser.FIELD_NAME_LOWER not in set( # Otherwise leave it to the Retry-After decorator h.lower() for h in _get_headers(result)) else 0, diff --git a/tests/test_throttled_http_client.py b/tests/test_throttled_http_client.py index f8fa6551..97b63ec6 100644 --- a/tests/test_throttled_http_client.py +++ b/tests/test_throttled_http_client.py @@ -250,6 +250,18 @@ def test_device_flow_retry_should_not_be_cached(self): logger.debug(http_cache) self.assertNotEqual(resp1.text, resp2.text, "Should return a new response") + def test_ropc_error_should_not_be_cached(self): + http_cache = {} + http_client = DummyHttpClient(status_code=400) + http_client = ThrottledHttpClient(http_client, http_cache=http_cache) + data = {"grant_type": "password", "username": "user", "password": "secret"} + resp1 = http_client.post("https://example.com", data=data) + resp2 = http_client.post("https://example.com", data=data) + logger.debug(http_cache) + self.assertNotEqual(resp1.text, resp2.text, "Should return a new response") + self.assertEqual({}, http_cache, + "A key derived from the password should not be stored in http_cache") + def test_throttled_http_client_should_provide_close(self): http_client = DummyHttpClient(status_code=200) http_client = ThrottledHttpClient(http_client)