Skip to content

Commit

Permalink
Add CSP-friendly Alpine build (#3959)
Browse files Browse the repository at this point in the history
* refactor csp build

* document
  • Loading branch information
calebporzio authored Jan 4, 2024
1 parent b24d8be commit 8c1511a
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 54 deletions.
50 changes: 50 additions & 0 deletions packages/csp/src/evaluator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { generateEvaluatorFromFunction, runIfTypeOfFunction } from 'alpinejs/src/evaluator'
import { closestDataStack, mergeProxies } from 'alpinejs/src/scope'
import { tryCatch } from 'alpinejs/src/utils/error'
import { injectMagics } from 'alpinejs/src/magics'

export function cspEvaluator(el, expression) {
let dataStack = generateDataStack(el)

// Return if the provided expression is already a function...
if (typeof expression === 'function') {
return generateEvaluatorFromFunction(dataStack, expression)
}

let evaluator = generateEvaluator(el, expression, dataStack)

return tryCatch.bind(null, el, expression, evaluator)
}

function generateDataStack(el) {
let overriddenMagics = {}

injectMagics(overriddenMagics, el)

return [overriddenMagics, ...closestDataStack(el)]
}

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

if (completeScope[expression] === undefined) {
throwExpressionError(el, expression)
}

runIfTypeOfFunction(receiver, completeScope[expression], completeScope, params)
}
}

function throwExpressionError(el, expression) {
console.warn(
`Alpine Error: Alpine is unable to interpret the following expression using the CSP-friendly build:
"${expression}"
Read more about the Alpine's CSP-friendly build restrictions here: https://alpinejs.dev/advanced/csp
`,
el
)
}
57 changes: 28 additions & 29 deletions packages/csp/src/index.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,37 @@
/**
* Alpine CSP Build.
*
* Alpine allows you to use JavaScript directly inside your HTML. This is an
* incredibly powerful features. However, it violates the "unsafe-eval"
* Content Security Policy. This alternate Alpine build provides a
* more constrained API for Alpine that is also CSP-friendly...
*/
import Alpine from 'alpinejs/src/alpine'

Alpine.setEvaluator(cspCompliantEvaluator)

/**
* _______________________________________________________
* The Evaluator
* -------------------------------------------------------
*
* By default, Alpine's evaluator "eval"-like utilties to
* interpret strings as runtime JS. We're going to use
* a more CSP-friendly evaluator for this instead.
*/
import { cspEvaluator } from './evaluator'

Alpine.setEvaluator(cspEvaluator)

/**
* The rest of this file bootstraps Alpine the way it is
* normally bootstrapped in the default build. We will
* set and define it's directives, magics, etc...
*/
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'

import { closestDataStack, mergeProxies } from 'alpinejs/src/scope'
import { injectMagics } from 'alpinejs/src/magics'
import { generateEvaluatorFromFunction, runIfTypeOfFunction } from 'alpinejs/src/evaluator'
import { tryCatch } from 'alpinejs/src/utils/error'

function cspCompliantEvaluator(el, expression) {
let overriddenMagics = {}

injectMagics(overriddenMagics, el)

let dataStack = [overriddenMagics, ...closestDataStack(el)]

if (typeof expression === 'function') {
return generateEvaluatorFromFunction(dataStack, expression)
}

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

if (completeScope[expression] !== undefined) {
runIfTypeOfFunction(receiver, completeScope[expression], completeScope, params)
}
}

return tryCatch.bind(null, el, expression, evaluator)
}
import 'alpinejs/src/directives/index'

export default Alpine
85 changes: 63 additions & 22 deletions packages/docs/src/en/advanced/csp.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,87 @@
---
order: 5
order: 1
title: CSP
---

# CSP (Content-Security Policy)
# CSP (Content-Security Policy) Build

