Skip to content

Commit 72aed62

Browse files
authored
feat(utils): Limit normalize maximum properties/elements (#4689)
This limits the number of properties/elements serialized for an object/array, to protect against huge objects being serialized if users inadvertently log them. To control this, a new `normalizeMaxBreadth` option has been added, which defaults to 1000. Documented in getsentry/sentry-docs#4844.
1 parent cc44957 commit 72aed62

File tree

4 files changed

+100
-23
lines changed

4 files changed

+100
-23
lines changed

packages/core/src/baseclient.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
342342
* @returns A new event with more information.
343343
*/
344344
protected _prepareEvent(event: Event, scope?: Scope, hint?: EventHint): PromiseLike<Event | null> {
345-
const { normalizeDepth = 3 } = this.getOptions();
345+
const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = this.getOptions();
346346
const prepared: Event = {
347347
...event,
348348
event_id: event.event_id || (hint && hint.event_id ? hint.event_id : uuid4()),
@@ -376,7 +376,7 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
376376
evt.sdkProcessingMetadata = { ...evt.sdkProcessingMetadata, normalizeDepth: normalize(normalizeDepth) };
377377
}
378378
if (typeof normalizeDepth === 'number' && normalizeDepth > 0) {
379-
return this._normalizeEvent(evt, normalizeDepth);
379+
return this._normalizeEvent(evt, normalizeDepth, normalizeMaxBreadth);
380380
}
381381
return evt;
382382
});
@@ -392,7 +392,7 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
392392
* @param event Event
393393
* @returns Normalized event
394394
*/
395-
protected _normalizeEvent(event: Event | null, depth: number): Event | null {
395+
protected _normalizeEvent(event: Event | null, depth: number, maxBreadth: number): Event | null {
396396
if (!event) {
397397
return null;
398398
}
@@ -403,18 +403,18 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
403403
breadcrumbs: event.breadcrumbs.map(b => ({
404404
...b,
405405
...(b.data && {
406-
data: normalize(b.data, depth),
406+
data: normalize(b.data, depth, maxBreadth),
407407
}),
408408
})),
409409
}),
410410
...(event.user && {
411-
user: normalize(event.user, depth),
411+
user: normalize(event.user, depth, maxBreadth),
412412
}),
413413
...(event.contexts && {
414-
contexts: normalize(event.contexts, depth),
414+
contexts: normalize(event.contexts, depth, maxBreadth),
415415
}),
416416
...(event.extra && {
417-
extra: normalize(event.extra, depth),
417+
extra: normalize(event.extra, depth, maxBreadth),
418418
}),
419419
};
420420
// event.contexts.trace stores information about a Transaction. Similarly,

