Skip to content

Commit 967fecf

Browse files
committed
refactor[devtools]: forbid editing class instances in props
1 parent 0ffc7f6 commit 967fecf

3 files changed

Lines changed: 75 additions & 5 deletions

File tree

packages/react-devtools-shared/src/__tests__/utils-test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import {
1111
getDisplayName,
1212
getDisplayNameForReactElement,
13+
isPlainObject,
1314
} from 'react-devtools-shared/src/utils';
1415
import {stackToComponentSources} from 'react-devtools-shared/src/devtools/utils';
1516
import {
@@ -270,4 +271,31 @@ describe('utils', () => {
270271
expect(gte('10.0.0', '9.0.0')).toBe(true);
271272
});
272273
});
274+
275+
describe('isPlainObject', () => {
276+
it('should return true for plain objects', () => {
277+
expect(isPlainObject({})).toBe(true);
278+
expect(isPlainObject({a: 1})).toBe(true);
279+
expect(isPlainObject({a: {b: {c: 123}}})).toBe(true);
280+
expect(isPlainObject(new Object())).toBe(true);
281+
});
282+
283+
it('should return false if object is a class instance', () => {
284+
expect(isPlainObject(new (class C {})())).toBe(false);
285+
});
286+
287+
it('should retun false for objects, which have not only Object in its prototype chain', () => {
288+
expect(isPlainObject([])).toBe(false);
289+
expect(isPlainObject(Symbol())).toBe(false);
290+
});
291+
292+
it('should retun false for primitives', () => {
293+
expect(isPlainObject(5)).toBe(false);
294+
expect(isPlainObject(true)).toBe(false);
295+
});
296+
297+
it('should return true for objects with no prototype', () => {
298+
expect(isPlainObject(Object.create(null))).toBe(true);
299+
});
300+
});
273301
});

packages/react-devtools-shared/src/hydration.js

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export type Unserializable = {
5252
size?: number,
5353
type: string,
5454
unserializable: boolean,
55-
...
55+
[string | number]: any,
5656
};
5757

5858
// This threshold determines the depth at which the bridge "dehydrates" nested data.
@@ -248,7 +248,6 @@ export function dehydrate(
248248
// Other types (e.g. typed arrays, Sets) will not spread correctly.
249249
Array.from(data).forEach(
250250
(item, i) =>
251-
// $FlowFixMe[prop-missing] Unserializable doesn't have an index signature
252251
(unserializableValue[i] = dehydrate(
253252
item,
254253
cleaned,
@@ -296,6 +295,7 @@ export function dehydrate(
296295

297296
case 'object':
298297
isPathAllowedCheck = isPathAllowed(path);
298+
299299
if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
300300
return createDehydrated(type, true, data, cleaned, path);
301301
} else {
@@ -316,15 +316,42 @@ export function dehydrate(
316316
return object;
317317
}
318318

319+
case 'class_instance':
320+
isPathAllowedCheck = isPathAllowed(path);
321+
322+
const value: Unserializable = {
323+
unserializable: true,
324+
type,
325+
readonly: true,
326+
preview_short: formatDataForPreview(data, false),
327+
preview_long: formatDataForPreview(data, true),
328+
name: data.constructor.name,
329+
};
330+
331+
getAllEnumerableKeys(data).forEach(key => {
332+
const keyAsString = key.toString();
333+
334+
value[keyAsString] = dehydrate(
335+
data[key],
336+
cleaned,
337+
unserializable,
338+
path.concat([keyAsString]),
339+
isPathAllowed,
340+
isPathAllowedCheck ? 1 : level + 1,
341+
);
342+
});
343+
344+
unserializable.push(path);
345+
346+
return value;
347+
319348
case 'infinity':
320349
case 'nan':
321350
case 'undefined':
322351
// Some values are lossy when sent through a WebSocket.
323352
// We dehydrate+rehydrate them to preserve their type.
324353
cleaned.push(path);
325-
return {
326-
type,
327-
};
354+
return {type};
328355

329356
default:
330357
return data;

packages/react-devtools-shared/src/utils.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,7 @@ export type DataType =
534534
| 'array_buffer'
535535
| 'bigint'
536536
| 'boolean'
537+
| 'class_instance'
537538
| 'data_view'
538539
| 'date'
539540
| 'function'
@@ -620,6 +621,9 @@ export function getDataType(data: Object): DataType {
620621
return 'html_all_collection';
621622
}
622623
}
624+
625+
if (!isPlainObject(data)) return 'class_instance';
626+
623627
return 'object';
624628
case 'string':
625629
return 'string';
@@ -835,6 +839,8 @@ export function formatDataForPreview(
835839
}
836840
case 'date':
837841
return data.toString();
842+
case 'class_instance':
843+
return data.constructor.name;
838844
case 'object':
839845
if (showFormattedValue) {
840846
const keys = Array.from(getAllEnumerableKeys(data)).sort(alphaSortKeys);
@@ -873,3 +879,12 @@ export function formatDataForPreview(
873879
}
874880
}
875881
}
882+
883+
// Basically checking that the object only has Object in its prototype chain
884+
export const isPlainObject = (object: Object): boolean => {
885+
const objectPrototype = Object.getPrototypeOf(object);
886+
if (!objectPrototype) return true;
887+
888+
const objectParentPrototype = Object.getPrototypeOf(objectPrototype);
889+
return objectParentPrototype == null;
890+
};

0 commit comments

Comments
 (0)