SSRF in langflow-ai/langflow (original) (raw)

Vulnerability Description


Vulnerability Overview

Langflow provides an API Request component that can issue arbitrary HTTP requests within a flow. This component takes a user-supplied URL, performs only normalization and basic format checks, and then sends the request using a server-side httpx client. It does not block private IP ranges (127.0.0.1, the 10/172/192 ranges) or cloud metadata endpoints (169.254.169.254), and it returns the response body as the result.

Because the flow execution endpoints (/api/v1/run, /api/v1/run/advanced) can be invoked with just an API key, if an attacker can control the API Request URL in a flow, non-blind SSRF is possible—accessing internal resources from the server’s network context. This enables requests to, and collection of responses from, internal administrative endpoints, metadata services, and internal databases/services, leading to information disclosure and providing a foothold for further attacks.

Vulnerable Code

  1. When a flow runs, the API Request URL is set via user input or tweaks, or it falls back to the value stored in the node UI.
    @router.post("/run/{flow_id_or_name}", response_model=None, response_model_exclude_none=True)
    async def simplified_run_flow(
    *,
    background_tasks: BackgroundTasks,
    flow: Annotated[FlowRead | None, Depends(get_flow_by_id_or_endpoint_name)],
    input_request: SimplifiedAPIRequest | None = None,
    stream: bool = False,
    api_key_user: Annotated[UserRead, Depends(api_key_security)],
    context: dict | None = None,
    http_request: Request,
    ):
    @router.post("/run/{flow_id_or_name}", response_model=None, response_model_exclude_none=True)
    async def simplified_run_flow(
    *,
    background_tasks: BackgroundTasks,
    flow: Annotated[FlowRead
    input_request: SimplifiedAPIRequest
    stream: bool = False,
    api_key_user: Annotated[UserRead, Depends(api_key_security)],
    context: dict
    http_request: Request,
    ):
    @router.post(
    ------------------------------------------------------------------------
    "/run/advanced/{flow_id_or_name}",
    response_model=RunResponse,
    response_model_exclude_none=True,
    )

| async def experimental_run_flow( |
| *, |
| session: DbSession, |
| flow: Annotated[Flow, Depends(get_flow_by_id_or_endpoint_name)], |
| inputs: list[InputValueRequest] | None = None, |
| outputs: list[str] | None = None, |
| tweaks: Annotated[Tweaks | None, Body(embed=True)] = None, |
| stream: Annotated[bool, Body(embed=True)] = False, |
| session_id: Annotated[None | str, Body(embed=True)] = None, |
| api_key_user: Annotated[UserRead, Depends(api_key_security)], |
| ) -> RunResponse: |
@router.post(
"/run/advanced/{flow_id_or_name}",
response_model=RunResponse,
response_model_exclude_none=True,
)
async def experimental_run_flow(
*,
session: DbSession,
flow: Annotated[Flow, Depends(get_flow_by_id_or_endpoint_name)],
inputs: list[InputValueRequest] | None = None,
outputs: list[str] | None = None,
tweaks: Annotated[Tweaks | None, Body(embed=True)] = None,
stream: Annotated[bool, Body(embed=True)] = False,
session_id: Annotated[None | str, Body(embed=True)] = None,
api_key_user: Annotated[UserRead, Depends(api_key_security)],
) -> RunResponse: 2. Normalization/validation stage: It only checks that the URL is non-empty and well-formed. No blocking of private networks, localhost, or IMDS.

def _normalize_url(self, url: str) -> str:
"""Normalize URL by adding https:// if no protocol is specified."""
if not url or not isinstance(url, str):
msg = "URL cannot be empty"
raise ValueError(msg)
url = url.strip()
if url.startswith(("http://", "https://")):
return url
return f"https://{url}"
def _normalize_url(self, url: str) -> str:  
    """Normalize URL by adding https:// if no protocol is specified."""  
    if not url or not isinstance(url, str):  
        msg = "URL cannot be empty"  
        raise ValueError(msg)  
    url = url.strip()  
    if url.startswith(("http://", "https://")):  
        return url  
    return f"https://{url}"  
url = self._normalize_url(url)
# Validate URL
if not validators.url(url):
msg = f"Invalid URL provided: {url}"
raise ValueError(msg)
    url = self._normalize_url(url)  
    # Validate URL  
    if not validators.url(url):  
        msg = f"Invalid URL provided: {url}"  
        raise ValueError(msg)
  1. On the server side, it sends a request to an arbitrary URL using httpx.AsyncClient and exposes the response body as metadata["result"].
    try:
    # Prepare request parameters

| request_params = { |
| "method": method, |
| "url": url, |
| "headers": headers, |
| "json": processed_body, |
| "timeout": timeout, |
| "follow_redirects": follow_redirects, |
| } |
| response = await client.request(**request_params) |
try:
# Prepare request parameters
request_params = {
"method": method,
"url": url,
"headers": headers,
"json": processed_body,
"timeout": timeout,
"follow_redirects": follow_redirects,
}
response = await client.request(**request_params)

# Base metadata
metadata = {
"source": url,
"status_code": response.status_code,
"response_headers": response_headers,
}
        # Base metadata  
        metadata = {  
            "source": url,  
            "status_code": response.status_code,  
            "response_headers": response_headers,  
        }  
# Handle response content
if is_binary:
result = response.content
else:
try:
result = response.json()
except json.JSONDecodeError:
self.log("Failed to decode JSON response")
result = response.text.encode("utf-8")
metadata["result"] = result
if include_httpx_metadata:
metadata.update({"headers": headers})
return Data(data=metadata)
        # Handle response content  
        if is_binary:  
            result = response.content  
        else:  
            try:  
                result = response.json()  
            except json.JSONDecodeError:  
                self.log("Failed to decode JSON response")  
                result = response.text.encode("utf-8")  
        metadata["result"] = result  
        if include_httpx_metadata:  
            metadata.update({"headers": headers})  
        return Data(data=metadata)

PoC


PoC Description

PoC

Impact