Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/csp/src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/docs/src/en/advanced/csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ The CSP build supports most JavaScript expressions you'd want to use in Alpine:
</div>
```

### JSON.parse()
```alpine
<!-- ✅ This works -->
<div x-data="{ items: JSON.parse('[\u0022a\u0022,\u0022b\u0022]') }">
<span x-text="items.length"></span>
</div>
```

`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.

<a name="whats-not-supported"></a>
## What's Not Supported

Expand Down Expand Up @@ -150,6 +160,8 @@ Some advanced and potentially dangerous JavaScript features aren't supported:
</div>
```

The only exception is `JSON.parse()` with a string, see [above](#whats-supported).

### HTML Injection
```alpine
<!-- ❌ These don't work -->
Expand Down
16 changes: 16 additions & 0 deletions tests/cypress/integration/plugins/csp-compatibility.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<div x-data="{ items: JSON.parse('[\u0022a\u0022,\u0022b\u0022,\u0022c\u0022]'), user: JSON.parse('{\u0022name\u0022:\u0022John\u0022}') }">
<span x-text="items.length"></span>
<h1 x-text="user.name"></h1>
<button @click="items = JSON.parse('[1]')">Replace</button>
</div>
`],
({ get }) => {
get('span').should(haveText('3'))
get('h1').should(haveText('John'))
get('button').click()
get('span').should(haveText('1'))
}
)
54 changes: 54 additions & 0 deletions tests/vitest/csp-evaluator.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <c>' })
});

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