CVE-2026-10140Critical (CVSS 9.6)Fixed7 min read
A client cache that keyed on everything except the user let one Langflow tenant's API key serve every other tenant's voice traffic
Langflow's voice mode kept its ElevenLabs client in class-level state and its OpenAI text-to-speech client in a cache keyed on a client-supplied session_id. The first key to arrive served every user behind it, and an attacker could claim a session name in advance so that other tenants' speech ran on the attacker's provider account. Fixed in Langflow 1.10.1.
- Vendor
- IBM
- Product
- Langflow OSS
- Weakness
- CWE-639
- Affected
- 1.0.0 through 1.10.0
- Fixed in
- 1.10.1
- Advisory
- GHSA-p5v5-v74q-w38h
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N
Disclosure timeline
May 26, 2026
Reported privately: voice mode caches its ElevenLabs client on a class attribute and its OpenAI text-to-speech client under a client-supplied session_id, so one tenant's API key ends up servicing other tenants' requests.
May 29, 2026
CVE-2026-10140 reserved by IBM, which is the CNA for Langflow OSS.
Jun 23, 2026
Fixed in Langflow 1.10.1. langflow-ai/langflow#13702 removes the singleton and re-keys both caches on (user_id, session_id); merged Jun 19.
Jun 26, 2026
IBM security bulletin published with named credit, rated Critical at CVSS 9.6. The CVE record and NVD entry followed on Jun 30, and GHSA-p5v5-v74q-w38h mirrored the CVE the same day.
Aug 11, 2026
IBM amended the CVE record to carry the finder credit alongside the bulletin's acknowledgement.
Langflow is a visual builder for LLM applications, and its voice mode is the part that turns a flow into something you can talk to. Speech goes up to OpenAI's realtime API for transcription, text comes back, and the reply is synthesized either by OpenAI or by ElevenLabs. Both of those upstream calls are paid, and both are paid by whoever owns the key.
Langflow's credential model is per user. OPENAI_API_KEY and ELEVENLABS_API_KEY are global variables stored encrypted against a specific user ID, and the voice endpoints read them back for the authenticated caller. Every request knows who is asking.
The clients built from those keys were cached. The caches did not know who was asking.
The bug
Two caches in api/v1/voice_mode.py, failing the same way at different depths.
The first is a class-level singleton. ElevenLabsClientManager held _instance and _api_key as class attributes, and get_client looked up the caller's stored key only when _api_key was still None:
class ElevenLabsClientManager:
_instance = None
_api_key = None
@classmethod
async def get_client(cls, user_id=None, session=None):
if cls._instance is None:
if cls._api_key is None and user_id and session:
cls._api_key = await variable_service.get_variable(
user_id=user_id, name="ELEVENLABS_API_KEY", ...
)
if cls._api_key:
cls._instance = ElevenLabs(api_key=cls._api_key)
return cls._instance
The first user to reach voice mode after a process start put their key on the class. Every user after that hit cls._instance is None as false and got that same client back. user_id is right there in the signature, and after the first call it is never read again.
The second is a dictionary keyed on the wrong string. tts_config_cache and voice_config_cache were dict[str, ...] keyed on session_id:
def get_tts_config(session_id: str, openai_key: str) -> TTSConfig:
if session_id not in tts_config_cache:
tts_config_cache[session_id] = TTSConfig(session_id, openai_key)
return tts_config_cache[session_id]
TTSConfig builds and holds OpenAI(api_key=openai_key). The session_id arrives from the websocket path, /ws/flow_tts/{flow_id}/{session_id}, and it is the chat session name, not a server-issued secret. So a second user connecting on a session name that is already in the dictionary gets the first user's TTSConfig, complete with an OpenAI client built from the first user's key. Their own key is passed into the function and quietly discarded.
One cache dropped the user ID entirely. The other replaced it with a value the client controls.
The two directions this runs
The passive direction is a bill. Whoever wins the cache pays for everyone. On a shared instance that is usually the earliest or most active tenant, and the more voice traffic the deployment does, the more of it lands on one account.
The active direction is the one that makes this Critical. An attacker who wants to be the cache winner just has to get there first, which for the singleton means being the first voice-mode caller after any restart, and for the session cache means opening a websocket on a session name before the victim does. Once the attacker's key is the one in the cache, other tenants' speech runs through the attacker's provider account, and the text being synthesized is whatever those tenants' flows produced. It shows up in the attacker's own OpenAI or ElevenLabs usage history, in their dashboard, under their login. The attacker never touches the victim's key and never needs to.
There is a smaller disclosure in the other direction too. GET /voice/elevenlabs/voice_ids calls the same singleton, so it returns the cache winner's ElevenLabs voice library to any authenticated user who asks, including custom and cloned voices they never shared.
Why nobody notices
Nothing in the response distinguishes a request serviced by your key from one serviced by someone else's. The audio comes back either way.
Langflow's own logs record which user made the request. They do not record which key answered it. Neither side of the join exists: the provider sees one account making all the calls with no tenant attribution, and Langflow sees per-tenant activity with no key attribution. Reconciling a surprising ElevenLabs bill against Langflow usage produces two lists that cannot be lined up.
And because the singleton lives on the class, nothing clears it. Not logout, not a new session, not rotating the variable. A long-running process holds the first key it ever saw until someone restarts it.
What it takes to reach
An authenticated account on the instance, with no admin rights and no ownership of the flow being spoken. Network access to the Langflow API, which defaults to port 7860. At least one user with ELEVENLABS_API_KEY configured for the singleton half. For the session-cache half, nothing but a session name and the willingness to connect first.
IBM scored it 9.6 Critical: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N. The scope change is the point. The damage does not stay inside Langflow's authorization boundary, it lands on an upstream provider account that Langflow has no authority over, as spend, as usage history, and as whatever the provider's terms hold that account responsible for. Confidentiality is high because victim content reaches the attacker's provider account and the winner's voice library is readable by anyone. Integrity is high because the identity every upstream request is attributed to is false. Availability is untouched, though an account suspended for someone else's traffic takes voice mode down with it. CISA's Vulnrichment agrees on impact and records no known exploitation.
The fix
Langflow 1.10.1, via langflow-ai/langflow#13702:
ElevenLabsClientManageris deleted.get_or_create_elevenlabs_clientnow reads the requesting user's key and builds a fresh client on every call, caching nothing.voice_config_cacheandtts_config_cacheare re-keyed fromsession_idto(user_id, session_id), so a client-supplied session name can no longer collide across tenants.user_idis threaded throughget_create_responseand its call sites, and voice config is resolved only after authentication. In the old codeget_voice_config(session_id)ran beforeget_current_user_for_websocket, which is the structural reason the identity could not have been part of the key: at the moment the cache was consulted, the server did not yet know who was calling.- A regression test,
test_voice_mode_client_scoping.py, asserts that the ElevenLabs client is built per user and that two users on the samesession_iddo not share config.
If you run Langflow
Upgrade to 1.10.1 or later. Then treat it as a billing question rather than a credential one. No key was disclosed to another user, so there is nothing that strictly needs rotating, but any ElevenLabs or OpenAI account attached to a shared instance may have been servicing traffic that was not its own, for as long as the process had been up. Compare provider usage over that window against what your own users actually did. If the numbers do not agree, the gap is other tenants, and the direction of the gap tells you whether you were the account paying or the account being paid for.
The pattern worth keeping
A cache key has to contain every input the cached value was derived from. Both of these values were derived from a per-user secret, and one was keyed on nothing while the other was keyed on a string the client picks. The moment a key omits the identity behind the value, the cache stops being a performance detail and starts answering authorization questions, and it answers them silently, because returning the same object is exactly what a cache is supposed to do.
The lazy-initialization idiom is what hides it. if x is None: x = build() reads like a cost optimization. In a multi-tenant process, on anything built from a credential, it means the first request through decides the security context for everyone behind it.