fix(security): prevent prototype pollution via untrusted map keys - #619
fix(security): prevent prototype pollution via untrusted map keys#619kalenkevich wants to merge 1 commit into
Conversation
appName, userId, sessionId and state keys arrive straight off request
paths and bodies on the dev server. Held in plain `{}` maps, a key of
`__proto__` aliases Object.prototype instead of creating an own
property, so a single unauthenticated request writes onto
Object.prototype for the lifetime of the process. The appendEvent
stateDelta path makes the planted value fully attacker-controlled.
Key the affected maps with Object.create(null) so these names become
ordinary own properties:
- InMemorySessionService: sessions, userState, appState.
- InMemoryCredentialService: credentials. Also stops an inherited
credentialKey such as `toString` resolving to a Function rather
than undefined.
- AdkApiServer: runnerCache, traceDict, sessionTraceDict. Here `in`
matched inherited names, so `appName in runnerCache` reported a hit
and yielded a Function where a Runner was expected, and
GET /debug/trace/toString returned 200 instead of 404.
An app literally named __proto__ keeps working, and no longer leaks
phantom sessions across apps.
AmaadMartin
left a comment
There was a problem hiding this comment.
The core change is correct, and I verified the two dev-server claims against the source at the head commit. getRunner uses appName in this.runnerCache at dev/src/server/adk_api_server.ts:1029, and /debug/trace/:eventId reads this.traceDict[eventId] at line 276, so both new dev tests fail on the unfixed code. I also confirmed that no null-prototype map reaches a caller, because mergeStates puts an app: or user: prefix on every key.
Four points below: one test that cannot fail, one sink on the same request path that this change does not close, one sibling map with the same shape, and one helper that you can delete.
| it('does not pollute Object.prototype via a __proto__ state key', async () => { | ||
| const session = await service.createSession({ | ||
| appName: 'app1', | ||
| userId: 'u1', | ||
| sessionId: 's1', | ||
| state: {}, | ||
| }); | ||
| await service.appendEvent({ | ||
| session, | ||
| event: createEvent({ | ||
| timestamp: Date.now(), | ||
| actions: createEventActions({ | ||
| stateDelta: { | ||
| [`${State.USER_PREFIX}__proto__`]: { | ||
| baseUrl: 'https://evil.test', | ||
| }, | ||
| }, | ||
| }), | ||
| }), | ||
| }); | ||
|
|
||
| expect(({} as Record<string, unknown>)['baseUrl']).toBeUndefined(); |
There was a problem hiding this comment.
Not a nit. This test passes on the unfixed code, so it does not guard the fix.
stateDelta: {
[`${State.USER_PREFIX}__proto__`]: {
baseUrl: 'https://evil.test',
},
},
...
expect(({} as Record<string, unknown>)['baseUrl']).toBeUndefined();Before the fix, this.userState['app1']['u1'] is a plain {}. The statement
map['__proto__'] = {baseUrl} calls the inherited __proto__ setter. That
setter changes the prototype of map. It does not write to Object.prototype.
Therefore ({}).baseUrl is undefined before the fix and after the fix.
The real damage before the fix is different. The key leaves the map, so the
user state value is lost. Assert that instead:
const stored = await service.getSession({
appName: 'app1',
userId: 'u1',
sessionId: 's1',
});
expect(stored?.state['user:__proto__']).toEqual({
baseUrl: 'https://evil.test',
});mergeStates puts the user: prefix back on each own key
(core/src/sessions/base_session_service.ts:247). Before the fix the map has
no own key, so the value is undefined and the test fails. After the fix
__proto__ is an own key, so the value comes back.
The other five tests in this block are correct. Each one fails on the unfixed
code. I checked each one by hand.
| }); | ||
|
|
||
| describe('prototype pollution', () => { | ||
| const POLLUTED_KEYS = [ |
There was a problem hiding this comment.
Nit, optional. The cleanup list is not complete.
const POLLUTED_KEYS = [
'polluted',
'httpOptions',
'pwned',
'baseUrl',
'poc_sid',
];The test does not pollute Object.prototype via appName in app state uses
appName: '__proto__' and userId: 'u1'. On unfixed code, createSession
writes Object.prototype.u1 before it reaches the app: state. The cleanup
leaves that key in place for the tests that follow.
Add 'u1' to the list.
This matters only when a person reverts the fix. That is the moment when the
suite must stay correct.
|
|
||
| if (!this.sessions[appName]) { | ||
| this.sessions[appName] = {}; | ||
| this.sessions[appName] = createNullProtoMap(); |
There was a problem hiding this comment.
Not a nit. The same primitive stays open one level up, on the same request path.
POST /apps/:appName/users/:userId/sessions/:sessionId reads the state from
the request body:
const state = req.body['state'] || {}; // dev/src/server/adk_api_server.ts:447That value goes to createSession, which calls trimTempState. trimTempState
copies each caller key into a plain object:
// core/src/sessions/base_session_service.ts:230
const filteredState: Record<string, unknown> = {};
for (const [key, value] of Object.entries(state)) {
if (!key.startsWith(State.TEMP_PREFIX)) {
filteredState[key] = value;
}
}A body of {"state": {"__proto__": {"isAdmin": true}}} does two things.
JSON.parse makes __proto__ an own key, so Object.entries returns it. The
copy then calls the __proto__ setter, the key leaves the map, and the
prototype of the new session state becomes the attacker object.
The session keeps that prototype. State wraps the session state and uses the
in operator:
// core/src/sessions/state.ts:31
get<T>(key: string, defaultValue?: T): T | undefined {
if (key in this.delta) { ... }
if (key in this.value) { ... }So state.get('isAdmin') returns true for that session.
trimTempDeltaState at line 213 has the same shape.
Use Object.create(null) for both filtered maps:
const filteredState: Record<string, unknown> = Object.create(null);Three notes on this suggestion, because it changes behaviour:
- The blast radius is one session, not the process. This is one tier below
the bugs that you fixed here. - I traced the prototype through the copies.
lodash-es/_initCloneObject
callsbaseCreate(getPrototype(object)), socloneDeepingetSessionand
inmergeStateskeeps the attacker prototype on the returned state. - I checked the dependants of the change. The suite holds one
toStrictEqual, incore/test/agents/functions_test.ts:402, and it does
not touch session state. A null-prototype state object therefore breaks no
current assertion.cloneDeepalso turns a null-prototype object back into
a plain{}, so no null-prototype map reaches a caller.
| function createNullProtoMap<T>(): Record<string, T> { | ||
| return Object.create(null) as Record<string, T>; | ||
| } |
There was a problem hiding this comment.
Nit. The helper is not necessary, and it is here three times.
function createNullProtoMap<T>(): Record<string, T> {
return Object.create(null) as Record<string, T>;
}The TypeScript signature of Object.create is (o: object | null): any. An
any value assigns to any type. The cast does nothing. Assign the call
directly:
private sessions: Record<string, Record<string, Record<string, Session>>> =
Object.create(null);The three copies are here, at
core/src/auth/credential_service/in_memory_credential_service.ts:22, and at
dev/src/server/adk_api_server.ts:62. Each copy has a different comment. If
you delete all three, you remove approximately 40 of the 269 added lines. Keep
one comment on each field, or one comment on each class.
I checked the lint rule that can object to the direct assignment.
eslint.config.js extends tseslint.configs.recommended, not the type-checked
configuration. @typescript-eslint/no-unsafe-assignment is therefore off.
| this.sessions[appName][userId] = createNullProtoMap(); | ||
| } | ||
|
|
||
| this.sessions[appName][userId][session.id] = session; |
There was a problem hiding this comment.
Nit. The scoping note in the description covers the outer key only.
The description says that InMemoryMemoryService is not affected, because the
key always contains /. That is true for the outer key. The inner key is the
session ID:
// core/src/memory/in_memory_memory_service.ts:30
if (!this.sessionEvents[userKey]) {
this.sessionEvents[userKey] = {};
}
this.sessionEvents[userKey][session.id] = session.events.filter(...);session.id comes from the request path, and it holds no /. A session with
the ID __proto__ replaces the prototype of the inner map. The entry then has
no own key, so Object.values at line 47 steps over it. searchMemory can
never find that session.
This is a silent loss of data, not process pollution. The fix is the same one
line that you use here:
this.sessionEvents[userKey] = Object.create(null);The claim for InMemoryArtifactService holds. artifactPath always joins the
segments with /, and the map has one level only.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
2. Or, if no issue exists, describe the change:
Problem:
Several in-memory maps are keyed directly by values that arrive off the request
path or body —
appName,userId,sessionId,eventIdand session statekeys. They were plain
{}object literals, so a key of__proto__resolves toObject.prototypeinstead of creating an own property:A single unauthenticated request to
POST /apps/:appName/users/:userId/sessions/:sessionIdtherefore writes onto
Object.prototypefor the remaining lifetime of theprocess. The
appendEventstateDeltapath is the more serious variant: theplanted value is fully attacker-controlled, so an arbitrary key can be given
an arbitrary object. Process-wide, that makes every
if (opts.KEY),obj[KEY] ??,KEY in objandfor…inattacker-influenced. The default devserver (
adk web/adk api_server) uses these services.Two further consequences of the same root cause:
InMemoryCredentialServiceshares the pattern, and additionally a lookup ofan inherited
credentialKeysuch astoStringreturned aFunctionratherthan
undefined.AdkApiServer,inwalks the prototype chain, soappName in this.runnerCachereported a hit for inherited names and handed back a
Functionwhere aRunnerwas expected.GET /debug/trace/toStringlikewise returned200with
Function.prototype.toStringinstead of404.Solution:
Key the affected maps with
Object.create(null). A null-prototype map has noinherited
__proto__accessor, so__proto__,constructorandprototypebecome ordinary own properties, and
in/ property reads no longer seeinherited names.
This is a structural fix rather than an input filter. Rejecting
__proto__asan
appNameat the route layer was considered and not taken: it is a breakingAPI change that would also refuse legitimately-named apps, and it would have to
be repeated at every call site, whereas the null-prototype map removes the
primitive at the point of storage.
Changed:
core/src/sessions/in_memory_session_service.ts—sessions,userState,appState, and the nested maps created increateSession/appendEvent.core/src/auth/credential_service/in_memory_credential_service.ts—credentials.dev/src/server/adk_api_server.ts—runnerCache,traceDict,sessionTraceDict.Each package gets a small local
createNullProtoMap()helper with a commentexplaining why, following the existing convention in
core/src/utils/streaming_utils.tsof keeping this kind of guard module-localrather than widening the
@google/adkpublic surface for an internal concern.Behaviour is preserved: an app literally named
__proto__still stores andretrieves normally, and no longer leaks phantom sessions into unrelated apps.
Not affected, checked while scoping:
InMemoryMemoryServicekeys on`${appName}/${userId}`andInMemoryArtifactServiceonencodeURIComponent-joined path segments. Both keys always contain/, soneither can ever equal
__proto__. Left unchanged.Testing Plan
Unit Tests:
10 new tests across 3 suites:
core/test/sessions/in_memory_session_service_test.tsappName, viauserId+user:state, viaappName+app:state, via a__proto__state key; no phantom cross-app sessions; app named__proto__still workscore/test/auth/credential_service/in_memory_credential_service_test.tsappName; inheritedcredentialKeyreturnsundefineddev/test/server/adk_api_server_test.tsrunnerCachebuilds a realRunnerfor an app named after an inherited key;/debug/trace/<inherited>returns 404Each new test was confirmed to fail without the source fix (stash the source
change, re-run) rather than only passing with it. 8 of the 10 fail on the
unfixed code; the other 2 are behaviour-preservation tests that must pass both
before and after. Representative failures on the unfixed tree:
Affected suites after the fix:
Full unit suite (
unit:core+unit:dev):The single failure is
createAgent > Interactive Mode > should handle Vertex AI selection with gcloud defaults, which is pre-existing and unrelated — it failsidentically on a clean tree at
origin/mainbecause it reads ambient gcloudconfiguration.
npm run ts:checkreports 281 errors in 43 files both with and without thischange, i.e. the pre-existing baseline is unchanged and no new type errors are
introduced.
eslintandprettier --checkare clean on all changed files.Manual End-to-End (E2E) Tests:
With an agents directory available:
Checklist
Additional context
Object.create(null)maps behave identically for every read and write theaffected code performs.
Object.keys,Object.valuesandObject.entriesareown-property operations and are unaffected;
mergeStatesusesObject.entries,and
JSON.stringify/res.json()serialise null-prototype objects normally.The only behavioural difference is the intended one:
Note that these maps are never handed to callers directly, so the null
prototype does not leak into the public API surface.