XSS and Escaping

Every .phtml has an $escaper, and the method you pick has to match the context the value lands in.

Syntax$escaper->escapeHtml($data, $allowedTags = null): string

Cross-site scripting (XSS) is output that a browser reads as markup instead of text. Magento's answer is one object. app/etc/di.xml passes Magento\Framework\Escaper into the template engine as a block variable, so every .phtml has $escaper without declaring or injecting anything.

One method per context

MethodUse it for
escapeHtml($data, $allowedTags = null)text between tags
escapeHtmlAttr($string, $escapeSingleQuote = true)an attribute value
escapeUrl($string)href and src
escapeJs($string)inside a JavaScript string
escapeCss($string)a CSS value

The context is the question, not the data. A name escaped with escapeHtml and then dropped into title="..." can still close the attribute. In templates <?= $escaper->escapeHtml($name) ?> is the short form of <?php echo $escaper->escapeHtml($name); ?>; they are the same thing, and core uses the short one.

Deprecated methods, and admin grids

escapeJsQuote(), escapeQuote() and escapeXssInUrl() were deprecated in framework 101.0.0. Use the five above.

An admin UI component grid renders each cell through Knockout's text binding, which escapes. Set a column's bodyTmpl to ui/grid/cells/html and the cell switches to the html binding and getLabelUnsanitizedHtml(), so a product name containing a tag renders as markup in the admin.

After editing a template run bin/magento cache:flush. In production mode also delete var/view_preprocessed, where minified copies of templates are kept.

Example

Example · php
<?php
// app/code/Vendor/Loyalty/view/frontend/templates/points/summary.phtml
/**
 * @var \Magento\Framework\View\Element\Template $block
 * @var \Magento\Framework\Escaper $escaper
 */
/** @var \Vendor\Loyalty\ViewModel\PointsSummary $viewModel */
$viewModel = $block->getData('view_model');
?>
<div class="loyalty-summary">
    <p class="loyalty-balance" title="<?= $escaper->escapeHtmlAttr($viewModel->getTierLabel()) ?>">
        <?= $escaper->escapeHtml(__('Balance: %1 points', $viewModel->getBalance())) ?>
    </p>

    <a href="<?= $escaper->escapeUrl($viewModel->getHistoryUrl()) ?>">
        <?= $escaper->escapeHtml(__('Point history')) ?>
    </a>

    <?php // The same call written longhand - identical output. ?>
    <p><?php echo $escaper->escapeHtml($viewModel->getTierLabel()); ?></p>
</div>
<script type="text/x-magento-init">
    {
        "*": {
            "Vendor_Loyalty/js/points": {
                "tier": "<?= $escaper->escapeJs($viewModel->getTierCode()) ?>"
            }
        }
    }
</script>

When to use it

  • Pass a code review that rejects any .phtml echoing a value without the escaper.
  • Fix a storefront template that puts an unescaped name into a title attribute.
  • Decide between escapeHtml and escapeHtmlAttr for a value used in both places on one page.
  • Explain why an admin grid column renders a stored field as live markup.

More examples

The grid column that renders a product name as markup

The first column uses the default body template, ui/grid/cells/text, which binds with text= and escapes. The second uses ui/grid/cells/html, which binds with html= and calls getLabelUnsanitizedHtml($row()), so a value of <img src=x onerror=alert(1)> executes for the admin user viewing the grid. Apply the change with bin/magento cache:flush, then reload the grid. Reach for bodyTmpl only when the column's value is markup you generated and never a stored field.

Example · xml
<!-- app/code/Vendor/Loyalty/view/adminhtml/ui_component/vendor_loyalty_ledger_listing.xml -->
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <columns name="vendor_loyalty_ledger_columns">
        <column name="product_name">
            <settings>
                <label translate="true">Product</label>
            </settings>
        </column>

        <!-- The same column switched to the unescaped body template.
             Only do this for markup your own code built. -->
        <column name="reason_html">
            <settings>
                <bodyTmpl>ui/grid/cells/html</bodyTmpl>
                <label translate="true">Reason</label>
            </settings>
        </column>
    </columns>
</listing>

Escaping in PHP, where there is no $escaper variable

$escaper only exists inside a template. In a block, a view model or a UI component column class, inject Magento\Framework\Escaper. Note the two different methods in one string: the order number sits in an attribute and needs escapeHtmlAttr, while the reason sits between tags and needs escapeHtml. This is the shape to use when a column class is the thing feeding ui/grid/cells/html.

Example · php
<?php
// app/code/Vendor/Loyalty/Block/Adminhtml/Ledger/Reason.php
declare(strict_types=1);

namespace Vendor\Loyalty\Block\Adminhtml\Ledger;

use Magento\Framework\Escaper;
use Magento\Framework\View\Element\Template;
use Magento\Framework\View\Element\Template\Context;

class Reason extends Template
{
    public function __construct(
        Context $context,
        private readonly Escaper $escaperService,
        array $data = []
    ) {
        parent::__construct($context, $data);
    }

    public function getReasonHtml(string $reason, string $orderIncrementId): string
    {
        return '<span class="loyalty-reason" data-order="'
            . $this->escaperService->escapeHtmlAttr($orderIncrementId)
            . '">'
            . $this->escaperService->escapeHtml($reason)
            . '</span>';
    }
}

Allowing a short tag list from admin-authored content

The second argument of escapeHtml is a list of tags to leave intact, for text an admin typed into a configuration field or a CMS block. Anything outside the list is escaped, and script, img, embed, iframe, video, source, object and audio are removed from your list before it is used. Reach for this when a field is documented as accepting simple formatting, and never for a value a customer can set.

Example · php
<?php
// app/code/Vendor/Loyalty/view/frontend/templates/points/terms.phtml
/**
 * @var \Magento\Framework\View\Element\Template $block
 * @var \Magento\Framework\Escaper $escaper
 */
$terms = (string) $block->getData('terms_text');
?>
<div class="loyalty-terms">
    <?= $escaper->escapeHtml($terms, ['p', 'strong', 'em', 'a', 'ul', 'li']) ?>
</div>

<?php
// Input:  <p>Points expire after <strong>12 months</strong>.</p>
//         <script>alert(1)</script>
// Output: <p>Points expire after <strong>12 months</strong>.</p>
//         alert(1)
//
// A tag outside the list is not escaped into visible markup: Escaper
// replaces the whole element with a text node holding its text content,
// so the <script> wrapper is dropped and only the escaped inner text
// (alert(1)) survives. An <img> has no text content, so it disappears.
?>

Discussion

  • Be the first to comment on this lesson.