Skip to main content
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: Dan Lancelot
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: Dan Lancelot

Using The Angular.js Parser To Comply With CSP In Alpine.js 3.13.5

By
Published in

The Alpine.js library allows for expressions that run arbitrary JavaScript code. This is because, under the hood, Alpine.js is programmatically creating Function definitions using said code as the function body. This is very clever; but, it doesn't work in an application that blocks unsafe-eval using a Content Security Policy (CSP). The Alpine project has a CSP-compatible build. However, the consumable syntax is so reduced as to be almost unusable. As an experiment, I wanted to see if I could restore most of the non-CSP syntax—while staying CSP compliant—by stealing the Angular.js parser.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

Like Alpine.js, the Angular.js framework didn't require a build process. You could drop Angular.js into the page, wire-up an ng-controller (the Angular equivalent of Alpine's x-data), define some properties, and the page would automagically update when said properties were mutated.

Angular.js was able to do this while staying CSP compliant because it was actually parsing the JavaScript expressions into an AST (Abstract Syntax Tree); and then, interpreting said AST in the context of a given scope. As you can imagine, this requires much more code to implement; and, incurs a greater overhead when initializing a given HTML view.

This is (part of the reason) why the Angular.js framework is something like 180kb and Alpine.js is something like 44kb (minified but not compressed).

The way Alpine.js implemented its CSP-compatible build is that the framework allows the evaluator to be overridden. And, the CSP-build includes an evaluator that is restricted to simple, key-based look-ups within the data stack.

As a fun experiment, I want to see if I could take the Angular.js parser and use it to define my own evaluator. The parser code is long (~ 1,000 lines) and very convoluted. I essentially copy-pasted it; and then started deleting stuff where I could. I then exposed a parse() method that takes the given Alpine.js expression, builds the AST, and returns a Function that can evaluate it:

var NgParser = (function() {

	// .... hundreds of lines of borrowed Angular.js code ....

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	var lexer = null;
	var astCreator = null;
	var astCompiler = null;

	/**
	* I parse the given expression into an evaluator function.
	*/
	function parse( expression ) {

		lexer = ( lexer || new Lexer() );
		astCreator = ( astCreator || new AST( lexer ) );
		astCompiler = ( astCompiler || new ASTInterpreter() );

		return astCompiler.compile( astCreator.ast( expression ) );

	}

	return {
		Lexer: Lexer,
		AST: AST,
		ASTInterpreter: ASTInterpreter,
		parse: parse
	};

})();

As you can see, this JavaScript files defines a global NgParser object, which exposes a .parse() method. Internally, this method takes the given expression (string), build the AST, and then returns an evaluator function. This function has the signature:

evaluate( scope, locals )

In our case - with Alpine.js - the scope will be the merged data stack of the associated element; and, the locals will contain the immediate scope and params associated with the runtime invocation of the function (such as the x-for iteration scope and the x-on event argument).

I'm not going to go any deeper into the Angular.js code because 1) there's a ton of it and 2) I barely understand what it is doing. You can see it for yourself in the link repository.

That said, let's look at an Alpine.js demo that implements a CSP meta-tag and uses my custom evaluator. Keep in mind that in the native CSP-build (for Alpine), any expression with a . in it or a method call () is not allowed. As such, if this were the native CSP-build, expressions like counter.name, counter.value, and incrementCounter(counter) would break.

<!doctype html>
<html lang="en">
<head>
	<!-- Make sure we're not allowing any unsafe-eval work. -->
	<meta
		http-equiv="Content-Security-Policy"
		content="
			script-src 'nonce-abc123' 'strict-dynamic';
			object-src 'none';
			base-uri 'none';
		"
	/>
	<link rel="stylesheet" type="text/css" href="./main.css" />
</head>
<body x-data="app">

	<h1>
		Using The Angular.js Parser To Comply With CSP In Alpine.js 3.13.5
	</h1>

	<template x-for="counter in counters">
		<p>
			<button @click="incrementCounter( counter )">
				<span x-text="counter.name"></span>:
				<span x-text="counter.value"></span>
			</button>

			<template x-if="( counter.value > 0 )">
				<button @click="counter.value = 0; logReset( $event, counter )">
					Reset
				</button>
			</template>
		</p>
	</template>

	<p>
		<button @click="console.log( 'He he, that tickled!' )">
			Log to Console
		</button>
	</p>

	<!-- The Angular.js parser (the parts I ripped-out at least). -->
	<script type="text/javascript" nonce="abc123" src="./angular-parser-lite.js" defer></script>
	<!-- My custom evaluator that uses the Angular.js parser under the hood. -->
	<script type="text/javascript" nonce="abc123" src="./alpine.csp-experiment.js" defer></script>
	<!-- The CSP version of the Alpine build. -->
	<script type="text/javascript" nonce="abc123" src="../../vendor/alpine/3.13.5/alpine-csp.3.13.5.min.js" defer></script>
	<script type="text/javascript" nonce="abc123">

		// Confirm that eval() execution is blocked by the Content Security Policy (CSP).
		try {

			eval( "1+1" );
			console.warn( "Uh-oh, eval() was able to execute!" );

		} catch ( error ) {

			console.info( "The eval() function threw an error (this is good)." );

		}

		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //

		document.addEventListener(
			"alpine:init",
			function setupAlpineBindings() {

				Alpine.data( "app", AppController );

			}
		);

		/**
		* I control the app component.
		*/
		function AppController() {

			return {
				counters: [
					{
						name: "One",
						value: 0
					},
					{
						name: "Two",
						value: 5
					},
					{
						name: "Three",
						value: 10
					}
				],
				incrementCounter: function( counter ) {

					counter.value++;

				},
				logReset: function( event, counter ) {

					console.group( "Reset Counter" );
					console.log( counter );
					console.log( event );
					console.groupEnd();

				}
			};

		}

	</script>

