diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index d6b05e066..b47bb8240 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -26,13 +26,25 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No if not www_auth_header: return None - # Pattern matches: field_name="value" or field_name=value (unquoted) - pattern = rf'{field_name}=(?:"([^"]+)"|([^\s,]+))' - match = re.search(pattern, www_auth_header) - - if match: - # Return quoted value if present, otherwise unquoted value - return match.group(1) or match.group(2) + header_value = www_auth_header.strip() + auth_params = re.sub(r"^\S+\s+", "", header_value, count=1) + + # Split auth-params on commas, but only outside quoted values. + in_quotes = False + param_start = 0 + for index, char in enumerate(auth_params + ","): + if char == '"': + in_quotes = not in_quotes + elif char == "," and not in_quotes: + param = auth_params[param_start:index].strip() + param_start = index + 1 + + name, separator, value = param.partition("=") + if separator and name.strip() == field_name: + match = re.match(r'\s*(?:"([^"]+)"|([^\s,]+))', value) + if match: + # Return quoted value if present, otherwise unquoted value + return match.group(1) or match.group(2) return None diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 1ec38ccf6..4acaf49e4 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -1997,6 +1997,16 @@ class TestWWWAuthenticate: "scope", "admin:write resource:read", ), + ( + 'Bearer error_scope="decoy", scope="read write"', + "scope", + "read write", + ), + ( + 'Bearer realm="api, scope=decoy", scope="read write"', + "scope", + "read write", + ), ( 'Bearer realm="api", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource", ' 'error="insufficient_scope"', @@ -2047,6 +2057,19 @@ def test_extract_field_from_www_auth_valid_cases( # Header without requested field ('Bearer realm="api", error="insufficient_scope"', "scope", "no scope parameter"), ('Bearer realm="api", scope="read write"', "resource_metadata", "no resource_metadata parameter"), + ('Bearer custom_scope="leaked"', "scope", "substring auth-param should not match scope"), + ('Bearer realm="api scope=leaked"', "scope", "auth-param inside quoted value should not match scope"), + ('Bearer realm="api, scope=leaked"', "scope", "auth-param after quoted comma should not match scope"), + ( + 'Bearer x_resource_metadata="https://decoy.example.com"', + "resource_metadata", + "substring auth-param should not match resource_metadata", + ), + ( + 'Bearer realm="api, resource_metadata=https://decoy.example.com"', + "resource_metadata", + "auth-param after quoted comma should not match resource_metadata", + ), # Malformed field (empty value) ("Bearer scope=", "scope", "malformed scope parameter"), ("Bearer resource_metadata=", "resource_metadata", "malformed resource_metadata parameter"),