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

directive('html', () => {
throw new Error('Using the x-html directive is prohibited')

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add "Alpine CSP Error:" to this as well

})
11 changes: 10 additions & 1 deletion packages/csp/src/evaluator.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ export function cspEvaluator(el, expression) {
return generateEvaluatorFromFunction(dataStack, expression)
}

if (el instanceof HTMLIFrameElement) {
console.warn('Alpine CSP Error: Evaluating expressions on an iframe is prohibited')
throw new Error('Alpine CSP Error: Evaluating expressions on an iframe is prohibited')
}

if (el instanceof HTMLScriptElement) {
console.warn('Alpine CSP Error: Evaluating expressions on a script is prohibited')
throw new Error('Alpine CSP Error: Evaluating expressions on a script is prohibited')
}

let evaluator = generateEvaluator(el, expression, dataStack)

return tryCatch.bind(null, el, expression, evaluator)
Expand All @@ -33,7 +43,6 @@ function generateEvaluator(el, expression, dataStack) {

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

Expand Down
2 changes: 2 additions & 0 deletions packages/csp/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ import 'alpinejs/src/magics/index'

import 'alpinejs/src/directives/index'

import './directives/x-html' // Disable

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly more helpful comment here


export default Alpine
143 changes: 94 additions & 49 deletions packages/csp/src/parser.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
let globals = new Set()
Object.getOwnPropertyNames(globalThis).forEach(key => globals.add(globalThis[key]))

let safemap = new WeakMap()

class Token {
constructor(type, value, start, end) {
this.type = type;
Expand Down Expand Up @@ -642,47 +647,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 +700,39 @@ 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 +743,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 +786,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 +803,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 +827,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')
}
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 +863,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`)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add "Alpine CSP Error: " prefix to all csp related exceptions so people know where to point to

}
}

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

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

safemap.set(prop, true)

return true
}
}

export function generateRuntimeFunction(expression) {
Expand All @@ -867,9 +912,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
Loading