In order for Alpine to be able to execute plain strings from HTML attributes as JavaScript expressions, for example `x-on:click="console.log()"`, it needs to rely on utilities that violate the "unsafe-eval" content security policy.
In order for Alpine to be able to execute plain strings from HTML attributes as JavaScript expressions, for example `x-on:click="console.log()"`, it needs to rely on utilities that violate the "unsafe-eval" [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) that some applications may enforce for security purposes.

> Under the hood, Alpine doesn't actually use eval() itself because it's slow and problematic. Instead it uses Function declarations, which are much better, but still violate "unsafe-eval".
In order to accommodate environments where this CSP is necessary, Alpine will offer an alternate build that doesn't violate "unsafe-eval", but has a more restrictive syntax.
In order to accommodate environments where this CSP is necessary, Alpine offer's an alternate build that doesn't violate "unsafe-eval", but has a more restrictive syntax.

<a name="installation"></a>
## Installation

The CSP build hasn’t been officially released yet. In the meantime, you may build it from source. To do this, clone the [`alpinejs/alpine`](https://github.com/alpinejs/alpine) repository and run:
You can use this build by either including it from a `<script>` tag or installing it via NPM:

```shell
npm install
npm run build
### Via CDN

You can include this build's CDN as a `<script>` tag just like you would normally with standard Alpine build:

```alpine
<!-- Alpine's CSP-friendly Core -->
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/csp@3.x.x/dist/cdn.min.js"></script>
```

This will generate a `/packages/csp/dist/` directory with the built files. After copying the appropriate file into your project, you can include it either via `<script>` tag or module import:
### Via NPM

<a name="script-tag"></a>
### Script tag
You can alternatively install this build from NPM for use inside your bundle like so:

```alpine
<html>
<script src="/path/to/cdn.js" defer></script>
</html>
```shell
npm install @alpinejs/csp
```

<a name="module-import"></a>
### Module import
Then initialize it from your bundle:

```js
import Alpine from './path/to/module.esm.js'
import Alpine from '@alpinejs/csp'

window.Alpine = Alpine
window.Alpine.start()

Alpine.start()
```

<a name="restrictions"></a>
## Restrictions
<a name="basic-example"></a>
## Basic Example

To provide a glimpse of how using the CSP build might feel, here is a copy-pastable HTML file with a working counter componennt using a common CSP setup:

```alpine
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'nonce-a23gbfz9e'">
<script defer nonce="a23gbfz9e" src="https://cdn.jsdelivr.net/npm/@alpinejs/csp@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div x-data="counter">
<button x-on:click="count++"></button>
<span x-text="count"></span>
</div>
<script nonce="a23gbfz9e">
document.addEventListener('alpine:init', () => {
Alpine.data('counter', () => {
return {
count: 1,
increment() {
this.count++;
},
}
})
})
</script>
</body>
</html>
```

<a name="api-restrictions"></a>
## API Restrictions

Since Alpine can no longer interpret strings as plain JavaScript, it has to parse and construct JavaScript functions from them manually.

Expand All @@ -70,10 +108,13 @@ However, breaking out the expressions into external APIs, the following is valid
<span x-text="count"></span>
</div>
```

```js
Alpine.data('counter', () => ({
count: 1,

increment() { this.count++ }
increment() {
this.count++
},
}))
```
4 changes: 2 additions & 2 deletions packages/docs/src/en/advanced/extending.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
order: 2
order: 3
title: Extending
---

Expand Down Expand Up @@ -229,7 +229,7 @@ Now if the directive is removed from this element or the element is removed itse

By default, any new directive will run after the majority of the standard ones (with the exception of `x-teleport`). This is usually acceptable but some times you might need to run your custom directive before another specific one.
This can be achieved by chaining the `.before() function to `Alpine.directive()` and specifying which directive needs to run after your custom one.

```js
Alpine.directive('foo', (el, { value, modifiers, expression }) => {
Alpine.addScopeToNode(el, {foo: 'bar'})
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/en/advanced/reactivity.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
order: 1
order: 2
title: Reactivity
---

Expand Down

0 comments on commit 8c1511a

Please sign in to comment.