diff --git a/packages/core/src/api.js b/packages/core/src/api.js index ca92e8902..81ca491e2 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -97,6 +97,9 @@ function assertNotCrossOrigin(req) { } } +// Log levels an SDK may forward through POST /percy/log. +const SDK_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']); + // Create a Percy CLI API server instance export function createPercyServer(percy, port) { let pkg = getPackageJSON(import.meta.url); @@ -319,7 +322,20 @@ export function createPercyServer(percy, port) { const message = req.body.message; const meta = req.body.meta || {}; - log[level](message, meta); + // `level` is attacker-controlled input on an unauthenticated endpoint, so + // it must never be used as a dynamic property key on the logger object + // (CWE-1321 / PER-8606, PER-8625). Only the four log levels are dispatched; + // anything else (`__proto__`, `constructor`, `loglevel`, ...) is rejected. + if (!SDK_LOG_LEVELS.has(level)) { + return res.json(400, { error: 'Invalid log level' }); + } + + switch (level) { + case 'debug': log.debug(message, meta); break; + case 'info': log.info(message, meta); break; + case 'warn': log.warn(message, meta); break; + default: log.error(message, meta); + } res.json(200, { success: true }); }) diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js index 5b25f8911..40e7e20f0 100644 --- a/packages/core/test/api.test.js +++ b/packages/core/test/api.test.js @@ -896,6 +896,41 @@ describe('API Server', () => { expect(sdkLogs[1].meta).toEqual(message2.meta); }); + it('rejects /log requests whose level is not a real log level (PER-8606/8625)', async () => { + await percy.start(); + let before = logger.loglevel(); + + for (let level of ['__proto__', 'constructor', 'loglevel', 'deprecated', 'stdout', '', null, 42]) { + let [data, res] = await request('/percy/log', { + body: { level, message: 'debug', meta: {} }, + method: 'post' + }, true); + + expect(res.statusCode).toBe(400); + expect(data).toEqual({ error: 'Invalid log level' }); + } + + // no dynamic dispatch side effects: global loglevel untouched, nothing logged + expect(logger.loglevel()).toBe(before); + expect(logger.instance.query(log => log.debug === 'sdk').length).toBe(0); + expect(Object.prototype.token).toBeUndefined(); + }); + + it('dispatches every allowed /log level', async () => { + await percy.start(); + logger.loglevel('debug'); + + for (let level of ['debug', 'info', 'warn', 'error']) { + await expectAsync(request('/percy/log', { + body: { level, message: `${level} message` }, + method: 'post' + })).toBeResolvedTo({ success: true }); + } + + const sdkLogs = logger.instance.query(log => log.debug === 'sdk'); + expect(sdkLogs.map(l => l.level)).toEqual(['debug', 'info', 'warn', 'error']); + }); + it('returns a 500 error when an endpoint throws', async () => { spyOn(percy, 'snapshot').and.rejectWith(new Error('test error')); await percy.start();