Alpine.js directives and WordPress sanitization
Tags: wordpress, alpinejs, sanitization, webdev
Published: 2020-10-06 16:04:32

WordPress has functions with sensible defaults for when you want to filter untrusted HTML. To extend the default allowed list, you can use the `wp_kses_allowed_html` filter.

Mostly I see people reaching for this when dealing with complex SVG structures where lesser-known tags and attributes pop up.

Another scenario is when working with *JavaScript libraries using directives*, such as [Alpine.js](https://github.com/alpinejs/alpine/) or [Vue.js]([https://vuejs.org/]).

## The problem with directives and WordPress

By default, if you run trough `wp_kses_post` an HTML containing Alpine.js directives, all are stripped out.

```php
$alpineJsHtml = <<<'EOD'
    <div x-data="{ isVisible: false }">
        <button type="button"
            x-on:click.debounce.250="isVisible = !isVisible">
            Toggle with debounce
        </button>
        <p x-show="isVisible">I'm visible</p>
    </div>
EOD;

echo wp_kses_post($alpineJsHtml);
```

The output:

```html
<div>
    <button type="button">
        Toggle with debounce
    </button>
    <p>I'm visible</p>
</div>
```

Behind the scenes, WordPress determines if an attribute is allowed using the `wp_kses_attr_check` function. Besides the `data-*` [wildcard attribute](https://github.com/WordPress/WordPress/blob/1fbcdb22135c1325ae606b71347f1c1aa56451d6/wp-includes/kses.php#L1201-L1211), all other attributes have to be *exactly predefined*, and unfortunately, there is no filter attached to the function to pass custom conditions.

Alpine.js (`2.6.0`) has 14 directives, and most of them don't have a dynamic part or modifiers. Allowing them could look something like this:

```php

add_filter(
    'wp_kses_allowed_html',
    static function ($tags) {
        $alpinizedTags = ['div', 'section', 'template'];
        $alpineDirectives = [
            'x-cloak' => true,
            'x-data' => true,
            'x-if' => true,
            // ...
        ];

        foreach ($alpinizedTags as $alpinizedTag) {
            if (!isset($tags[$alpinizedTag])) {
                continue;
            }

            $tags[$alpinizedTag] = array_merge($alpineDirectives, $tags[$alpinizedTag]);
        }

        return $tags;
    }
);
```

## Dynamic directives and modifiers

But with directives like `x-on` where you can listen for custom events, and add modifiers that represent milliseconds, there is no sane way to predefine all possible variations. Remember, the allowed attribute has to be an exact match; no wildcards are supported.

**One option** is to simply update the list whenever you use a new combination.

```php
$alpineDirectives = [
    'x-on' => true, // not enough
    'x-on:click.debounce.250' => true,
    'x-on:change' => true,
    'x-on:open-menu' => 'true',
    // ...
];
```

**Another solution** is to deal with directives case by case basis instead of relying on a global list. This is how it would look with the `wp_kses` function:

```php
echo wp_kses($alpineJsHtml, [
    'div' => [
        'x-data' => true,
    ],
    'button' => [
        'type' => true,
        'x-on:click.debounce.250' => true,
    ]
]);
```

But we can take **this idea further** and construct the list of directives dynamically and allowing them automatically.

```php
function allowedPostTagsWithAlpineAttrs($content)
{
    global $allowedposttags;

    // Anything that looks like an Alpine.js directive
    preg_match_all('/(x-[\w:.-]*)/', $content, $matches);

    if ($matches === false || empty($matches[0])) {
        return $allowedposttags;
    }

    $allowedTags = $allowedposttags;
    $alpineAttrs = [];

    foreach ($matches[0] as $match) {
        $alpineAttrs[$match] = true;
    }

    foreach ($allowedTags as $tag => $attributes) {
        $allowedTags[$tag] = array_merge($alpineAttrs, $attributes);
    }

    return $allowedTags;
}

echo wp_kses($alpineJsHtml, allowedPostTagsWithAlpineAttrs($alpineJsHtml));
```

With this approach, we don't have to keep track of the modifiers or update the directives list if Alpine.js introduces a new one.