diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7f112bd4c95b..0484b2f1cb09 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -257,6 +257,7 @@ export { } from './utils/misc'; export { isNodeEnv, loadModule } from './utils/node'; export { normalize, normalizeToSize, normalizeUrlToBase } from './utils/normalize'; +export { setNormalizationDepthOverrideHint, setSkipNormalizationHint } from './utils/normalizationHints'; export { addNonEnumerableProperty, convertToPlainObject, diff --git a/packages/core/src/integrations/extraerrordata.ts b/packages/core/src/integrations/extraerrordata.ts index 59bac56908c3..8afde618af36 100644 --- a/packages/core/src/integrations/extraerrordata.ts +++ b/packages/core/src/integrations/extraerrordata.ts @@ -7,7 +7,7 @@ import type { IntegrationFn } from '../types-hoist/integration'; import { debug } from '../utils/debug-logger'; import { isError, isPlainObject } from '../utils/is'; import { normalize } from '../utils/normalize'; -import { addNonEnumerableProperty } from '../utils/object'; +import { setSkipNormalizationHint } from '../utils/normalizationHints'; import { truncate } from '../utils/string'; const INTEGRATION_NAME = 'ExtraErrorData'; @@ -66,7 +66,7 @@ function _enhanceEventWithErrorData( if (isPlainObject(normalizedErrorData)) { // We mark the error data as "already normalized" here, because we don't want other normalization procedures to // potentially truncate the data we just already normalized, with a certain depth setting. - addNonEnumerableProperty(normalizedErrorData, '__sentry_skip_normalization__', true); + setSkipNormalizationHint(normalizedErrorData); contexts[exceptionName] = normalizedErrorData; } diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts index 3a661ca90a3d..610a8d14870f 100644 --- a/packages/core/src/trpc.ts +++ b/packages/core/src/trpc.ts @@ -3,7 +3,7 @@ import { captureException } from './exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; import { startSpanManual } from './tracing'; import { normalize } from './utils/normalize'; -import { addNonEnumerableProperty } from './utils/object'; +import { setNormalizationDepthOverrideHint } from './utils/normalizationHints'; interface SentryTrpcMiddlewareOptions { /** Whether to include procedure inputs in reported events. Defaults to `false`. */ @@ -53,9 +53,8 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { procedure_type: type, }; - addNonEnumerableProperty( + setNormalizationDepthOverrideHint( trpcContext, - '__sentry_override_normalization_depth__', 1 + // 1 for context.input + the normal normalization depth (clientOptions?.normalizeDepth ?? 5), // 5 is a sane depth ); diff --git a/packages/core/src/utils/normalizationHints.ts b/packages/core/src/utils/normalizationHints.ts new file mode 100644 index 000000000000..da2d7ee41158 --- /dev/null +++ b/packages/core/src/utils/normalizationHints.ts @@ -0,0 +1,29 @@ +import { addNonEnumerableProperty } from './object'; + +/** + * Internal symbols for normalization behavior. JSON and other structured user payloads cannot + * carry these keys, so they cannot spoof SDK-only normalization hints. + */ +const SENTRY_SKIP_NORMALIZATION = Symbol('sentry.skipNormalization'); +const SENTRY_OVERRIDE_NORMALIZATION_DEPTH = Symbol('sentry.overrideNormalizationDepth'); + +/** Marks an object so `normalize` returns it unchanged (already-normalized SDK data). */ +export function setSkipNormalizationHint(obj: object): void { + addNonEnumerableProperty(obj, SENTRY_SKIP_NORMALIZATION, true); +} + +/** Overrides remaining normalization depth from this object downward (e.g. Redux / Pinia state). */ +export function setNormalizationDepthOverrideHint(obj: object, depth: number): void { + addNonEnumerableProperty(obj, SENTRY_OVERRIDE_NORMALIZATION_DEPTH, depth); +} + +/** @internal */ +export function hasSkipNormalizationHint(value: object) { + return Boolean((value as Record)[SENTRY_SKIP_NORMALIZATION]); +} + +/** @internal */ +export function getNormalizationDepthOverrideHint(value: object): number | undefined { + const v = (value as Record)[SENTRY_OVERRIDE_NORMALIZATION_DEPTH]; + return typeof v === 'number' ? v : undefined; +} diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index 1c25d937cfe4..117d32b3ae4d 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -1,5 +1,6 @@ import type { Primitive } from '../types-hoist/misc'; import { isSyntheticEvent, isVueViewModel } from './is'; +import { getNormalizationDepthOverrideHint, hasSkipNormalizationHint } from './normalizationHints'; import { convertToPlainObject } from './object'; import { getFunctionName, getVueInternalName } from './stacktrace'; @@ -101,20 +102,15 @@ function visit( // From here on, we can assert that `value` is either an object or an array. - // Do not normalize objects that we know have already been normalized. As a general rule, the - // "__sentry_skip_normalization__" property should only be used sparingly and only should only be set on objects that - // have already been normalized. - if ((value as ObjOrArray)['__sentry_skip_normalization__']) { + // Do not normalize objects that we know have already been normalized. Hints use internal symbols + // (see normalizationHints.ts) so user-controlled JSON cannot spoof them. + if (hasSkipNormalizationHint(value)) { return value as ObjOrArray; } - // We can set `__sentry_override_normalization_depth__` on an object to ensure that from there - // We keep a certain amount of depth. - // This should be used sparingly, e.g. we use it for the redux integration to ensure we get a certain amount of state. - const remainingDepth = - typeof (value as ObjOrArray)['__sentry_override_normalization_depth__'] === 'number' - ? ((value as ObjOrArray)['__sentry_override_normalization_depth__'] as number) - : depth; + // Override remaining depth from this node (e.g. Redux / Pinia state). Set via setNormalizationDepthOverrideHint. + const overrideDepth = getNormalizationDepthOverrideHint(value); + const remainingDepth = overrideDepth !== undefined ? overrideDepth : depth; // We're also done if we've reached the max depth if (remainingDepth === 0) { diff --git a/packages/core/src/utils/object.ts b/packages/core/src/utils/object.ts index 787a66ac8525..34d8a267aacf 100644 --- a/packages/core/src/utils/object.ts +++ b/packages/core/src/utils/object.ts @@ -53,7 +53,7 @@ export function fill(source: { [key: string]: any }, name: string, replacementFa * @param name The name of the property to be set * @param value The value to which to set the property */ -export function addNonEnumerableProperty(obj: object, name: string, value: unknown): void { +export function addNonEnumerableProperty(obj: object, name: string | symbol, value: unknown): void { try { Object.defineProperty(obj, name, { // enumerable: false, // the default, so we can save on bundle size by not explicitly setting it @@ -62,7 +62,7 @@ export function addNonEnumerableProperty(obj: object, name: string, value: unkno configurable: true, }); } catch { - DEBUG_BUILD && debug.log(`Failed to add non-enumerable property "${name}" to object`, obj); + DEBUG_BUILD && debug.log(`Failed to add non-enumerable property "${String(name)}" to object`, obj); } } diff --git a/packages/core/test/lib/utils/normalize.test.ts b/packages/core/test/lib/utils/normalize.test.ts index 17c0628e53df..b296a8766f4f 100644 --- a/packages/core/test/lib/utils/normalize.test.ts +++ b/packages/core/test/lib/utils/normalize.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, test, vi } from 'vitest'; -import { addNonEnumerableProperty, normalize } from '../../../src'; +import { normalize, setNormalizationDepthOverrideHint, setSkipNormalizationHint } from '../../../src'; import * as isModule from '../../../src/utils/is'; import * as stacktraceModule from '../../../src/utils/stacktrace'; @@ -655,7 +655,28 @@ describe('normalize()', () => { }); }); - describe('skips normalizing objects marked with a non-enumerable property __sentry_skip_normalization__', () => { + describe('regression: JSON cannot spoof skip-normalization via string keys', () => { + test('__sentry_skip_normalization__ as an own string property is still normalized', () => { + function someFun(): void { + /* no-empty */ + } + const jsonLikePayload = { + __sentry_skip_normalization__: true, + nan: NaN, + fun: someFun, + }; + + const result = normalize(jsonLikePayload); + + expect(result).toEqual({ + __sentry_skip_normalization__: true, + nan: '[NaN]', + fun: '[Function: someFun]', + }); + }); + }); + + describe('skips normalizing objects marked with setSkipNormalizationHint (internal symbol)', () => { test('by leaving non-serializable values intact', () => { const someFun = () => undefined; const alreadyNormalizedObj = { @@ -663,7 +684,7 @@ describe('normalize()', () => { fun: someFun, }; - addNonEnumerableProperty(alreadyNormalizedObj, '__sentry_skip_normalization__', true); + setSkipNormalizationHint(alreadyNormalizedObj); const result = normalize(alreadyNormalizedObj); expect(result).toEqual({ @@ -681,7 +702,7 @@ describe('normalize()', () => { }, }; - addNonEnumerableProperty(alreadyNormalizedObj, '__sentry_skip_normalization__', true); + setSkipNormalizationHint(alreadyNormalizedObj); const obj = { foo: { @@ -703,7 +724,7 @@ describe('normalize()', () => { }); }); - describe('overrides normalization depth with a non-enumerable property __sentry_override_normalization_depth__', () => { + describe('overrides normalization depth with setNormalizationDepthOverrideHint', () => { test('by increasing depth if it is higher', () => { const normalizationTarget = { foo: 'bar', @@ -717,7 +738,7 @@ describe('normalize()', () => { }, }; - addNonEnumerableProperty(normalizationTarget, '__sentry_override_normalization_depth__', 3); + setNormalizationDepthOverrideHint(normalizationTarget, 3); const result = normalize(normalizationTarget, 1); @@ -745,7 +766,7 @@ describe('normalize()', () => { }, }; - addNonEnumerableProperty(normalizationTarget, '__sentry_override_normalization_depth__', 1); + setNormalizationDepthOverrideHint(normalizationTarget, 1); const result = normalize(normalizationTarget, 3); diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index e9c5eac8424e..b04510a68cc9 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Scope } from '@sentry/core'; -import { addBreadcrumb, addNonEnumerableProperty, getClient, getCurrentScope, getGlobalScope } from '@sentry/core'; +import { + addBreadcrumb, + getClient, + getCurrentScope, + getGlobalScope, + setNormalizationDepthOverrideHint, +} from '@sentry/core'; interface Action { type: T; @@ -138,9 +144,8 @@ function createReduxEnhancer(enhancerOptions?: Partial): // Set the normalization depth of the redux state to the configured `normalizeDepth` option or a sane number as a fallback const newStateContext = { state: { type: 'redux', value: transformedState } }; - addNonEnumerableProperty( + setNormalizationDepthOverrideHint( newStateContext, - '__sentry_override_normalization_depth__', 3 + // 3 layers for `state.value.transformedState` normalizationDepth, // rest for the actual state ); diff --git a/packages/vue/src/pinia.ts b/packages/vue/src/pinia.ts index 596efb6ef182..df0a3c4d8938 100644 --- a/packages/vue/src/pinia.ts +++ b/packages/vue/src/pinia.ts @@ -1,4 +1,10 @@ -import { addBreadcrumb, addNonEnumerableProperty, getClient, getCurrentScope, getGlobalScope } from '@sentry/core'; +import { + addBreadcrumb, + getClient, + getCurrentScope, + getGlobalScope, + setNormalizationDepthOverrideHint, +} from '@sentry/core'; import type { Ref } from 'vue'; // Inline Pinia types @@ -112,9 +118,8 @@ export const createSentryPiniaPlugin: ( state: piniaStateContext, }; - addNonEnumerableProperty( + setNormalizationDepthOverrideHint( newState, - '__sentry_override_normalization_depth__', 3 + // 3 layers for `state.value.transformedState normalizationDepth, // rest for the actual state );