packages/types/src/options.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ export interface Options {
101101
*/
102102
normalizeDepth?: number;
103103

104+
/**
105+
* Maximum number of properties or elements that the normalization algorithm will output in any single array or object included in the normalized event.
106+
* Used when normalizing an event before sending, on all of the listed attributes:
107+
* - `breadcrumbs.data`
108+
* - `user`
109+
* - `contexts`
110+
* - `extra`
111+
* Defaults to `1000`
112+
*/
113+
normalizeMaxBreadth?: number;
114+
104115
/**
105116
* Controls how many milliseconds to wait before shutting down. The default is
106117
* SDK-specific but typically around 2 seconds. Setting this too low can cause

packages/utils/src/object.ts

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -300,29 +300,35 @@ function makeSerializable<T>(value: T, key?: any): T | string {
300300
return value;
301301
}
302302

303+
type UnknownMaybeWithToJson = unknown & { toJSON?: () => string };
304+
303305
/**
304306
* Walks an object to perform a normalization on it
305307
*
306308
* @param key of object that's walked in current iteration
307309
* @param value object to be walked
308310
* @param depth Optional number indicating how deep should walking be performed
311+
* @param maxProperties Optional maximum number of properties/elements included in any single object/array
309312
* @param memo Optional Memo class handling decycling
310313
*/
311-
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
312-
export function walk(key: string, value: any, depth: number = +Infinity, memo: MemoFunc = memoBuilder()): any {
314+
export function walk(
315+
key: string,
316+
value: UnknownMaybeWithToJson,
317+
depth: number = +Infinity,
318+
maxProperties: number = +Infinity,
319+
memo: MemoFunc = memoBuilder(),
320+
): unknown {
313321
const [memoize, unmemoize] = memo;
314322

315323
// If we reach the maximum depth, serialize whatever is left
316324
if (depth === 0) {
317325
return serializeValue(value);
318326
}
319327

320-
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
321328
// If value implements `toJSON` method, call it and return early
322329
if (value !== null && value !== undefined && typeof value.toJSON === 'function') {
323330
return value.toJSON();
324331
}
325-
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
326332

327333
// `makeSerializable` provides a string representation of certain non-serializable values. For all others, it's a
328334
// pass-through. If what comes back is a primitive (either because it's been stringified or because it was primitive
@@ -344,15 +350,24 @@ export function walk(key: string, value: any, depth: number = +Infinity, memo: M
344350
return '[Circular ~]';
345351
}
346352

353+
let propertyCount = 0;
347354
// Walk all keys of the source
348355
for (const innerKey in source) {
349356
// Avoid iterating over fields in the prototype if they've somehow been exposed to enumeration.
350357
if (!Object.prototype.hasOwnProperty.call(source, innerKey)) {
351358
continue;
352359
}
360+
361+
if (propertyCount >= maxProperties) {
362+
acc[innerKey] = '[MaxProperties ~]';
363+
break;
364+
}
365+
366+
propertyCount += 1;
367+
353368
// Recursively walk through all the child nodes
354-
const innerValue: any = source[innerKey];
355-
acc[innerKey] = walk(innerKey, innerValue, depth - 1, memo);
369+
const innerValue: UnknownMaybeWithToJson = source[innerKey];
370+
acc[innerKey] = walk(innerKey, innerValue, depth - 1, maxProperties, memo);
356371
}
357372

358373
// Once walked through all the branches, remove the parent from memo storage
@@ -363,22 +378,28 @@ export function walk(key: string, value: any, depth: number = +Infinity, memo: M
363378
}
364379

365380
/**
366-
* normalize()
381+
* Recursively normalizes the given object.
367382
*
368383
* - Creates a copy to prevent original input mutation
369-
* - Skip non-enumerablers
370-
* - Calls `toJSON` if implemented
384+
* - Skips non-enumerable properties
385+
* - When stringifying, calls `toJSON` if implemented
371386
* - Removes circular references
372-
* - Translates non-serializeable values (undefined/NaN/Functions) to serializable format
373-
* - Translates known global objects/Classes to a string representations
374-
* - Takes care of Error objects serialization
375-
* - Optionally limit depth of final output
387+
* - Translates non-serializable values (`undefined`/`NaN`/functions) to serializable format
388+
* - Translates known global objects/classes to a string representations
389+
* - Takes care of `Error` object serialization
390+
* - Optionally limits depth of final output
391+
* - Optionally limits number of properties/elements included in any single object/array
392+
*
393+
* @param input The object to be normalized.
394+
* @param depth The max depth to which to normalize the object. (Anything deeper stringified whole.)
395+
* @param maxProperties The max number of elements or properties to be included in any single array or
396+
* object in the normallized output..
397+
* @returns A normalized version of the object, or `"**non-serializable**"` if any errors are thrown during normalization.
376398
*/
377-
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
378-
export function normalize(input: any, depth?: number): any {
399+
export function normalize(input: unknown, depth: number = +Infinity, maxProperties: number = +Infinity): any {
379400
try {
380401
// since we're at the outermost level, there is no key
381-
return walk('', input, depth);
402+
return walk('', input as UnknownMaybeWithToJson, depth, maxProperties);
382403
} catch (_oO) {
383404
return '**non-serializable**';
384405
}

packages/utils/test/object.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,51 @@ describe('normalize()', () => {
533533
});
534534
});
535535

536+
describe('can limit max properties', () => {
537+
test('object', () => {
538+
const obj = {
539+
nope: 'here',
540+
foo: {
541+
one: 1,
542+
two: 2,
543+
three: 3,
544+
four: 4,
545+
five: 5,
546+
six: 6,
547+
seven: 7,
548+
},
549+
after: 'more',
550+
};
551+
552+
expect(normalize(obj, 10, 5)).toEqual({
553+
nope: 'here',
554+
foo: {
555+
one: 1,
556+
two: 2,
557+
three: 3,
558+
four: 4,
559+
five: 5,
560+
six: '[MaxProperties ~]',
561+
},
562+
after: 'more',
563+
});
564+
});
565+
566+
test('array', () => {
567+
const obj = {
568+
nope: 'here',
569+
foo: new Array(100).fill('s'),
570+
after: 'more',
571+
};
572+
573+
expect(normalize(obj, 10, 5)).toEqual({
574+
nope: 'here',
575+
foo: ['s', 's', 's', 's', 's', '[MaxProperties ~]'],
576+
after: 'more',
577+
});
578+
});
579+
});
580+
536581
test('normalizes value on every iteration of decycle and takes care of things like Reacts SyntheticEvents', () => {
537582
const obj = {
538583
foo: {

0 commit comments

Comments
 (0)