This is the first post in Daemon Field Notes, a series where we pull a real finding out of an agentic penetration test, strip out anything that identifies the client, and walk through why it happened and how to kill the whole class of bug. Every finding in this series was discovered by an AI agent driving the live application, and confirmed the only way we trust a finding, by exploiting it.
The one-line version
A logged-in, low-privilege user could send one request to the AI assistant with ../ in an ID field and read, modify, or delete anything in the company's OpenAI account (every uploaded file, every vector store, every fine-tuning job) using the organization's own API key. It needed no admin role and no second bug, just string concatenation into a URL.
This is a classic path traversal wearing an AI costume.
The setup: a chat assistant with a thin proxy behind it
The target was a mature multi-tenant B2B SaaS platform. Like a lot of products right now, it had recently shipped an in-app AI assistant, the "chat with your data" feature that's landing in every dashboard this year. Customers never held an OpenAI key of their own; the platform brokered every call on their behalf.
To make that work, the app exposed a handful of internal API endpoints that its own frontend called (fetch a saved conversation, list the messages in it, fetch a stored model response). Any authenticated tenant user, down to the lowest role, could reach them. A normal call from the browser looked like this:
POST /internal/ai/conversation HTTP/1.1
Host: app.redacted-client.example
Cookie: session=<low-priv-user-session>
Content-Type: application/json
{ "conversation_id": "conv_9f3c..." }
Under the hood, the platform was a thin proxy in front of the OpenAI REST API. Handling that request, the backend did something morally equivalent to:
# The bug, distilled
def get_conversation(conversation_id: str):
url = f"https://api.openai.com/v1/conversations/{conversation_id}"
return http.get(url, headers={"Authorization": f"Bearer {ORG_API_KEY}"})
Spot it? conversation_id comes straight from the user and gets pasted into the path. Nothing validates that it actually looks like a conversation ID.
How the OpenAI API actually works, and why proxying it invites this
To understand why one ../ is so catastrophic here, you need to understand the thing being proxied. The OpenAI API is a fairly standard REST API, and it has three properties that combine badly with a naive proxy.
1. It's a resource tree addressed by URL path. Everything is a resource under https://api.openai.com/v1/, and you select it by its path:
GET /v1/conversations/{id} # one conversation
GET /v1/conversations/{id}/items # its items
GET /v1/responses/{id} # one model response
GET /v1/files # every uploaded file on the account
GET /v1/vector_stores # every vector store
GET /v1/fine_tuning/jobs # every fine-tuning job
GET /v1/models # every model the org can use
DELETE /v1/conversations/{id} # destroy a conversation
The critical detail is that conversations, files, vector_stores, and fine_tuning are all siblings under /v1/. They live one directory apart. So when a proxy builds /v1/conversations/{id} and lets you write the {id}, you aren't picking a conversation. You're standing in the /v1/conversations/ directory holding a value that can walk to any sibling with a single ../. The URL path is the authorization surface, and the proxy handed you a pen.
2. Authentication is a single, organization-wide bearer token. Every request carries Authorization: Bearer sk-.... That key isn't scoped to a user, a conversation, or a tenant, and by default it's scoped to the entire OpenAI organization. The API has no concept of "this request is on behalf of low-priv customer #4,812." It only knows the key, and the key can do everything. When the proxy attaches that key to a request whose path you control, the API happily authorizes it. This is the textbook confused deputy, where the proxy has authority (the org key), the attacker has influence (the path), and the API can't tell the difference between a legitimate call and a traversal.
3. There is no tenant boundary inside the account. A conversation ID and a file ID and a vector store live in the same flat namespace under one org. The isolation between customers exists only in the proxy's logic ("user X may see conversation Y"), and that logic is enforced by, well, building the right URL. Break the URL and you've broken the only tenant boundary that existed.
Put those together, and the shape of the vulnerability is almost inevitable. A REST resource tree + one god-key + user-controlled path segments = traversal to the whole account. This is why "we just proxy OpenAI" is a security decision, not a plumbing detail. The moment you proxy a provider API, you've inherited its entire URL namespace as attack surface, and it's now your job to make sure a customer can only ever address the sliver of it you intended.
Most teams don't think of it that way. They think of the AI provider as a trusted backend they're calling, not as a filesystem-like namespace they're exposing one string-concatenation away from the user.
The bug: the path is just a string, and so is your input
The intended request path is:
/v1/conversations/{ConversationId}
The mental model the developer had was "the user picks which conversation." The reality is "the user controls a chunk of the URL path." Those are very different threat models.
If I set conversation_id to ../files, the URL the backend builds is:
https://api.openai.com/v1/conversations/../files
Every HTTP client (and every server) normalizes ../ before the request goes out. That collapses to:
https://api.openai.com/v1/files
I'm no longer asking for a conversation. I'm asking for the file listing on the entire OpenAI account, and the proxy is dutifully attaching the org's bearer token to my request.
One ../ escapes the /conversations/ prefix. From /v1/conversations/{id}, a single traversal step lands me anywhere under /v1/.
Here's the actual shape of the exploit request (sanitized):
POST /internal/ai/conversation HTTP/1.1
Host: app.redacted-client.example
Cookie: session=<low-priv-user-session>
Content-Type: application/json
{ "conversation_id": "../files" }
And the response was the org's file listing, handed straight back through the assistant's own API:
{
"object": "list",
"data": [
{
"id": "file-REDACTED1",
"filename": "REDACTED-internal-doc.pdf",
"bytes": 88213
},
{
"id": "file-REDACTED2",
"filename": "REDACTED-data-export.csv",
"bytes": 41120
}
],
"has_more": true
}
What you can actually reach
Because the traversal isn't specific to reads, the impact tracks the HTTP verb of whichever endpoint you enter through. The assistant exposed the usual read/write/delete spread over conversations and responses, so the traversal inherited every verb. A few illustrative entry points:
| Endpoint you enter through | User-controlled field | Verb | Where it's meant to go |
|---|---|---|---|
| Fetch a conversation | conversation_id |
GET | /v1/conversations/{id} |
| List a conversation's items | conversation_id |
GET | /v1/conversations/{id}/items |
| Fetch a model response | response_id |
GET | /v1/responses/{id} |
| Update a conversation | conversation_id |
POST | /v1/conversations/{id} |
| Delete a conversation | conversation_id |
DELETE | /v1/conversations/{id} |
So this wasn't read-only. A GET endpoint let me enumerate, a POST endpoint let me traverse to a write path, and a DELETE endpoint let me traverse to a destroy path. The verb was fixed per entry point, but there were entry points for every verb.
Walking through the read endpoints alone, the agent pulled:
- Every uploaded file on the account, a full listing of everything the org had pushed to OpenAI: documents, internal reference material, and exported data. The filenames alone leaked business context.
- Every vector store on the account, and the names read like a map of the company's internal AI initiatives. Vector store names are basically project labels; enumerate them and you've enumerated the roadmap.
- Every fine-tuning job, including job IDs, the base models, the training-file references, and job status.
- The full model list available to the org, including any private or early-access models.
None of that data belonged to my tenant. There was no tenant boundary inside the OpenAI account at all. It was one shared organization behind one shared key, and the proxy had just handed a low-priv user the keys to all of it.
Why this is nastier than a textbook path traversal
Old-school path traversal (?file=../../etc/passwd) reads a file off a disk. This is a confused deputy with a much better badge:
- It borrows privileged credentials. The request rides the organization's API key, so authorization is decided by "whatever that key can do," which is everything.
- It's not just read. The same flaw on a DELETE endpoint is a destruction primitive, and the same flaw on a write endpoint lets an attacker mutate state. An attacker could wipe or corrupt conversations and model responses across the whole account.
- The blast radius is the whole tenant population. In a multi-tenant product sharing one AI account, every customer's AI data sits behind that single key. One tenant's low-priv user reaches all of it.
- It's invisible to most AI-security tooling. Prompt-injection scanners and "LLM guardrails" look at the content of prompts. This bug has nothing to do with prompts. It's plain input validation on a URL path, and the AI part is a red herring that made everyone stop looking for boring web bugs.
That last point is the theme of this series. Teams are pouring review effort into jailbreaks and prompt injection while shipping the AI feature on top of un-reviewed HTTP plumbing. The LLM is the exciting part; the proxy is where you get owned.
Why an LLM wouldn't conventionally catch this
Here's the part worth sitting with, because it cuts against how most people picture "AI finding vulnerabilities."
When people say "AI security testing," they usually mean one of two things: a chatbot that reviews your source code, or a guardrail that inspects prompts for jailbreaks. Neither of those would ever find this bug.
- A code-reading LLM wouldn't flag it.
url = f".../conversations/{conversation_id}"looks completely ordinary. There's noeval, no SQL, no obvious sink. Whether it's a vulnerability depends entirely on runtime behavior that isn't in the file. Does OpenAI's API normalize../? Arefilesandconversationsreally siblings? Is that key org-scoped? A model reading the function in isolation has no way to know the path resolves to a live, sibling-addressable resource tree behind a god-key. The bug isn't in the code; it's in the interaction between the code and a remote system's semantics. - A prompt-injection scanner wouldn't flag it. This finding has nothing to do with prompts. The payload is
../files, not "ignore your previous instructions." Every tool built to police what the model does is looking in the wrong place entirely, because the target was never the model but the plumbing.
This is the whole thesis behind how we build Daemon. The value isn't "an LLM that knows about vulnerabilities." It's an agent that drives the running application, logging in as a real low-privilege tenant user, mapping the app's AI endpoints live, and, for every endpoint that took an ID, fuzzing the ID field with a traversal wordlist (../, ..%2f, ..\, double-encoded variants) against the actual backend and reading what came back.
When ../files returned a JSON file listing instead of a 404 conversation not found, the agent didn't shrug it off. It did what a good human tester does next, chaining to ../vector_stores, ../fine_tuning/jobs, and ../models, capturing the responses, and only then reporting the finding, with the loot attached as proof. A finding we can exploit is a finding we can prove. That's the bar, and it's a bar you can only clear against a live system.
The reason the agent catches this is stamina applied to the live app, not cleverness applied to source. Every ID parameter on every AI endpoint was a candidate. Fuzzing all of them, across every HTTP verb, and following each hit to its full impact is exactly the tedious, exhaustive work that gets deprioritized in a time-boxed manual test, and exactly what an agent does without getting bored. Static analysis can't reach it because the truth lives on OpenAI's servers, not in the repo.
Remediation: validate the ID, don't trust the path
The fix is old and boring, which is the point.
1. Allowlist the ID format before you build the URL. OpenAI IDs have a predictable shape. Validate against it and reject everything else:
import re
CONVERSATION_ID = re.compile(r"^conv_[A-Za-z0-9]{24,}$")
RESPONSE_ID = re.compile(r"^resp_[A-Za-z0-9]{24,}$")
def get_conversation(conversation_id: str):
if not CONVERSATION_ID.fullmatch(conversation_id):
raise ValueError("invalid conversation id")
url = f"https://api.openai.com/v1/conversations/{conversation_id}"
...
Allowlist (^conv_[…]$), not denylist. Do not try to strip ../, because attackers have ..%2f, ..%252f, ..\, overlong UTF-8, and a decade of bypasses for every blocklist you'll write. Match the shape you expect; reject the rest.
2. Do the validation at the proxy boundary, not in the UI or some earlier layer. The check has to live immediately before the URL is constructed, so no code path can reach the HTTP client with an unvalidated ID.
3. Never interpolate user input into a URL path. If a client supports it, pass IDs as parameters to a typed method rather than string-building the path yourself. String concatenation into a URL is the root cause here, the same way it is in SQL injection.
4. Scope the credential. As defense in depth, the proxy's API key should be the least-privileged credential that still works. If per-tenant isolation is possible (separate projects/keys per customer), a traversal stays inside one tenant instead of the whole org. One shared god-key behind a multi-tenant proxy is the amplifier that turned a validation bug into a company-wide breach.
The pattern to watch for
If your product added an AI feature this year, go find every place where a user-supplied value ends up inside an outbound URL to a model provider, whether conversation IDs, response IDs, file IDs, vector-store IDs, or assistant IDs. Any of them concatenated into a path is this bug.
Then ask the question that actually matters. If this string were ../something, where would the request go, and whose credentials would it carry?
That's a five-minute audit that would have closed this finding before it shipped. The hard part isn't the fix, it's remembering that behind the AI feature is the same web application you already know how to secure.
Want the tedious, exhaustive version of that audit run continuously against your live app, with every finding proven by exploitation, not guessed at? That's what Daemon does. Request a demo.
Written by
Founder of PlatformSecurity and veteran security expert with over a decade of experience in offensive security. Recognized penetration testing specialist who has uncovered critical vulnerabilities in Fortune 500 companies, cloud infrastructure, and enterprise applications. Expert in red team operations, cloud security, and vulnerability research with a track record of responsible disclosures and high-impact security findings.