From 74fdc578ccda42a7d7c78010ed857493bbed89fb Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Fri, 31 Jul 2026 09:31:25 -0400 Subject: [PATCH] fix(cron): set headers for chronos JWKS requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chronos cron-fire verifier constructed PyJWKClient without explicit headers, so its JWKS fetch to the NAS portal hit the same WAF 403 the dashboard-auth providers already guard against. It reaches the same portal issuer, so it's the same bug class — mirror the fix here and add a constructor-contract regression test. Co-authored-by: James Hodgkinson --- plugins/cron_providers/chronos/verify.py | 11 +++++++++- tests/plugins/test_chronos_verify.py | 28 +++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/plugins/cron_providers/chronos/verify.py b/plugins/cron_providers/chronos/verify.py index 831ff3d0a0a..108d788a2ee 100644 --- a/plugins/cron_providers/chronos/verify.py +++ b/plugins/cron_providers/chronos/verify.py @@ -62,7 +62,16 @@ def _get_jwk_client(jwks_url: str) -> Any: if client is None: from jwt import PyJWKClient - client = PyJWKClient(jwks_url) + # Explicit Accept + User-Agent so the JWKS fetch isn't blocked by the + # NAS portal's WAF, which 403s the default Python-urllib fingerprint + # (same fix as the dashboard-auth nous/self_hosted providers). + client = PyJWKClient( + jwks_url, + headers={ + "Accept": "application/json", + "User-Agent": "HermesAgent/1.0", + }, + ) _JWK_CLIENTS[jwks_url] = client return client diff --git a/tests/plugins/test_chronos_verify.py b/tests/plugins/test_chronos_verify.py index 649732b98dd..6380ec5f9a4 100644 --- a/tests/plugins/test_chronos_verify.py +++ b/tests/plugins/test_chronos_verify.py @@ -144,7 +144,7 @@ def test_jwks_url_path_resolves_key(rsa_keys, monkeypatch): key = pub class FakeJWKClient: - def __init__(self, url): + def __init__(self, url, **kwargs): assert url == "https://portal.nousresearch.com/.well-known/jwks.json" def get_signing_key_from_jwt(self, tok): @@ -161,6 +161,32 @@ def test_jwks_url_path_resolves_key(rsa_keys, monkeypatch): assert claims is not None and claims["purpose"] == "cron_fire" +def test_jwks_client_sends_explicit_http_headers(monkeypatch): + """Constructor-contract regression: the JWKS fetch must send an explicit + Accept + User-Agent so it isn't blocked by the NAS portal WAF (same fix as + the dashboard-auth nous/self_hosted providers).""" + from plugins.cron_providers.chronos import verify as verify_mod + + captured = {} + + class FakeJWKClient: + def __init__(self, url, **kwargs): + captured["url"] = url + captured["kwargs"] = kwargs + + monkeypatch.setattr("jwt.PyJWKClient", FakeJWKClient) + monkeypatch.setattr(verify_mod, "_JWK_CLIENTS", {}) + + url = "https://portal.nousresearch.com/.well-known/jwks.json" + verify_mod._get_jwk_client(url) + + assert captured["url"] == url + assert captured["kwargs"].get("headers") == { + "Accept": "application/json", + "User-Agent": "HermesAgent/1.0", + } + + def test_get_fire_verifier_returns_nas_verifier(): from plugins.cron_providers.chronos.verify import get_fire_verifier, verify_nas_fire_token