Fix: DNS-based SSRF bypass in guardrail webhook URL validation - #575
Open
Deez-Automations wants to merge 2 commits into
Open
Conversation
validate_webhook_url() only ever checked the hostname string via ipaddress.ip_address(hostname), which succeeds solely for a bare IP literal typed directly into the URL. Any normal hostname raised ValueError, which was silently swallowed -- so a hostname that isn't itself an IP literal but resolves via DNS to a loopback, private, or link-local address (including the 169.254.169.254 cloud metadata endpoint) was never checked, in either DEBUG or production mode. The existing DEBUG-mode local-testing carve-out is untouched and still fully covered by its existing test contract. Resolves the hostname and checks every returned address, unwrapping IPv4-mapped IPv6 addresses first. Also adds 100.64.0.0/10 (carrier- grade NAT, used internally by several cloud providers) and ::/128 to the blocked-ranges list, and fixes an uncaught UnicodeError on malformed/overlong hostnames (confirmed socket.getaddrinfo raises UnicodeEncodeError, not socket.gaierror, for that case). DNS resolution is a synchronous, unbounded call with no built-in per-call timeout -- calling it inline from the async webhook-firing path (which runs on every guardrail hook invocation) would let an attacker-controlled DNS target block the event loop for every concurrent session. Added validate_webhook_url_async(), which offloads resolution to a worker thread via asyncio.to_thread bounded by asyncio.wait_for, and wired it into both the registration route and the hook-firing path (the latter as defense-in-depth against DNS rebinding, re-checking immediately before the actual request rather than trusting registration-time validation alone). The repository's internal synchronous check gained a skip_url_validation flag so a caller that already validated asynchronously doesn't redundantly perform a second, unbounded resolution while holding an open DB session. Resolves GenAI-Security-Project#535
This was referenced Aug 20, 2026
…ts (GenAI-Security-Project#535) Comparing this PR against another contributor's independent fix for the same issue (GenAI-Security-Project#538) surfaced a real gap this PR's original pass missed: all 5 write endpoints (PUT, POST /toggle, POST /rotate-secret, DELETE, POST /test) used get_session_context, which accepts anonymous temporary sessions. Since /test fires an immediate outbound HTTP request to whatever webhook_url is on file, an anonymous visitor could register an internal URL and trigger a request to it with zero authentication -- a second, independent path into the same unauthenticated-SSRF issue, on top of the DNS-resolution bypass this PR already fixed. Switched the 5 write endpoints to get_authenticated_session_context, matching the pattern already used elsewhere in this codebase (finbot/apps/ctf/routes/profile.py's own GET/PUT split). Read endpoints (GET, GET /activity) stay anonymous-accessible -- no state-changing side effect. 10 new tests in test_guardrail_route_security.py: all 5 write endpoints reject anonymous sessions with 401, both read endpoints still allow anonymous access, and 3 endpoints confirmed to still work correctly for a genuinely authenticated session (regression against locking out real usage).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The Labs guardrail feature lets a session register a
webhook_urlthat the server later POSTs to (GuardrailHookService.invoke,finbot/guardrails/service.py) during hook invocations.A note on the linked issue first: it states there's no validation at all and suggests importing
is_ssrf_safe()fromfinbot/apps/ctf/routes/profile.py. That function doesn't exist in the current codebase (it would come from #507, which is still open/unmerged) — worth flagging so nobody goes looking for it. There is existing validation already wired in:validate_webhook_url()infinbot/core/data/repositories.pyis already called fromLabsGuardrailConfigRepository.upsert()and already wrapped in a 422 response. It has its own passing test suite with an intentional, documented contract: inDEBUGmode (default for local dev), private/loopback addresses are allowed so contributors can test webhooks against local servers; in production (DEBUG=False), they're blocked via a hardcoded network list.The actual gap: that check only ever inspected the hostname via
ipaddress.ip_address(hostname), which only succeeds for a bare IP literal typed directly into the URL. Any normal hostname raisesValueError, which was silently swallowed — so a hostname that isn't itself an IP literal but resolves via DNS to127.0.0.1,169.254.169.254(cloud metadata), or any RFC1918 range was never checked, in either DEBUG or production mode. That's the real, exploitable bypass this PR closes.Comparison against another open fix for the same issue
Another contributor has an independent, already-open PR (#538) for this same issue, and this comparison isn't a clean "ours is better" — it genuinely isn't, on its own. The two PRs found different, non-overlapping halves of the same vulnerability:
validate_webhook_url()above (plus a self-caught event-loop DoS, see below). It did not touch endpoint authentication.PUT,POST /toggle,POST /rotate-secret,DELETE,POST /test) usedget_session_context, which accepts anonymous temporary sessions — meaning an unauthenticated visitor could register a webhook and, via/test, trigger an immediate outbound request to it. That's a second, independent path into the same "unauthenticated SSRF" issue, and it was still true onmainas of this PR's last commit before this update.Rather than leave that gap open and just note it, I've added the authentication fix from #538's approach into this PR too (see "Also added" below), so this PR now closes both halves. Credit to #538 for finding the half this PR missed — flagging clearly rather than quietly absorbing it.
Fix
validate_webhook_url(): when the hostname isn't a literal IP, it's now resolved and every returned address is checked against the blocked-ranges list (IPv4-mapped IPv6 addresses are unwrapped first). The existing DEBUG-mode carve-out is untouched — same gating, same intentional behavior, confirmed by a new test that DNS resolution is never even triggered in DEBUG mode.100.64.0.0/10(RFC 6598 carrier-grade NAT, used internally by several cloud providers) and::/128to the blocked list.UnicodeErroron malformed/overlong hostnames (socket.getaddrinforaisesUnicodeEncodeErrorfor those, notsocket.gaierror— confirmed directly, not assumed).invoke()runs on every guardrail hook call. Calling it inline there would let an attacker-controlled DNS target block the event loop for every concurrent session, not just the one making the request. Addedvalidate_webhook_url_async(), which offloads resolution to a worker thread viaasyncio.to_threadbounded byasyncio.wait_for(timeout=2s), and wired it into both the registration route andinvoke()(the latter as defense-in-depth re-validation immediately before the actual request, narrowing the DNS-rebinding TOCTOU window rather than fully closing it — full closure would mean pinning the resolved IP and connecting directly to it instead of letting httpx re-resolve independently, which felt like a larger, separate change).LabsGuardrailConfigRepository.upsert()gained askip_url_validationflag so the route (which now validates asynchronously first) doesn't also run the internal synchronous check redundantly — that would've meant a second, unbounded DNS resolution performed while holding an open DB session, which is worse than what this PR fixes, not better. Default behavior (no flag) is unchanged for any other caller.Also added: authentication on write endpoints
All 5 write endpoints now use
get_authenticated_session_contextinstead ofget_session_context, matching the pattern this codebase already uses elsewhere (finbot/apps/ctf/routes/profile.py's own GET/PUT split). The 2 read-only endpoints (GET,GET /activity) stay anonymous-accessible — no state-changing side effect. 10 new tests intest_guardrail_route_security.pyconfirm all 5 write endpoints return 401 for an anonymous session, both read endpoints still allow one, and 3 endpoints still work correctly for a genuinely authenticated session.Review process
This went through two full review passes before opening the PR, and the first one caught a real problem in my own patch worth being upfront about: the initial version of the DNS-resolution fix called
socket.getaddrinfosynchronously, inline, directly insideinvoke(). Sinceinvoke()fires on every guardrail hook call — a hot path, not an occasional one — that would have let an attacker register a webhook pointing at an unresponsive DNS target and freeze the entire event loop for every concurrent session on that worker, for as long as the OS resolver takes to give up (often 15-30s+). That's a self-contained HIGH-severity DoS, introduced in the course of fixing the SSRF, not present before this PR and not something I'd anticipated going in.The
asyncio.to_thread+asyncio.wait_foroffload described above, theskip_url_validationflag, theUnicodeErrorfix, and thetest_does_not_block_the_event_looptest that actually proves non-blocking behavior (rather than just asserting a mock was called) all came out of catching and closing that specific finding. A second review pass confirmed it was fixed correctly and surfaced no new blocking issues.Known residual gaps, noted rather than silently left
ThreadPoolExecutor(there's no dedicated pool configured anywhere in this codebase). Under sustained abuse — many concurrent registrations/invocations pointed at an unresponsive DNS target — the shared pool could still saturate, which would affect other unrelatedasyncio.to_thread/run_in_executorconsumers in the app. The event loop itself is genuinely protected (that was the primary risk), but a dedicated bounded executor for this specific check would close the second-order resource-exhaustion angle. Didn't want to bundle a broader executor-management change into this fix without discussion, but happy to follow up if that's wanted.invoke()narrows the DNS-rebinding window but doesn't fully close it (two independentgetaddrinfocalls, milliseconds apart, both attacker-influenceable via a low-TTL record). Full closure needs IP-pinning (resolve once, connect to that address directly) rather than re-validating and letting httpx re-resolve on its own — scoping that as a possible follow-up rather than folding it into this PR.Test plan
tests/unit/labs/test_guardrail_config.py::TestValidateWebhookUrlwith cases for: hostname resolving to a blocked/metadata IP, mixed safe+unsafe resolved addresses, a hostname resolving to a genuinely public IP, an unresolvable hostname, IPv4-mapped IPv6, an overlong/malformed hostname, and confirmation that DNS resolution never fires in DEBUG modeTestValidateWebhookUrlAsyncclass: correct delegation for safe/unsafe URLs, a timeout test, and a test that actually proves non-blocking behavior (runs a slow mocked resolution concurrently with a trivial coroutine viaasyncio.gatherand asserts the trivial one completes first)skip_url_validationin both directions (default still validates; explicit skip bypasses)test_guardrail_service.pyconfirminginvoke()rejects a webhook resolving to a blocked address and never callshttpx.AsyncClient.posttest_guardrail_route_security.pyfor the authentication fix (see above)pytest tests/unit/labs/ -q— 99/99 passingorigin/main: cleantests/unit/labs/test_guardrail_service.py::TestWebhookInvocation(this PR'stest_rejects_webhook_url_resolving_to_blocked_address, Fix: guardrail payload corruption before signing + missing after_tool for complete_task #576'stest_invoke_never_raises_even_on_internal_failure). Not a logical conflict — both tests are valid and independent, resolved by keeping both. Flagging for whoever merges second, so it's expected rather than surprising.