Compare commits

...

1 Commits

Author SHA1 Message Date
0xbyt4
e929b66c1a fix(security): PKCE verifier leak, OAuth refresh Content-Type, tool_choice prefix
1. PKCE code_verifier was used as OAuth state parameter, leaking the
   PKCE secret in the authorization URL (browser history, proxy logs,
   Referer headers). Now uses a separate random value for state.

2. refresh_hermes_oauth_token sent application/json but RFC 6749
   requires application/x-www-form-urlencoded for token endpoints.
   Matched to _refresh_oauth_token which already used the correct format.

3. When is_oauth=True, tool names get mcp_ prefix but tool_choice
   name did not, causing Anthropic API rejection (name mismatch).
   Now prefixes tool_choice name to match tool definitions.
2026-03-17 10:32:45 -07:00
2 changed files with 70 additions and 5 deletions

View File

@@ -477,7 +477,12 @@ def run_hermes_oauth_login() -> Optional[str]:
import time
import webbrowser
import secrets as _secrets
verifier, challenge = _generate_pkce()
# Use a separate random value for state (CSRF protection).
# The code_verifier is the PKCE secret and must NEVER appear in the URL
# (browser history, proxy logs, Referer headers would leak it).
oauth_state = _secrets.token_urlsafe(16)
# Build authorization URL
params = {
@@ -488,7 +493,7 @@ def run_hermes_oauth_login() -> Optional[str]:
"scope": _OAUTH_SCOPES,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": verifier,
"state": oauth_state,
}
from urllib.parse import urlencode
auth_url = f"https://claude.ai/oauth/authorize?{urlencode(params)}"
@@ -615,7 +620,8 @@ def refresh_hermes_oauth_token() -> Optional[str]:
return None
try:
data = json.dumps({
import urllib.parse
data = urllib.parse.urlencode({
"grant_type": "refresh_token",
"refresh_token": creds["refreshToken"],
"client_id": _OAUTH_CLIENT_ID,
@@ -625,7 +631,7 @@ def refresh_hermes_oauth_token() -> Optional[str]:
_OAUTH_TOKEN_URL,
data=data,
headers={
"Content-Type": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": f"claude-cli/{_CLAUDE_CODE_VERSION} (external, cli)",
},
method="POST",
@@ -1056,8 +1062,9 @@ def build_anthropic_kwargs(
# Anthropic has no tool_choice "none" — omit tools entirely to prevent use
kwargs.pop("tools", None)
elif isinstance(tool_choice, str):
# Specific tool name
kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}
# Specific tool name — must match the (possibly prefixed) tool definition
tc_name = (_MCP_TOOL_PREFIX + tool_choice) if is_oauth and not tool_choice.startswith(_MCP_TOOL_PREFIX) else tool_choice
kwargs["tool_choice"] = {"type": "tool", "name": tc_name}
# Map reasoning_config to Anthropic's thinking parameter.
# Claude 4.6 models use adaptive thinking + output_config.effort.

View File

@@ -969,3 +969,61 @@ class TestToolChoice:
tool_choice="search",
)
assert kwargs["tool_choice"] == {"type": "tool", "name": "search"}
# ---------------------------------------------------------------------------
# Security bug regression tests
# ---------------------------------------------------------------------------
class TestPKCESecurityBugs:
"""PKCE OAuth flow must not leak code_verifier via URL state parameter."""
def test_auth_url_does_not_contain_verifier(self):
"""The authorization URL must not contain the PKCE code_verifier.
The verifier is the PKCE secret — exposing it in the URL (via state param,
browser history, proxy logs) defeats PKCE entirely."""
import inspect
from agent.anthropic_adapter import run_hermes_oauth_login
source = inspect.getsource(run_hermes_oauth_login)
# The current code does "state": verifier — this is the bug
assert '"state": verifier' not in source, \
"PKCE code_verifier must not be used as OAuth state parameter — it leaks the secret in the URL"
class TestRefreshContentType:
"""Hermes OAuth refresh must use form-urlencoded per RFC 6749."""
def test_hermes_refresh_uses_form_urlencoded(self):
"""refresh_hermes_oauth_token must use application/x-www-form-urlencoded, not JSON."""
import inspect
from agent.anthropic_adapter import refresh_hermes_oauth_token
source = inspect.getsource(refresh_hermes_oauth_token)
# RFC 6749 Section 4.1.3 requires form-urlencoded for token endpoint
assert "application/x-www-form-urlencoded" in source, \
"Token refresh must use application/x-www-form-urlencoded per OAuth RFC 6749"
assert "application/json" not in source, \
"Token refresh must NOT use application/json for the token endpoint"
class TestToolChoiceOAuthPrefix:
"""When is_oauth=True, tool_choice name must get mcp_ prefix like tools do."""
_DUMMY_TOOL = [{"type": "function", "function": {"name": "search", "description": "s", "parameters": {"type": "object", "properties": {}}}}]
def test_specific_tool_choice_gets_mcp_prefix_with_oauth(self):
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "Hi"}],
tools=self._DUMMY_TOOL,
max_tokens=4096,
reasoning_config=None,
tool_choice="search",
is_oauth=True,
)
# Tools are prefixed with mcp_
tool_names = [t["name"] for t in kwargs["tools"]]
assert all(n.startswith("mcp_") for n in tool_names)
# tool_choice must also be prefixed to match
assert kwargs["tool_choice"]["name"] == "mcp_search", \
"tool_choice name must be mcp_-prefixed when is_oauth=True to match tool definitions"