-
Notifications
You must be signed in to change notification settings - Fork 190
Expand file tree
/
Copy pathsanitize.ts
More file actions
275 lines (247 loc) · 10.3 KB
/
Copy pathsanitize.ts
File metadata and controls
275 lines (247 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
import { ONE_KIBI_BYTE } from '@datadog/js-core/util'
import { display } from '../display'
import type { Context, ContextArray, ContextValue } from './context'
import type { ObjectWithToJsonMethod } from './jsonStringify'
import { detachToJsonMethod } from './jsonStringify'
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
type PrimitivesAndFunctions = string | number | boolean | undefined | null | symbol | bigint | Function
type ExtendedContextValue = PrimitivesAndFunctions | ExtendedContext | ExtendedContextArray
interface ExtendedContext {
[key: string]: ExtendedContextValue
}
type ExtendedContextArray = ExtendedContextValue[]
interface ContainerElementToProcess {
source: ExtendedContextArray | ExtendedContext
target: ContextArray | Context
path: string
}
interface SanitizedEvent extends Context {
type: string
isTrusted: boolean
currentTarget: string | null | undefined
target: string | null | undefined
}
// The maximum size of a single event is 256KiB. By default, we ensure that user-provided data
// going through sanitize fits inside our events, while leaving room for other contexts, metadata, ...
export const SANITIZE_DEFAULT_MAX_CHARACTER_COUNT = 220 * ONE_KIBI_BYTE
// Symbol for the root element of the JSONPath used for visited objects
const JSON_PATH_ROOT_ELEMENT = '$'
// When serializing (using JSON.stringify) a key of an object, { key: 42 } gets wrapped in quotes as "key".
// With the separator (:), we need to add 3 characters to the count.
const KEY_DECORATION_LENGTH = 3
/**
* Ensures user-provided data is 'safe' for the SDK
* - Deep clones data
* - Removes cyclic references
* - Transforms unserializable types to a string representation
*
* LIMITATIONS:
* - Size is in characters, not byte count (may differ according to character encoding)
* - Size does not take into account indentation that can be applied to JSON.stringify
* - Non-numerical properties of Arrays are ignored. Same behavior as JSON.stringify
*
* @param source - User-provided data meant to be serialized using JSON.stringify
* @param maxCharacterCount - Maximum number of characters allowed in serialized form
*/
export function sanitize(source: string, maxCharacterCount?: number): string | undefined
export function sanitize(source: Context, maxCharacterCount?: number): Context
export function sanitize(source: unknown, maxCharacterCount?: number): ContextValue
export function sanitize(source: unknown, maxCharacterCount = SANITIZE_DEFAULT_MAX_CHARACTER_COUNT) {
// Unbind any toJSON function we may have on [] or {} prototypes
const restoreObjectPrototypeToJson = detachToJsonMethod(Object.prototype)
const restoreArrayPrototypeToJson = detachToJsonMethod(Array.prototype)
try {
// Initial call to sanitizeProcessor - will populate containerQueue if source is an Array or a plain Object
const containerQueue: ContainerElementToProcess[] = []
const visitedObjectsWithPath = new WeakMap<object, string>()
const sanitizedData = sanitizeProcessor(
source as ExtendedContextValue,
JSON_PATH_ROOT_ELEMENT,
undefined,
containerQueue,
visitedObjectsWithPath
)
const serializedSanitizedData = JSON.stringify(sanitizedData)
let accumulatedCharacterCount = serializedSanitizedData ? serializedSanitizedData.length : 0
if (accumulatedCharacterCount > maxCharacterCount) {
warnOverCharacterLimit(maxCharacterCount, 'discarded', source)
return undefined
}
while (containerQueue.length > 0 && accumulatedCharacterCount < maxCharacterCount) {
const containerToProcess = containerQueue.shift()!
let separatorLength = 0 // 0 for the first element, 1 for subsequent elements
// Arrays and Objects have to be handled distinctly to ensure
// we do not pick up non-numerical properties from Arrays
if (Array.isArray(containerToProcess.source)) {
for (let key = 0; key < containerToProcess.source.length; key++) {
const targetData = sanitizeProcessor(
containerToProcess.source[key],
containerToProcess.path,
key,
containerQueue,
visitedObjectsWithPath
)
if (targetData !== undefined) {
accumulatedCharacterCount += JSON.stringify(targetData).length
} else {
// When an element of an Array (targetData) is undefined, it is serialized as null:
// JSON.stringify([undefined]) => '[null]' - This accounts for 4 characters
accumulatedCharacterCount += 4
}
accumulatedCharacterCount += separatorLength
separatorLength = 1
if (accumulatedCharacterCount > maxCharacterCount) {
warnOverCharacterLimit(maxCharacterCount, 'truncated', source)
break
}
;(containerToProcess.target as ContextArray)[key] = targetData
}
} else {
for (const key in containerToProcess.source) {
if (Object.prototype.hasOwnProperty.call(containerToProcess.source, key)) {
const targetData = sanitizeProcessor(
containerToProcess.source[key],
containerToProcess.path,
key,
containerQueue,
visitedObjectsWithPath
)
// When a property of an object has an undefined value, it will be dropped during serialization:
// JSON.stringify({a:undefined}) => '{}'
if (targetData !== undefined) {
accumulatedCharacterCount +=
JSON.stringify(targetData).length + separatorLength + key.length + KEY_DECORATION_LENGTH
separatorLength = 1
}
if (accumulatedCharacterCount > maxCharacterCount) {
warnOverCharacterLimit(maxCharacterCount, 'truncated', source)
break
}
;(containerToProcess.target as Context)[key] = targetData
}
}
}
}
return sanitizedData
} finally {
// Rebind detached toJSON functions
restoreObjectPrototypeToJson()
restoreArrayPrototypeToJson()
}
}
/**
* Internal function to factorize the process common to the
* initial call to sanitize, and iterations for Arrays and Objects
*
*/
function sanitizeProcessor(
source: ExtendedContextValue,
parentPath: string,
key: string | number | undefined,
queue: ContainerElementToProcess[],
visitedObjectsWithPath: WeakMap<object, string>
) {
// Start by handling toJSON, as we want to sanitize its output
const sourceToSanitize = tryToApplyToJSON(source)
if (!sourceToSanitize || typeof sourceToSanitize !== 'object') {
return sanitizePrimitivesAndFunctions(sourceToSanitize)
}
const sanitizedSource = sanitizeObjects(sourceToSanitize)
if (sanitizedSource !== '[Object]' && sanitizedSource !== '[Array]' && sanitizedSource !== '[Error]') {
return sanitizedSource
}
// Handle potential cyclic references
// We need to use source as sourceToSanitize could be a reference to a new object
// At this stage, we know the source is an object type
const sourceAsObject = source as object
if (visitedObjectsWithPath.has(sourceAsObject)) {
return `[Reference seen at ${visitedObjectsWithPath.get(sourceAsObject)!}]`
}
// Add processed source to queue
const currentPath = key !== undefined ? `${parentPath}.${key}` : parentPath
const target = Array.isArray(sourceToSanitize) ? ([] as ContextArray) : ({} as Context)
visitedObjectsWithPath.set(sourceAsObject, currentPath)
queue.push({ source: sourceToSanitize, target, path: currentPath })
return target
}
/**
* Handles sanitization of simple, non-object types
*
*/
function sanitizePrimitivesAndFunctions(value: PrimitivesAndFunctions) {
// BigInt cannot be serialized by JSON.stringify(), convert it to a string representation
if (typeof value === 'bigint') {
return `[BigInt] ${value.toString()}`
}
// Functions cannot be serialized by JSON.stringify(). Moreover, if a faulty toJSON is present, it needs to be converted
// so it won't prevent stringify from serializing later
if (typeof value === 'function') {
return `[Function] ${value.name || 'unknown'}`
}
// JSON.stringify() does not serialize symbols.
if (typeof value === 'symbol') {
// symbol.description is part of ES2019+
type symbolWithDescription = symbol & { description: string }
return `[Symbol] ${(value as symbolWithDescription).description || value.toString()}`
}
return value
}
/**
* Handles sanitization of object types
*
* LIMITATIONS
* - If a class defines a toStringTag Symbol, it will fall in the catch-all method and prevent enumeration of properties.
* To avoid this, a toJSON method can be defined.
*/
function sanitizeObjects(value: object): string | SanitizedEvent {
try {
if (value instanceof Event) {
return sanitizeEvent(value)
}
if (value instanceof RegExp) {
return `[RegExp] ${value.toString()}`
}
// Handle all remaining object types in a generic way
const result = Object.prototype.toString.call(value)
const match = result.match(/\[object (.*)\]/)
if (match?.[1]) {
return `[${match[1]}]`
}
} catch {
// If the previous serialization attempts failed, and we cannot convert using
// Object.prototype.toString, declare the value unserializable
}
return '[Unserializable]'
}
function sanitizeEvent(event: Event): SanitizedEvent {
return {
type: event.type,
isTrusted: event.isTrusted,
currentTarget: event.currentTarget ? (sanitizeObjects(event.currentTarget) as string) : null,
target: event.target ? (sanitizeObjects(event.target) as string) : null,
}
}
/**
* Checks if a toJSON function exists and tries to execute it
*
*/
function tryToApplyToJSON(value: ExtendedContextValue) {
const object = value as ObjectWithToJsonMethod
if (object && typeof object.toJSON === 'function') {
try {
return object.toJSON() as ExtendedContextValue
} catch {
// If toJSON fails, we continue by trying to serialize the value manually
}
}
return value
}
/**
* Helper function to display the warning when the accumulated character count is over the limit
*/
function warnOverCharacterLimit(maxCharacterCount: number, changeType: 'discarded' | 'truncated', source: unknown) {
display.warn(
`The data provided has been ${changeType} as it is over the limit of ${maxCharacterCount} characters:`,
source
)
}