</body>
</html>

As you can see, this Alpine.js demo is pretty vanilla; but, it includes things that should not work in a CSP build - include a console.log() call within an @click binding. However, when we run this code with the borrowed Angular.js parser, we get the following output:

As you can see, this worked just like the normal Alpine.js execution. Well, almost - the Angular.js parser does have limitations in what it can do when compared to the raw JavaScript that Alpine.js executes. But, this is pretty close to parity; and works in a strict CSP context.

Here's the code for my custom evaluator. This is not feature complete. For example, it doesn't inject any Magics - the code that Alpine.js uses to implement those mechanics are not exposed on the public Alpine object. But, this is just a proof-of-concept:

(function() {
	"use strict";

	document.addEventListener(
		"alpine:init",
		function setupAlpineBindings() {

			// EXPERIMENT: This is INCOMPLETE. But, this evaluator attempts to use
			// implement a CSP (Content Security Policy) compatible expression evaluator
			// using the Angular.js 1.8.2 parser / lexer / AST interpreter.
			Alpine.setEvaluator( ExperimentalEvaluator );

		}
	);

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	// Parsing expressions on-the-fly is expensive. In order to minimize the work, we want
	// to cache the resultant function based on the string input.
	var cache = createMap();

	/**
	* I parse the given expression attached to the given element.
	*/
	function ExperimentalEvaluator( element, expression ) {

		return generateEvaluatorFn( expression, Alpine.closestDataStack( element ) );

	}


	/**
	* I generate the evaluator function to be run anytime the given expression needs to be
	* evaluated (such as during the initial rendering or after an interaction).
	*/
	function generateEvaluatorFn( expression, dataStack ) {

		return evaluatorFn;

		/**
		* I evaluate the expression using the given locals (scope + params); and pass the
		* expression result to the given receiver.
		*/
		function evaluatorFn( receiver, locals ) {

			receiver = ( receiver || noop );
			locals = ( locals || createMap() );

			var scope = ( locals.scope || createMap() );
			var params = ( locals.params || [] );
			var completeScope = Alpine.mergeProxies([
				scope,
				...dataStack,
				// I'm including the WINDOW object at the top of the datastack so that
				// things like console.log() can be called from within expressions. This
				// is probably pretty dangerous! Yarrrrr!
				window
			]);

			// NOTE: If the expression is already a function reference, we're going to use
			// that instead of trying to parse it. I'm not sure what use-case this covers;
			// but, that seems to be what the core evaluator does.
			var expressionFn = ( typeof expression === "function" )
				? expression
				: parseExpressionUsingNgParser( expression )
			;

			var result = expressionFn( completeScope, locals );

			// If the expression evaluation resulted in a Function reference, we're going
			// to execute the reference in the context of the scope.
			if ( typeof result === "function" ) {

				receiver( result.apply( completeScope, params ) );

			} else {

				receiver( result );

			}

		};

	}


	/**
	* I parse the given expression using the Angular.js Lexer / AST / AST Interpreter. The
	* results are cached; and any subsequent call for the same expression will return the
	* cached result.
	*/
	function parseExpressionUsingNgParser( expression ) {

		if ( cache[ expression ] ) {

			return cache[ expression ];

		}

		return ( cache[ expression ] = NgParser.parse( expression ) );

	}


	/**
	* I do nothing and can be used anytime a generic function fallback is required.
	*/
	function noop() {
		// ...
	}


	/**
	* I create an empty object with no prototype chain (for simple look-ups).
	*/
	function createMap() {

		return Object.create( null );

	}

})();

All the heavy lifting is done by the NgParser.parse() method. Mostly, the custom evaluator is just gluing all the calls together and making sure that the correct scope is passed into the evaluator functions.

I think the Alpine.js build could be a little more flexible if it allowed the parser to be overridden instead of the whole evaluator. Right now, I'm missing logic in my proof-of-concept because I had to completely re-implement the evaluator. But, good enough for a fun Friday code kata!

Want to use code from this post? Check out the license.

Reader Comments

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel