@@ -2341,3 +2341,145 @@ async def test_no_none_in_log_method_or_url(self, caplog):
23412341 assert " None" not in line .split (">>>" )[1 ], (
23422342 f"Found 'None' in log line: { line } "
23432343 )
2344+
2345+
2346+ # -------------------------------------------------------------------
2347+ # Security regression tests
2348+ # -------------------------------------------------------------------
2349+
2350+
2351+ class TestRedirectUriValidation :
2352+ """Security tests: redirect URI must match the exact expected prefix.
2353+
2354+ Before the fix, the check was:
2355+ location.startswith("msal") and "://auth" in location
2356+
2357+ This was too broad. A compromised server could send:
2358+ - msal{CLIENT_ID}://auth.evil.com?code=... (domain mismatch)
2359+ - msalEVIL://auth?code=... (wrong client ID)
2360+
2361+ Both would match the old check but must NOT match the new one,
2362+ which requires startswith(f"msal{CLIENT_ID}://auth") followed by
2363+ '?' or '/'.
2364+ """
2365+
2366+ async def test_correct_redirect_uri_accepted (self ):
2367+ """The legitimate redirect URI is still accepted."""
2368+ login_resp = _make_mock_response (
2369+ status = 200 ,
2370+ text = SAMPLE_B2C_HTML ,
2371+ url = SAMPLE_PAGE_URL ,
2372+ )
2373+ post_resp = _make_mock_response (status = 200 , text = '{"status":"200"}' )
2374+ confirmed_resp = _make_mock_response (
2375+ status = 302 ,
2376+ headers = {"Location" : REDIRECT_URL },
2377+ )
2378+
2379+ session = _make_mock_session (
2380+ get = MagicMock (side_effect = [login_resp , confirmed_resp ]),
2381+ post = MagicMock (return_value = post_resp ),
2382+ )
2383+
2384+ with _patch_session (session ):
2385+ result = await b2c_login_with_credentials (AUTH_URI , "user@test.com" , "pass" )
2386+
2387+ assert result == REDIRECT_URL
2388+
2389+ async def test_correct_redirect_uri_with_trailing_slash_accepted (self ):
2390+ """msal{CLIENT_ID}://auth/?... (with trailing slash) is also valid."""
2391+ redirect_with_slash = (
2392+ f"msal{ _CLIENT_ID } ://auth/?code=test-auth-code-123&state=test-state"
2393+ )
2394+ login_resp = _make_mock_response (
2395+ status = 200 ,
2396+ text = SAMPLE_B2C_HTML ,
2397+ url = SAMPLE_PAGE_URL ,
2398+ )
2399+ post_resp = _make_mock_response (status = 200 , text = '{"status":"200"}' )
2400+ confirmed_resp = _make_mock_response (
2401+ status = 302 ,
2402+ headers = {"Location" : redirect_with_slash },
2403+ )
2404+
2405+ session = _make_mock_session (
2406+ get = MagicMock (side_effect = [login_resp , confirmed_resp ]),
2407+ post = MagicMock (return_value = post_resp ),
2408+ )
2409+
2410+ with _patch_session (session ):
2411+ result = await b2c_login_with_credentials (AUTH_URI , "user@test.com" , "pass" )
2412+
2413+ assert result == redirect_with_slash
2414+
2415+ async def test_attacker_subdomain_redirect_not_accepted (self ):
2416+ """msal{CLIENT_ID}://auth.evil.com?... must NOT be accepted.
2417+
2418+ The old check '://auth' in location would accept this because
2419+ the substring '://auth' appears in '://auth.evil.com'.
2420+ The new check requires startswith(f'msal{CLIENT_ID}://auth')
2421+ followed by '?' or '/', which the malicious URL fails because
2422+ '.' is neither.
2423+ """
2424+ malicious_redirect = f"msal{ _CLIENT_ID } ://auth.evil.com?code=stolen&state=xyz"
2425+ login_resp = _make_mock_response (
2426+ status = 200 ,
2427+ text = SAMPLE_B2C_HTML ,
2428+ url = SAMPLE_PAGE_URL ,
2429+ )
2430+ post_resp = _make_mock_response (status = 200 , text = '{"status":"200"}' )
2431+ # The malicious redirect followed by a 200 with no real redirect
2432+ malicious_resp = _make_mock_response (
2433+ status = 302 ,
2434+ headers = {"Location" : malicious_redirect },
2435+ )
2436+ final_200 = _make_mock_response (
2437+ status = 200 ,
2438+ text = "<html>no redirect here</html>" ,
2439+ )
2440+
2441+ session = _make_mock_session (
2442+ get = MagicMock (side_effect = [login_resp , malicious_resp , final_200 ]),
2443+ post = MagicMock (return_value = post_resp ),
2444+ )
2445+
2446+ with (
2447+ _patch_session (session ),
2448+ pytest .raises (
2449+ AuthenticationError ,
2450+ match = "Reached 200 response without finding redirect URL" ,
2451+ ),
2452+ ):
2453+ await b2c_login_with_credentials (AUTH_URI , "user@test.com" , "pass" )
2454+
2455+ async def test_wrong_client_id_redirect_not_accepted (self ):
2456+ """msalWRONG_CLIENT_ID://auth?... must NOT be accepted."""
2457+ malicious_redirect = "msalDEADBEEF-0000-0000-0000-000000000000://auth?code=x"
2458+ login_resp = _make_mock_response (
2459+ status = 200 ,
2460+ text = SAMPLE_B2C_HTML ,
2461+ url = SAMPLE_PAGE_URL ,
2462+ )
2463+ post_resp = _make_mock_response (status = 200 , text = '{"status":"200"}' )
2464+ malicious_resp = _make_mock_response (
2465+ status = 302 ,
2466+ headers = {"Location" : malicious_redirect },
2467+ )
2468+ final_200 = _make_mock_response (
2469+ status = 200 ,
2470+ text = "<html>no redirect here</html>" ,
2471+ )
2472+
2473+ session = _make_mock_session (
2474+ get = MagicMock (side_effect = [login_resp , malicious_resp , final_200 ]),
2475+ post = MagicMock (return_value = post_resp ),
2476+ )
2477+
2478+ with (
2479+ _patch_session (session ),
2480+ pytest .raises (
2481+ AuthenticationError ,
2482+ match = "Reached 200 response without finding redirect URL" ,
2483+ ),
2484+ ):
2485+ await b2c_login_with_credentials (AUTH_URI , "user@test.com" , "pass" )
0 commit comments