Skip to content
Merged
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
6 changes: 6 additions & 0 deletions packages/csp/src/directives/x-html.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { directive } from 'alpinejs/src/directives'
import { handleError } from 'alpinejs/src/utils/error'

directive('html', (el, { expression }) => {
handleError(new Error('Using the x-html directive is prohibited in the CSP build'), el)
})
9 changes: 8 additions & 1 deletion packages/csp/src/evaluator.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,21 @@ function generateDataStack(el) {
}

function generateEvaluator(el, expression, dataStack) {
if (el instanceof HTMLIFrameElement) {
throw new Error('Evaluating expressions on an iframe is prohibited in the CSP build')
}

if (el instanceof HTMLScriptElement) {
throw new Error('Evaluating expressions on a script is prohibited in the CSP build')
}

return (receiver = () => {}, { scope = {}, params = [] } = {}) => {
let completeScope = mergeProxies([scope, ...dataStack])

let evaluate = generateRuntimeFunction(expression)

let returnValue = evaluate({
scope: completeScope,
allowGlobal: false,
forceBindingRootScopeToFunctions: true,
})

Expand Down
8 changes: 7 additions & 1 deletion packages/csp/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ import { reactive, effect, stop, toRaw } from '@vue/reactivity'
Alpine.setReactivityEngine({ reactive, effect, release: stop, raw: toRaw })

import 'alpinejs/src/magics/index'

import 'alpinejs/src/directives/index'

/**
* The `x-html` directive needs to be disabled here
* because it is not CSP friendly. To disable it,
* we'll override it with noop implementation.
*/
import './directives/x-html'

export default Alpine
147 changes: 98 additions & 49 deletions packages/csp/src/parser.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
let safemap = new WeakMap()
let globals = new Set()

Object.getOwnPropertyNames(globalThis).forEach(key => {
// Prevent Safari deprecation warning...
if (key === 'styleMedia') return

globals.add(globalThis[key])
})

class Token {
constructor(type, value, start, end) {
this.type = type;
Expand Down Expand Up @@ -642,47 +652,47 @@ class Parser {
}

class Evaluator {
evaluate({ node, scope = {}, context = null, allowGlobal = false, forceBindingRootScopeToFunctions = true }) {
evaluate({ node, scope = {}, context = null, forceBindingRootScopeToFunctions = true }) {
switch (node.type) {
case 'Literal':
return node.value;

case 'Identifier':
if (node.name in scope) {
const value = scope[node.name];

this.checkForDangerousValues(value)

// If it's a function and we're accessing it directly (not calling it),
// bind it to scope to preserve 'this' context for later calls
if (typeof value === 'function') {
return value.bind(scope);
}
return value;
}

// Fallback to globals - let CSP catch dangerous ones at runtime
if (allowGlobal && typeof globalThis[node.name] !== 'undefined') {
const value = globalThis[node.name];
if (typeof value === 'function') {
return value.bind(globalThis);
}
return value;
}

throw new Error(`Undefined variable: ${node.name}`);

case 'MemberExpression':
const object = this.evaluate({ node: node.object, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
const object = this.evaluate({ node: node.object, scope, context, forceBindingRootScopeToFunctions });
if (object == null) {
throw new Error('Cannot read property of null or undefined');
}

let memberValue;
let property;
if (node.computed) {
const property = this.evaluate({ node: node.property, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
memberValue = object[property];
property = this.evaluate({ node: node.property, scope, context, forceBindingRootScopeToFunctions });
} else {
memberValue = object[node.property.name];
property = node.property.name;
}

this.checkForDangerousKeywords(property)

let memberValue = object[property];

this.checkForDangerousValues(memberValue)

// If the accessed value is a function, bind it based on forceBindingRootScopeToFunctions flag
if (typeof memberValue === 'function') {
if (forceBindingRootScopeToFunctions) {
Expand All @@ -695,34 +705,38 @@ class Evaluator {
return memberValue;

case 'CallExpression':
const args = node.arguments.map(arg => this.evaluate({ node: arg, scope, context, allowGlobal, forceBindingRootScopeToFunctions }));
const args = node.arguments.map(arg => this.evaluate({ node: arg, scope, context, forceBindingRootScopeToFunctions }));

let returnValue;

if (node.callee.type === 'MemberExpression') {
// For member expressions, get the object and function separately to preserve context
const obj = this.evaluate({ node: node.callee.object, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
let func;
const obj = this.evaluate({ node: node.callee.object, scope, context, forceBindingRootScopeToFunctions });

let prop;
if (node.callee.computed) {
const prop = this.evaluate({ node: node.callee.property, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
func = obj[prop];
prop = this.evaluate({ node: node.callee.property, scope, context, forceBindingRootScopeToFunctions });
} else {
func = obj[node.callee.property.name];
prop = node.callee.property.name
}

this.checkForDangerousKeywords(prop)

let func = obj[prop];
if (typeof func !== 'function') {
throw new Error('Value is not a function');
}

// For member expressions, always use the object as the 'this' context
return func.apply(obj, args);
returnValue = func.apply(obj, args);
} else {
// For direct function calls (identifiers), get the original function and apply context
if (node.callee.type === 'Identifier') {
const name = node.callee.name;

let func;
if (name in scope) {
func = scope[name];
} else if (allowGlobal && typeof globalThis[name] !== 'undefined') {
func = globalThis[name];
} else {
throw new Error(`Undefined variable: ${name}`);
}
Expand All @@ -733,21 +747,25 @@ class Evaluator {

// For direct calls, use provided context or the scope
const thisContext = context !== null ? context : scope;
return func.apply(thisContext, args);
returnValue = func.apply(thisContext, args);
} else {
// For other expressions
const callee = this.evaluate({ node: node.callee, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
const callee = this.evaluate({ node: node.callee, scope, context, forceBindingRootScopeToFunctions });
if (typeof callee !== 'function') {
throw new Error('Value is not a function');
}

// For other expressions, use provided context
return callee.apply(context, args);
returnValue = callee.apply(context, args);
}
}

this.checkForDangerousValues(returnValue)

return returnValue

case 'UnaryExpression':
const argument = this.evaluate({ node: node.argument, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
const argument = this.evaluate({ node: node.argument, scope, context, forceBindingRootScopeToFunctions });
switch (node.operator) {
case '!': return !argument;
case '-': return -argument;
Expand All @@ -772,9 +790,9 @@ class Evaluator {

return node.prefix ? scope[name] : oldValue;
} else if (node.argument.type === 'MemberExpression') {
const obj = this.evaluate({ node: node.argument.object, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
const obj = this.evaluate({ node: node.argument.object, scope, context, forceBindingRootScopeToFunctions });
const prop = node.argument.computed
? this.evaluate({ node: node.argument.property, scope, context, allowGlobal, forceBindingRootScopeToFunctions })
? this.evaluate({ node: node.argument.property, scope, context, forceBindingRootScopeToFunctions })
: node.argument.property.name;

const oldValue = obj[prop];
Expand All @@ -789,8 +807,8 @@ class Evaluator {
throw new Error('Invalid update expression target');

case 'BinaryExpression':
const left = this.evaluate({ node: node.left, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
const right = this.evaluate({ node: node.right, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
const left = this.evaluate({ node: node.left, scope, context, forceBindingRootScopeToFunctions });
const right = this.evaluate({ node: node.right, scope, context, forceBindingRootScopeToFunctions });

switch (node.operator) {
case '+': return left + right;
Expand All @@ -813,41 +831,34 @@ class Evaluator {
}

case 'ConditionalExpression':
const test = this.evaluate({ node: node.test, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
const test = this.evaluate({ node: node.test, scope, context, forceBindingRootScopeToFunctions });
return test
? this.evaluate({ node: node.consequent, scope, context, allowGlobal, forceBindingRootScopeToFunctions })
: this.evaluate({ node: node.alternate, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
? this.evaluate({ node: node.consequent, scope, context, forceBindingRootScopeToFunctions })
: this.evaluate({ node: node.alternate, scope, context, forceBindingRootScopeToFunctions });

case 'AssignmentExpression':
const value = this.evaluate({ node: node.right, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
const value = this.evaluate({ node: node.right, scope, context, forceBindingRootScopeToFunctions });

if (node.left.type === 'Identifier') {
scope[node.left.name] = value;
return value;
} else if (node.left.type === 'MemberExpression') {
const obj = this.evaluate({ node: node.left.object, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
if (node.left.computed) {
const prop = this.evaluate({ node: node.left.property, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
obj[prop] = value;
} else {
obj[node.left.property.name] = value;
}
return value;
throw new Error('Property assignments are prohibited in the CSP build')
}
throw new Error('Invalid assignment target');

case 'ArrayExpression':
return node.elements.map(el => this.evaluate({ node: el, scope, context, allowGlobal, forceBindingRootScopeToFunctions }));
return node.elements.map(el => this.evaluate({ node: el, scope, context, forceBindingRootScopeToFunctions }));

case 'ObjectExpression':
const result = {};
for (const prop of node.properties) {
const key = prop.computed
? this.evaluate({ node: prop.key, scope, context, allowGlobal, forceBindingRootScopeToFunctions })
? this.evaluate({ node: prop.key, scope, context, forceBindingRootScopeToFunctions })
: prop.key.type === 'Identifier'
? prop.key.name
: this.evaluate({ node: prop.key, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
const value = this.evaluate({ node: prop.value, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
: this.evaluate({ node: prop.key, scope, context, forceBindingRootScopeToFunctions });
const value = this.evaluate({ node: prop.value, scope, context, forceBindingRootScopeToFunctions });
result[key] = value;
}
return result;
Expand All @@ -856,6 +867,44 @@ class Evaluator {
throw new Error(`Unknown node type: ${node.type}`);
}
}

checkForDangerousKeywords(keyword) {
let blacklist = [
'constructor', 'prototype', '__proto__',
'__defineGetter__', '__defineSetter__',
'insertAdjacentHTML',
]

if (blacklist.includes(keyword)) {
throw new Error(`Accessing "${keyword}" is prohibited in the CSP build`)
}
}

checkForDangerousValues(prop) {
if (prop === null) {
return
}

if (typeof prop !== 'object' && typeof prop !== 'function') {
return
}

if (safemap.has(prop)) {
return
}

if (prop instanceof HTMLIFrameElement || prop instanceof HTMLScriptElement) {
throw new Error('Accessing iframes and scripts is prohibited in the CSP build')
}

if (globals.has(prop)) {
throw new Error('Accessing global variables is prohibited in the CSP build')
}

safemap.set(prop, true)

return true
}
}

export function generateRuntimeFunction(expression) {
Expand All @@ -867,9 +916,9 @@ export function generateRuntimeFunction(expression) {
const evaluator = new Evaluator();

return function(options = {}) {
const { scope = {}, context = null, allowGlobal = false, forceBindingRootScopeToFunctions = false } = options;
const { scope = {}, context = null, forceBindingRootScopeToFunctions = false } = options;
// Use the scope directly - mutations are expected for assignments
return evaluator.evaluate({ node: ast, scope, context, allowGlobal, forceBindingRootScopeToFunctions });
return evaluator.evaluate({ node: ast, scope, context, forceBindingRootScopeToFunctions });
};
} catch (error) {
throw new Error(`CSP Parser Error: ${error.message}`);
Expand Down
44 changes: 29 additions & 15 deletions packages/docs/src/en/advanced/csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,31 +106,23 @@ The CSP build supports most JavaScript expressions you'd want to use in Alpine:
### Method Calls
```alpine
<!-- ✅ These work -->
<div x-data="{ items: ['a', 'b'], getMessage: () => 'Hello' }">
<span x-text="getMessage()"></span>
<div x-data="{ items: ['a', 'b'] }">
<button x-on:click="items.push('c')">Add Item</button>
</div>
```

### Global Variables and Functions
```alpine
<!-- ✅ These work -->
<div x-data="{ count: 42 }">
<button x-on:click="console.log('Count is:', count)">Log Count</button>
<span x-text="Math.max(count, 100)"></span>
<span x-text="parseInt('123') + count"></span>
<span x-text="JSON.stringify({ value: count })"></span>
</div>
```

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

Some advanced JavaScript features aren't supported:
Some advanced and potentially dangerous JavaScript features aren't supported:

### Complex Expressions
```alpine
<!-- ❌ These don't work -->
<div x-data>
<div x-data="{ user: { name: '' } }">
<!-- Property assignments -->
<button x-on:click="user.name = 'John'">Bad</button>

<!-- Arrow functions -->
<button x-on:click="() => console.log('hi')">Bad</button>

Expand All @@ -145,6 +137,28 @@ Some advanced JavaScript features aren't supported:
</div>
```

### Global Variables and Functions
```alpine
<!-- ❌ These don't work -->
<div x-data>
<button x-on:click="console.log('hi')"></button>
<span x-text="document.title"></span>
<span x-text="window.innerWidth"></span>
<span x-text="Math.max(count, 100)"></span>
<span x-text="parseInt('123') + count"></span>
<span x-text="JSON.stringify({ value: count })"></span>
</div>
```

### HTML Injection
```alpine
<!-- ❌ These don't work -->
<div x-data="{ message: 'Hello <span>World</span>' }">
<span x-html="message"></span>
<span x-init="$el.insertAdjacentHTML('beforeend', message)"></span>
</div>
```

<a name="when-to-extract-logic"></a>
## When to Extract Logic

Expand Down
Loading