Skip to content

Commit cb7ae74

Browse files
committed
fix: guard deepMerge against prototype pollution keys
Add DANGEROUS_KEYS filtering in deepMerge to skip __proto__, constructor, and prototype keys during object iteration. This prevents prototype pollution when untrusted JSON values containing these keys are merged via getConfig(guildId). The loadConfig DB-loading paths already filter DANGEROUS_KEYS on row.key. Adds two tests verifying deepMerge skips dangerous keys during merge.
1 parent 763aa1c commit cb7ae74

2 files changed

Lines changed: 49 additions & 0 deletions

File tree

src/modules/config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ let fileConfigCache = null;
6565
*/
6666
function deepMerge(target, source) {
6767
for (const key of Object.keys(source)) {
68+
if (DANGEROUS_KEYS.has(key)) continue;
69+
6870
if (isPlainObject(target[key]) && isPlainObject(source[key])) {
6971
deepMerge(target[key], source[key]);
7072
} else {

tests/modules/config-guild.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,53 @@ describe('per-guild configuration', () => {
190190
});
191191
});
192192

193+
describe('prototype pollution protection', () => {
194+
it('should skip dangerous keys during deep merge', async () => {
195+
const guildId = 'guild-proto-pollution';
196+
delete Object.prototype.polluted;
197+
198+
try {
199+
// Directly inject a guild override with __proto__ key into the cache
200+
// to simulate a malicious value that bypassed path validation
201+
await configModule.setConfigValue('ai.model', 'safe-model', guildId);
202+
203+
// Set a value whose parsed JSON contains __proto__ — this is the attack vector.
204+
// When deepMerge iterates the guild override, it must skip __proto__.
205+
await configModule.setConfigValue(
206+
'ai.threadMode',
207+
'{"__proto__":{"polluted":"yes"}}',
208+
guildId,
209+
);
210+
211+
// Trigger deepMerge by requesting guild config
212+
configModule.getConfig(guildId);
213+
214+
// Object.prototype should NOT be polluted
215+
expect(Object.prototype.polluted).toBeUndefined();
216+
} finally {
217+
await configModule.resetConfig('ai', guildId);
218+
delete Object.prototype.polluted;
219+
}
220+
});
221+
222+
it('should skip constructor and prototype keys during deep merge', async () => {
223+
const guildId = 'guild-constructor-pollution';
224+
225+
try {
226+
const dangerousJson = '{"constructor":{"polluted":true},"prototype":{"evil":true}}';
227+
await configModule.setConfigValue('ai.threadMode', dangerousJson, guildId);
228+
229+
const config = configModule.getConfig(guildId);
230+
231+
// The dangerous keys should not appear in the merged result's ai section
232+
expect(config.ai.constructor).toBe(Object); // Should be the native constructor, not overridden
233+
expect(config.ai.prototype).toBeUndefined();
234+
} finally {
235+
await configModule.resetConfig('ai', guildId);
236+
}
237+
});
238+
});
239+
193240
describe('fallback to global defaults', () => {
194241
it('should return global defaults for unknown guild', () => {
195242
const config = configModule.getConfig('unknown-guild');

0 commit comments

Comments
 (0)