From d7d6f607878d521a6f69edb98efe5cea42208a44 Mon Sep 17 00:00:00 2001 From: VoidChecksum Date: Sat, 30 May 2026 13:15:11 +0200 Subject: [PATCH] fix(tools/web): http_request works on Python 3.13; flag https jku/x5u MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http_request drove its coroutine via `asyncio.get_event_loop().run_until_complete(_do())`. On the pinned Python 3.13 runtime this raises in both reachable contexts: with no current loop it errors ("There is no current event loop"), and inside the agent's running loop it errors ("This event loop is already running"). Every call therefore returned an opaque RuntimeError, making the tool dead on the supported runtime. Introduce a `_run_coro` helper that runs a coroutine safely regardless of context: it uses `asyncio.run` directly when no loop is running, and otherwise drives the coroutine on a fresh single-worker thread that owns its own loop (via ThreadPoolExecutor + asyncio.run). http_request now calls this helper, preserving the existing try/except and return shape. http_request is the only tool in this module using the broken pattern. The JWT jku key-confusion check only fired when `jku` did not start with `https://`, so the dominant attack — `jku=https://attacker.com/jwks.json` — was treated as safe, and `x5u` was parsed but never checked. The host is attacker-influenced regardless of scheme, so emit a finding whenever `jku` or `x5u` is present, and add the symmetric `x5u` check. Tests: http_request returns a parsed result without a RuntimeError from both a plain sync context and from inside a running event loop (and a direct `_run_coro` guard while a loop is active); JWT parsing flags both `https` jku and `https` x5u headers. --- .../decepticon/decepticon/tools/web/jwt.py | 6 +- .../decepticon/decepticon/tools/web/tools.py | 29 ++++++-- .../decepticon/tests/unit/web/test_jwt.py | 20 ++++++ .../decepticon/tests/unit/web/test_tools.py | 68 +++++++++++++++++++ 4 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 packages/decepticon/tests/unit/web/test_tools.py diff --git a/packages/decepticon/decepticon/tools/web/jwt.py b/packages/decepticon/decepticon/tools/web/jwt.py index 9b13967a..a9cb6cbd 100644 --- a/packages/decepticon/decepticon/tools/web/jwt.py +++ b/packages/decepticon/decepticon/tools/web/jwt.py @@ -187,8 +187,10 @@ def parse_token(token: str) -> JWTToken: tok.findings.append("alg=HS256 with jku header — key confusion candidate") if header.kid and ("../" in header.kid or "%2f" in header.kid.lower()): tok.findings.append("kid contains path traversal — file read / SQLi candidate") - if header.jku and not header.jku.startswith("https://"): - tok.findings.append("jku over non-HTTPS or attacker-controlled host — key confusion") + if header.jku: + tok.findings.append("jku points at an attacker-influenced host — key confusion") + if header.x5u: + tok.findings.append("x5u points at an attacker-influenced host — key confusion") if claims.expired: tok.findings.append("token already expired — test whether server enforces exp") if claims.exp is None: diff --git a/packages/decepticon/decepticon/tools/web/tools.py b/packages/decepticon/decepticon/tools/web/tools.py index 88f093f2..137eaa7d 100644 --- a/packages/decepticon/decepticon/tools/web/tools.py +++ b/packages/decepticon/decepticon/tools/web/tools.py @@ -2,9 +2,11 @@ from __future__ import annotations +import asyncio import json import os -from typing import Any +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, TypeVar from langchain_core.tools import tool @@ -24,6 +26,27 @@ def _json(data: Any) -> str: return json.dumps(data, indent=2, default=str, ensure_ascii=False) +_T = TypeVar("_T") + + +def _run_coro(coro: Coroutine[Any, Any, _T]) -> _T: + """Run ``coro`` to completion regardless of the calling context. + + These sync ``@tool`` wrappers are invoked both from plain sync code + and from inside the agent's running event loop. ``asyncio.run`` is the + correct primitive but raises if a loop is already running, so when one + is detected the coroutine is driven on a fresh thread that owns its own + loop. On Python 3.13 ``get_event_loop().run_until_complete`` no longer + works in either context, hence this helper. + """ + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + with ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(lambda: asyncio.run(coro)).result() + + @tool def jwt_parse(token: str) -> str: """Parse a JWT and surface known header / claim findings. @@ -186,8 +209,6 @@ def http_request( Returns: JSON with status, headers, body (truncated), elapsed_ms, request_id """ - import asyncio - try: headers = json.loads(headers_json) if headers_json else {} except json.JSONDecodeError: @@ -205,7 +226,7 @@ async def _do(): return resp try: - resp = asyncio.get_event_loop().run_until_complete(_do()) + resp = _run_coro(_do()) return _json( { "status": resp.status, diff --git a/packages/decepticon/tests/unit/web/test_jwt.py b/packages/decepticon/tests/unit/web/test_jwt.py index d31b21bf..32018495 100644 --- a/packages/decepticon/tests/unit/web/test_jwt.py +++ b/packages/decepticon/tests/unit/web/test_jwt.py @@ -62,6 +62,26 @@ def test_jku_non_https_flagged(self) -> None: parsed = parse_token(t) assert any("jku" in f for f in parsed.findings) + def test_jku_https_attacker_host_flagged(self) -> None: + t = forge_token( + {"sub": "f"}, + alg="HS256", + secret="k", + header={"jku": "https://attacker.com/jwks.json"}, + ) + parsed = parse_token(t) + assert any("jku" in f for f in parsed.findings) + + def test_x5u_https_attacker_host_flagged(self) -> None: + t = forge_token( + {"sub": "f"}, + alg="HS256", + secret="k", + header={"x5u": "https://attacker.com/cert.pem"}, + ) + parsed = parse_token(t) + assert any("x5u" in f for f in parsed.findings) + class TestForgeVerify: @pytest.mark.parametrize("alg", ["HS256", "HS384", "HS512"]) diff --git a/packages/decepticon/tests/unit/web/test_tools.py b/packages/decepticon/tests/unit/web/test_tools.py new file mode 100644 index 00000000..aece2950 --- /dev/null +++ b/packages/decepticon/tests/unit/web/test_tools.py @@ -0,0 +1,68 @@ +"""Tests for the web @tool wrappers (http_request event-loop safety).""" + +from __future__ import annotations + +import asyncio +import json + +import pytest + +from decepticon.tools.web import tools +from decepticon.tools.web.http import HTTPResponse + + +class _FakeSession: + async def request( + self, + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + body: bytes | None = None, + tag: str = "", + ) -> HTTPResponse: + return HTTPResponse( + id="resp-1", + request_id="req-1", + status=200, + headers={"content-type": "text/plain"}, + body=b"pong", + elapsed_ms=1.5, + timestamp=2.0, + ) + + +@pytest.fixture +def patched_session(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(tools, "_get_session", lambda: _FakeSession()) + + +def test_http_request_returns_parsed_result_from_sync_context(patched_session: None) -> None: + raw = tools.http_request.invoke({"method": "GET", "url": "https://target.test/ping"}) + parsed = json.loads(raw) + assert "error" not in parsed + assert parsed["status"] == 200 + assert parsed["body"] == "pong" + + +async def test_http_request_works_inside_running_event_loop(patched_session: None) -> None: + loop = asyncio.get_running_loop() + + raw = await loop.run_in_executor( + None, + lambda: tools.http_request.invoke({"method": "GET", "url": "https://target.test/ping"}), + ) + + parsed = json.loads(raw) + assert "error" not in parsed + assert parsed["status"] == 200 + assert parsed["body"] == "pong" + + +async def test_run_coro_helper_works_with_active_running_loop() -> None: + asyncio.get_running_loop() + + async def _coro() -> str: + return "ok" + + assert tools._run_coro(_coro()) == "ok"