Skip to content

Commit 740fa95

Browse files
authored
fix(data-inspector): block prototype-chain writes (#324)
1 parent 1c9f789 commit 740fa95

4 files changed

Lines changed: 148 additions & 7 deletions

File tree

plans/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Generated by the improve skill on 2026-09-01 at commit `2d978f84`. Execute in th
1010
| 002 | Require authentication on route-based MCP | P1 | M | 001 | TODO |
1111
| 003 | Enforce shared-state exposure policy on direct MCP reads | P1 | S | 002 | TODO |
1212
| 004 | Contain remote asset materialization | P1 | S | - | TODO |
13-
| 005 | Block Data Inspector prototype-chain writes | P1 | S | - | TODO |
13+
| 005 | Block Data Inspector prototype-chain writes | P1 | S | - | DONE |
1414
| 006 | Validate request-derived authentication-link origins | P1 | M | - | TODO |
1515
| 007 | Reject pre-existing symlink escapes from filesystem roots | P2 | M | - | TODO |
1616

plugins/data-inspector/src/engine/normalize.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ export function navigate(value: unknown, path: NodePath, options: Pick<Normalize
103103
return undefined
104104
switch (kind) {
105105
case 'k':
106-
cur = cur instanceof Map ? cur.get(at) : (cur as Record<string, unknown>)[at]
106+
// Own properties only — mirrors the walker, which never descends into
107+
// inherited properties, and keeps live re-navigation off the prototype chain.
108+
cur = cur instanceof Map
109+
? cur.get(at)
110+
: Object.hasOwn(cur, at) ? (cur as Record<string, unknown>)[at] : undefined
107111
break
108112
case 'i': {
109113
const arr = cur as unknown[]

plugins/data-inspector/src/engine/write.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,29 @@ class WriteError extends Error {
2222
}
2323
}
2424

25+
/**
26+
* Property names that reach or replace a shared prototype through ordinary
27+
* property access (`__proto__`, `constructor.prototype`, …). Plain-object
28+
* set/add/rename destinations reject these; Map keys are data, not property
29+
* names, and never go through this check.
30+
*/
31+
const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
32+
33+
/** Reject a plain-object property name that could reach a shared prototype. */
34+
function assertSafeObjectKey(key: string): void {
35+
if (UNSAFE_OBJECT_KEYS.has(key))
36+
throw new WriteError('InvalidKey', `"${key}" is a prototype-sensitive property name`)
37+
}
38+
39+
/**
40+
* Create an own data property with a plain descriptor, bypassing any setter
41+
* inherited from the prototype chain. Used for every write that introduces a
42+
* property name the target doesn't already own (`add`, `rename`'s new key).
43+
*/
44+
function defineOwnDataProperty(target: object, key: string, value: unknown): void {
45+
Object.defineProperty(target, key, { configurable: true, enumerable: true, writable: true, value })
46+
}
47+
2548
/** Decode a discriminated wire value into the raw JS value to write. */
2649
function decode(value: WriteValue): unknown {
2750
return value.kind === 'undefined' ? undefined : value.value
@@ -86,9 +109,15 @@ function setAt(parent: object, seg: PathSegment, value: unknown, opts: WriteAppl
86109
}
87110
assertMutableObject(parent)
88111
const key = at as string
89-
const desc = Object.getOwnPropertyDescriptor(parent, key)
90-
if (desc && !desc.writable && !desc.set)
112+
assertSafeObjectKey(key)
113+
if (!Object.hasOwn(parent, key))
114+
throw new WriteError('PathNotFound', `property "${key}" does not exist`)
115+
const desc = Object.getOwnPropertyDescriptor(parent, key)!
116+
if (!desc.writable && !desc.set)
91117
throw new WriteError('ReadonlyProperty', `property "${key}" has no setter`)
118+
// The property is verified own, so bracket assignment can only run
119+
// this object's own setter (or write its own data slot) — never one
120+
// inherited from a shared prototype.
92121
const record = parent as Record<string, unknown>
93122
record[key] = value
94123
return
@@ -195,8 +224,11 @@ function addTo(container: object, key: WriteValue | undefined, value: unknown, o
195224
const propKey = decodeKey(key, 'add')
196225
if (typeof propKey !== 'string')
197226
throw new WriteError('InvalidKey', 'an object property key must be a string')
198-
const record = container as Record<string, unknown>
199-
record[propKey] = value
227+
assertSafeObjectKey(propKey)
228+
// A fresh own data property, never a bracket assignment: the key is new to
229+
// this object, so assignment would otherwise walk the prototype chain and
230+
// could run an inherited setter.
231+
defineOwnDataProperty(container, propKey, value)
200232
}
201233

202234
function renameAt(parent: object, seg: PathSegment, newKey: unknown): void {
@@ -218,11 +250,14 @@ function renameAt(parent: object, seg: PathSegment, newKey: unknown): void {
218250
throw new WriteError('PathNotFound', `property "${key}" does not exist`)
219251
if (typeof newKey !== 'string')
220252
throw new WriteError('InvalidKey', 'an object property key must be a string')
253+
assertSafeObjectKey(newKey)
221254
if (newKey === key)
222255
return
223256
const value = (parent as Record<string, unknown>)[key]
224257
delete (parent as Record<string, unknown>)[key]
225-
;(parent as Record<string, unknown>)[newKey] = value
258+
// The new key is fresh to this object; define it directly rather than
259+
// assigning through the prototype chain.
260+
defineOwnDataProperty(parent, newKey, value)
226261
return
227262
}
228263
if (kind === 'mk' || kind === 'mv') {

plugins/data-inspector/test/write.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,108 @@ describe('applyWrite — rename', () => {
181181
})
182182
})
183183

184+
describe('applyWrite — prototype-chain safety', () => {
185+
const unsafeKeys = ['__proto__', 'prototype', 'constructor'] as const
186+
187+
it('rejects set of prototype-sensitive destination keys', () => {
188+
for (const key of unsafeKeys) {
189+
const root = {}
190+
const out = applyWrite(root, { op: 'set', path: [['k', key]], value: json({ polluted: true }) })
191+
expect(out).toMatchObject({ ok: false, error: { name: 'InvalidKey' } })
192+
}
193+
expect(Object.prototype).not.toHaveProperty('polluted')
194+
})
195+
196+
it('rejects add of prototype-sensitive destination keys', () => {
197+
for (const key of unsafeKeys) {
198+
const out = applyWrite({}, { op: 'add', path: [], key: json(key), value: json({ polluted: true }) })
199+
expect(out).toMatchObject({ ok: false, error: { name: 'InvalidKey' } })
200+
}
201+
expect(Object.prototype).not.toHaveProperty('polluted')
202+
})
203+
204+
it('rejects rename onto a prototype-sensitive destination key', () => {
205+
for (const key of unsafeKeys) {
206+
const root = { a: 1 }
207+
const out = applyWrite(root, { op: 'rename', path: [['k', 'a']], key: json(key) })
208+
expect(out).toMatchObject({ ok: false, error: { name: 'InvalidKey' } })
209+
expect(root).toEqual({ a: 1 })
210+
}
211+
expect(Object.prototype).not.toHaveProperty('polluted')
212+
})
213+
214+
it('treats an inherited property as absent, reporting nested set as PathNotFound', () => {
215+
const proto = { shared: { secret: 1 } }
216+
const root = Object.create(proto) as Record<string, unknown>
217+
// `shared` is inherited, not an own property of `root`.
218+
const out = applyWrite(root, { op: 'set', path: [['k', 'shared'], ['k', 'secret']], value: json(2) })
219+
expect(out).toMatchObject({ ok: false, error: { name: 'PathNotFound' } })
220+
expect(proto.shared.secret).toBe(1)
221+
})
222+
223+
it('treats an inherited property as absent, reporting delete/rename as PathNotFound', () => {
224+
const proto = { shared: 1 }
225+
const root = Object.create(proto) as Record<string, unknown>
226+
expect(applyWrite(root, { op: 'delete', path: [['k', 'shared']] })).toMatchObject({ ok: false, error: { name: 'PathNotFound' } })
227+
expect(applyWrite(root, { op: 'rename', path: [['k', 'shared']], key: json('renamed') })).toMatchObject({ ok: false, error: { name: 'PathNotFound' } })
228+
expect(proto.shared).toBe(1)
229+
})
230+
231+
it('add creates an own data property without invoking an inherited setter', () => {
232+
const proto: Record<string, unknown> = {}
233+
let setterCalls = 0
234+
const bumpSetterCalls = () => setterCalls++
235+
Object.defineProperty(proto, 'name', { configurable: true, enumerable: true, get: () => 'proto-value', set: bumpSetterCalls })
236+
try {
237+
const root: Record<string, unknown> = Object.create(proto)
238+
const out = applyWrite(root, { op: 'add', path: [], key: json('name'), value: json('own-value') })
239+
expect(out.ok).toBe(true)
240+
expect(setterCalls).toBe(0)
241+
expect(Object.hasOwn(root, 'name')).toBe(true)
242+
expect(root.name).toBe('own-value')
243+
}
244+
finally {
245+
delete proto.name
246+
}
247+
})
248+
249+
it('rename creates an own data property at the destination without invoking an inherited setter', () => {
250+
const proto: Record<string, unknown> = {}
251+
let setterCalls = 0
252+
const bumpSetterCalls = () => setterCalls++
253+
Object.defineProperty(proto, 'name', { configurable: true, enumerable: true, get: () => 'proto-value', set: bumpSetterCalls })
254+
try {
255+
const root: Record<string, unknown> = Object.create(proto)
256+
root.oldKey = 'own-value'
257+
const out = applyWrite(root, { op: 'rename', path: [['k', 'oldKey']], key: json('name') })
258+
expect(out.ok).toBe(true)
259+
expect(setterCalls).toBe(0)
260+
expect(Object.hasOwn(root, 'name')).toBe(true)
261+
expect(root.name).toBe('own-value')
262+
}
263+
finally {
264+
delete proto.name
265+
}
266+
})
267+
268+
it('lets a Map use __proto__/prototype/constructor as ordinary data keys', () => {
269+
const map = new Map<unknown, unknown>()
270+
for (const key of unsafeKeys) {
271+
const out = applyWrite(map, { op: 'add', path: [], key: json(key), value: json(`value:${key}`) })
272+
expect(out.ok).toBe(true)
273+
}
274+
for (const key of unsafeKeys)
275+
expect(map.get(key)).toBe(`value:${key}`)
276+
277+
expect(applyWrite(map, { op: 'set', path: [['k', '__proto__']], value: json('updated') })).toMatchObject({ ok: true })
278+
expect(map.get('__proto__')).toBe('updated')
279+
280+
expect(applyWrite(map, { op: 'rename', path: [['k', 'prototype']], key: json('renamed-prototype') })).toMatchObject({ ok: true })
281+
expect(map.get('renamed-prototype')).toBe('value:prototype')
282+
expect(map.has('prototype')).toBe(false)
283+
})
284+
})
285+
184286
describe('applyWrite — request typing', () => {
185287
it('round-trips through JSON (wire-safety of the request shape)', () => {
186288
const request: WriteRequest = { op: 'set', path: [['k', 'a'], ['i', 0]], value: { kind: 'undefined' } }

0 commit comments

Comments
 (0)