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')
+ });
+});