fix(security): add SSRF guard for A2A peer and Gensui connect endpoints#18
fix(security): add SSRF guard for A2A peer and Gensui connect endpoints#18wstlima wants to merge 1 commit into
Conversation
CodeQL flagged 2 full-ssrf and 7 partial-ssrf findings. Investigated all
9: the ones in shogun/api/system.py (Ollama/LMStudio model provider
proxy) and shogun/services/channel_service.py (Telegram bot API, only
the path is user-controlled, not the host) are working as designed —
connecting to a local/self-hosted inference server is the actual
feature, and blocking private IPs there would break the documented use
case. Left those alone.
The two that are genuine unrestricted SSRF, with no legitimate reason
to reach internal network ranges:
- shogun/integrations/a2a_client.py `send()` / `ping()` — peer_url
comes straight from InvitePeerRequest.peer_url (shogun/api/a2a.py,
no visible auth dependency on that router) and is used verbatim to
build the outbound request URL. A2A peers are remote Shogun
instances reached over the public internet — there's no valid case
for an A2A peer resolving to 127.0.0.0/8, 169.254.169.254, or an
RFC 1918 address.
- shogun/api/gensui_config.py `/connect` and `/test` — req.server_url
is fully attacker/user-controlled and hits `{server_url}/api/gensui/
health` unchecked. Gensui commonly runs on the operator's own LAN
(this POC itself proxies http://127.0.0.1:8787), so this endpoint
needs to allow private/loopback ranges — but not the cloud metadata
endpoint or link-local space.
Added shogun/services/ssrf_guard.py: resolves the hostname via
socket.getaddrinfo and rejects the target if any resolved address is
private/loopback/link-local/reserved/multicast. `assert_safe_url()`
defaults to strict (blocks everything above — used for a2a_client.py);
`allow_private=True` permits RFC 1918 and loopback while still
blocking link-local (which covers 169.254.169.254 generically) and the
well-known metadata literal addresses explicitly — used for
gensui_config.py.
This is a resolve-time check, not a complete SSRF defense: it does not
protect against DNS rebinding after the check or against redirects to
a blocked target (httpx follows redirects by default in some call
sites here). Sufficient to close the most common/obvious vector;
documented as a known limitation in the module docstring.
Verified with 8 representative cases (public host, metadata endpoint
in both modes, private/loopback in both modes, bad scheme) — all pass
as expected. See shogun/services/ssrf_guard.py for the test snippet
used.
|
Thank you, @wstlima. This was a valuable and well-documented finding, and we have accepted it. Shogun legitimately supports private LAN, VPN, Docker, loopback, and air-gapped federation, so the strict public-only model in this PR has been redesigned as an explicit destination-policy system. The replacement implementation is in PR #21 and includes infrastructure-admin authorization, public/private/loopback/allowlist policies, permanent metadata and link-local blocking, validation of every A/AAAA response, IPv4-mapped IPv6 normalization, scheme and port controls, disabled redirects, structured security logs, configuration documentation, and regression/route tests. We are closing this original PR as superseded by that architecture-adjusted implementation—not because the finding was rejected. Your security and deployment review is genuinely appreciated, and you are credited in the release notes under Security contributors. |
Fixes #9
Summary
CodeQL flagged 2
full-ssrfand 7partial-ssrffindings. Investigated all 9:Findings in
shogun/api/system.py(Ollama/LMStudio model provider proxy) andshogun/services/channel_service.py(Telegram bot API — only the path is user-controlled, not the host) are working as designed. Connecting to a local/self-hosted inference server is the actual feature; blocking private IPs there would break the documented use case. Left those alone.The two that are genuine unrestricted SSRF, with no legitimate reason to reach internal network ranges:
shogun/integrations/a2a_client.pysend()/ping()—peer_urlcomes straight fromInvitePeerRequest.peer_url(no visible auth dependency on that router) and is used verbatim to build the outbound request URL. A2A peers are remote Shogun instances reached over the public internet — no valid case for a peer resolving to127.0.0.0/8,169.254.169.254, or an RFC 1918 address.shogun/api/gensui_config.py/connectand/test—req.server_urlis fully user-controlled and hits{server_url}/api/gensui/healthunchecked. Gensui commonly runs on the operator's own LAN, so this endpoint needs to allow private/loopback ranges — but not the cloud metadata endpoint or link-local space.Fix
Added
shogun/services/ssrf_guard.py: resolves the hostname viasocket.getaddrinfoand rejects the target if any resolved address is private/loopback/link-local/reserved/multicast.assert_safe_url()defaults to strict (blocks everything above) — used fora2a_client.pyallow_private=Truepermits RFC 1918 and loopback while still blocking link-local (covers169.254.169.254) and the well-known metadata literal addresses — used forgensui_config.pyThis is a resolve-time check, not a complete SSRF defense: it doesn't protect against DNS rebinding after the check or redirects to a blocked target. Documented as a known limitation in the module docstring.
Test plan
127.0.0.1:8787(the documented local Gensui use case) still works withallow_private=True