diff --git a/packages/csp/src/parser.js b/packages/csp/src/parser.js index 99ef7bd99..b4fc4d3bc 100644 --- a/packages/csp/src/parser.js +++ b/packages/csp/src/parser.js @@ -749,6 +749,20 @@ class Evaluator { return memberValue; case 'CallExpression': + // `JSON.parse()` with a single string argument is the one call on a + // global this build allows. Laravel's `Js::from()` and `@js()` emit it + // for every array and object, and parsing JSON runs no code. Nothing + // else on `JSON` is reachable, and a `JSON` defined in scope still wins... + if (this.isJsonParseIntrinsic(node, scope)) { + const json = this.evaluate({ node: node.arguments[0], scope, context, forceBindingRootScopeToFunctions }); + + if (typeof json !== 'string') { + throw new Error('JSON.parse() only accepts a single string argument in the CSP build'); + } + + return JSON.parse(json); + } + const args = node.arguments.map(arg => this.evaluate({ node: arg, scope, context, forceBindingRootScopeToFunctions })); let returnValue; @@ -963,6 +977,16 @@ class Evaluator { } } + isJsonParseIntrinsic(node, scope) { + return node.callee.type === 'MemberExpression' + && ! node.callee.computed + && node.callee.object.type === 'Identifier' + && node.callee.object.name === 'JSON' + && ! ('JSON' in scope) + && node.callee.property.name === 'parse' + && node.arguments.length === 1; + } + checkForDangerousValues(prop) { if (prop === null) { return diff --git a/packages/docs/src/en/advanced/csp.md b/packages/docs/src/en/advanced/csp.md index 4202bb0c0..b419399e5 100644 --- a/packages/docs/src/en/advanced/csp.md +++ b/packages/docs/src/en/advanced/csp.md @@ -111,6 +111,16 @@ The CSP build supports most JavaScript expressions you'd want to use in Alpine: ``` +### JSON.parse() +```alpine + +
+ +
+``` + +`JSON.parse()` with a single string argument is the one call on a global the CSP build allows. Server-side helpers such as Laravel's `@js()` and `Js::from()` emit it for every array and object, and parsing JSON runs no code. The rest of the `JSON` global stays unavailable. + ## What's Not Supported @@ -150,6 +160,8 @@ Some advanced and potentially dangerous JavaScript features aren't supported: ``` +The only exception is `JSON.parse()` with a string, see [above](#whats-supported). + ### HTML Injection ```alpine diff --git a/tests/cypress/integration/plugins/csp-compatibility.spec.js b/tests/cypress/integration/plugins/csp-compatibility.spec.js index 2ff1e2173..58a80249b 100644 --- a/tests/cypress/integration/plugins/csp-compatibility.spec.js +++ b/tests/cypress/integration/plugins/csp-compatibility.spec.js @@ -277,3 +277,19 @@ test.csp('throws when inserting a node into the DOM', cy.get('button').click() }, ) + +test.csp('supports JSON.parse() with a string, as emitted by Laravel\'s @js() for arrays and objects', + [html` +
+ +

+ +
+ `], + ({ get }) => { + get('span').should(haveText('3')) + get('h1').should(haveText('John')) + get('button').click() + get('span').should(haveText('1')) + } +) diff --git a/tests/vitest/csp-evaluator.spec.js b/tests/vitest/csp-evaluator.spec.js index ef4e53fe1..d646d6158 100644 --- a/tests/vitest/csp-evaluator.spec.js +++ b/tests/vitest/csp-evaluator.spec.js @@ -446,3 +446,57 @@ describe('DOM access restrictions', () => { expect(cspRawEvaluator(element, "list.append('a')", { scope })).toBe(1) }); }); + +describe('JSON.parse() intrinsic', () => { + let element = { parentNode: null, _x_dataStack: [] } + + it('parses an object from a string literal', () => { + expect(cspRawEvaluator(element, "JSON.parse('{\"recordKey\":\"1\",\"nested\":{\"a\":true,\"b\":null}}')")) + .toEqual({ recordKey: '1', nested: { a: true, b: null } }) + }); + + it('parses an array from a string literal', () => { + expect(cspRawEvaluator(element, "JSON.parse('[1,\"two\",false]')")).toEqual([1, 'two', false]) + }); + + it('parses the output of Laravel\'s Js::from(), with its unicode escapes', () => { + expect(cspRawEvaluator(element, "JSON.parse('{\\u0022a\\u0022:\\u0022b \\u003Cc\\u003E\\u0022}')")).toEqual({ a: 'b ' }) + }); + + it('can be used as an argument and as a member base', () => { + let scope = { count: (items) => items.length } + + expect(cspRawEvaluator(element, "count(JSON.parse('[1,2,3]'))", { scope })).toBe(3) + expect(cspRawEvaluator(element, "JSON.parse('{\"a\":{\"b\":2}}').a.b")).toBe(2) + }); + + it('returns a fresh value on every evaluation', () => { + let first = cspRawEvaluator(element, "JSON.parse('[1]')") + let second = cspRawEvaluator(element, "JSON.parse('[1]')") + + expect(first).not.toBe(second) + }); + + it('accepts a string from scope, not only a literal', () => { + let scope = { raw: '{"ok":true}' } + + expect(cspRawEvaluator(element, 'JSON.parse(raw)', { scope })).toEqual({ ok: true }) + }); + + it('rejects a non-string argument and a wrong arity', () => { + expect(() => cspRawEvaluator(element, 'JSON.parse(42)')).toThrow() + expect(() => cspRawEvaluator(element, "JSON.parse('[]', null)")).toThrow() + }); + + it('does not expose the rest of the JSON global', () => { + expect(() => cspRawEvaluator(element, 'JSON.stringify(1)')).toThrow('Undefined variable: JSON') + expect(() => cspRawEvaluator(element, 'JSON')).toThrow('Undefined variable: JSON') + expect(() => cspRawEvaluator(element, "JSON['parse']('[]')")).toThrow('Undefined variable: JSON') + }); + + it('lets a JSON defined in scope take precedence', () => { + let scope = { JSON: { parse: () => 'from scope' } } + + expect(cspRawEvaluator(element, "JSON.parse('[]')", { scope })).toBe('from scope') + }); +});