Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/woocommerce-pos/includes/Services/Receipt_Renderer_Factory.php
+++ b/woocommerce-pos/includes/Services/Receipt_Renderer_Factory.php
@@ -10,6 +10,7 @@
use WCPOSWooCommercePOSInterfacesReceipt_Renderer_Interface;
use WCPOSWooCommercePOSTemplatesRenderersLegacy_Php_Renderer;
use WCPOSWooCommercePOSTemplatesRenderersLogicless_Renderer;
+use WCPOSWooCommercePOSTemplatesRenderersThermal_Html_Renderer;
/**
* Receipt_Renderer_Factory class.
@@ -26,6 +27,11 @@
switch ( $engine ) {
case 'logicless':
return new Logicless_Renderer();
+ case 'thermal':
+ // Thermal content is stored raw (kses-exempt), so it must render
+ // through the thermal pipeline — never the PHP-executing legacy
+ // renderer, which would treat the content as executable PHP.
+ return new Thermal_Html_Renderer();
case 'legacy-php':
default:
return new Legacy_Php_Renderer();
--- a/woocommerce-pos/includes/Templates/Renderers/Thermal_Html_Renderer.php
+++ b/woocommerce-pos/includes/Templates/Renderers/Thermal_Html_Renderer.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Thermal HTML receipt renderer.
+ *
+ * Renders a thermal (receipt-printer) template to HTML for the browser print
+ * surface. Thermal template content is stored raw — it is exempt from wp_kses
+ * because it is XML markup for printers, not HTML — so it must NEVER be executed
+ * as PHP. This renderer runs the content through the thermal pipeline
+ * (Mustache render -> XML AST -> HTML), which escapes receipt data and discards
+ * anything that is not recognised thermal markup (PHP processing instructions
+ * included). The include-based Legacy_Php_Renderer must never receive thermal
+ * content.
+ *
+ * @package WCPOSWooCommercePOSTemplatesRenderers
+ */
+
+namespace WCPOSWooCommercePOSTemplatesRenderers;
+
+use WCPOSWooCommercePOSInterfacesReceipt_Renderer_Interface;
+use WCPOSWooCommercePOSLogger;
+use WCPOSWooCommercePOSTemplatesThermalHtml_Thermal_Emitter;
+use WCPOSWooCommercePOSTemplatesThermalThermal_Renderer;
+use WC_Abstract_Order;
+
+/**
+ * Thermal_Html_Renderer class.
+ */
+class Thermal_Html_Renderer implements Receipt_Renderer_Interface {
+ /**
+ * Render a thermal template to HTML.
+ *
+ * @param array $template Template metadata/content.
+ * @param WC_Abstract_Order|null $order Order object, or null for sample-data preview.
+ * @param array $receipt_data Canonical receipt payload (unused; the thermal pipeline rebuilds its own data).
+ */
+ public function render( array $template, ?WC_Abstract_Order $order, array $receipt_data ): void {
+ // The thermal pipeline builds its receipt data from a concrete order.
+ if ( ! $order instanceof WC_Abstract_Order ) {
+ echo '<!-- Thermal receipt preview requires an order -->';
+ return;
+ }
+
+ $paper_width_px = $this->paper_width_px( $template );
+
+ try {
+ $ast = ( new Thermal_Renderer() )->build_ast( $template, $order );
+ $html = ( new Html_Thermal_Emitter() )->emit(
+ $ast,
+ array( 'paper_width_px' => $paper_width_px )
+ );
+ } catch ( Throwable $e ) {
+ // Malformed thermal markup (e.g. a raw PHP payload with no <receipt>
+ // root) throws during parsing. Fail closed with a harmless comment,
+ // but log the cause so a genuinely broken template is diagnosable.
+ Logger::log(
+ sprintf(
+ 'Thermal receipt render failed for template %s: %s',
+ isset( $template['id'] ) ? (string) $template['id'] : 'unknown',
+ $e->getMessage()
+ )
+ );
+ echo '<!-- Thermal receipt could not be rendered -->';
+ return;
+ }
+
+ // The emitter leaves its output width-agnostic (the PDF path constrains it
+ // via the physical page size). On the browser-print surface the page can be
+ // wider than the roll, so constrain to the resolved paper width here or the
+ // row tables would stretch across the whole page.
+ $open = '<div style="width:' . esc_attr( $this->format_px( $paper_width_px ) )
+ . 'px;max-width:100%;margin:0 auto;">';
+
+ // $html is built by the thermal emitter, which escapes receipt data; $open
+ // is a fixed wrapper with an escaped numeric width.
+ echo $open . $html . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
+
+ /**
+ * Format a pixel value for inline CSS without a trailing decimal point.
+ *
+ * @param float $px Pixel value.
+ *
+ * @return string
+ */
+ private function format_px( float $px ): string {
+ return rtrim( rtrim( number_format( $px, 2, '.', '' ), '0' ), '.' );
+ }
+
+ /**
+ * Resolve the template's paper width in CSS pixels (96dpi).
+ *
+ * Template metadata declares the physical roll ('58mm' / '80mm'); fall back
+ * to 80mm when absent or out of range so the emitter can scale the character
+ * grid to fit the page.
+ *
+ * @param array $template Template metadata/content.
+ *
+ * @return float Paper width in CSS px.
+ */
+ private function paper_width_px( array $template ): float {
+ $raw = isset( $template['paper_width'] ) ? (string) $template['paper_width'] : '';
+ $mm = (float) $raw; // Leading-number cast: '58mm' -> 58.0.
+
+ if ( $mm < 25.0 || $mm > 250.0 ) {
+ $mm = 80.0;
+ }
+
+ return round( $mm * 96 / 25.4, 2 );
+ }
+}
--- a/woocommerce-pos/vendor/autoload.php
+++ b/woocommerce-pos/vendor/autoload.php
@@ -19,4 +19,4 @@
require_once __DIR__ . '/composer/autoload_real.php';
-return ComposerAutoloaderInitd314d8e08ecddaafa88a270bcf997c4c::getLoader();
+return ComposerAutoloaderInit8454f2701641055150b8e4897e686c14::getLoader();
--- a/woocommerce-pos/vendor/composer/autoload_classmap.php
+++ b/woocommerce-pos/vendor/composer/autoload_classmap.php
@@ -372,6 +372,7 @@
'WCPOS\WooCommercePOS\Templates\Received' => $baseDir . '/includes/Templates/Received.php',
'WCPOS\WooCommercePOS\Templates\Renderers\Legacy_Php_Renderer' => $baseDir . '/includes/Templates/Renderers/Legacy_Php_Renderer.php',
'WCPOS\WooCommercePOS\Templates\Renderers\Logicless_Renderer' => $baseDir . '/includes/Templates/Renderers/Logicless_Renderer.php',
+ 'WCPOS\WooCommercePOS\Templates\Renderers\Thermal_Html_Renderer' => $baseDir . '/includes/Templates/Renderers/Thermal_Html_Renderer.php',
'WCPOS\WooCommercePOS\Templates\Thermal\Epos_Xml_Thermal_Emitter' => $baseDir . '/includes/Templates/Thermal/Epos_Xml_Thermal_Emitter.php',
'WCPOS\WooCommercePOS\Templates\Thermal\Escpos_Thermal_Emitter' => $baseDir . '/includes/Templates/Thermal/Escpos_Thermal_Emitter.php',
'WCPOS\WooCommercePOS\Templates\Thermal\Html_Thermal_Emitter' => $baseDir . '/includes/Templates/Thermal/Html_Thermal_Emitter.php',
--- a/woocommerce-pos/vendor/composer/autoload_real.php
+++ b/woocommerce-pos/vendor/composer/autoload_real.php
@@ -2,7 +2,7 @@
// autoload_real.php @generated by Composer
-class ComposerAutoloaderInitd314d8e08ecddaafa88a270bcf997c4c
+class ComposerAutoloaderInit8454f2701641055150b8e4897e686c14
{
private static $loader;
@@ -22,16 +22,16 @@
return self::$loader;
}
- spl_autoload_register(array('ComposerAutoloaderInitd314d8e08ecddaafa88a270bcf997c4c', 'loadClassLoader'), true, true);
+ spl_autoload_register(array('ComposerAutoloaderInit8454f2701641055150b8e4897e686c14', 'loadClassLoader'), true, true);
self::$loader = $loader = new ComposerAutoloadClassLoader(dirname(__DIR__));
- spl_autoload_unregister(array('ComposerAutoloaderInitd314d8e08ecddaafa88a270bcf997c4c', 'loadClassLoader'));
+ spl_autoload_unregister(array('ComposerAutoloaderInit8454f2701641055150b8e4897e686c14', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
- call_user_func(ComposerAutoloadComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::getInitializer($loader));
+ call_user_func(ComposerAutoloadComposerStaticInit8454f2701641055150b8e4897e686c14::getInitializer($loader));
$loader->register(true);
- $filesToLoad = ComposerAutoloadComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::$files;
+ $filesToLoad = ComposerAutoloadComposerStaticInit8454f2701641055150b8e4897e686c14::$files;
$requireFile = Closure::bind(static function ($fileIdentifier, $file) {
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
--- a/woocommerce-pos/vendor/composer/autoload_static.php
+++ b/woocommerce-pos/vendor/composer/autoload_static.php
@@ -4,7 +4,7 @@
namespace ComposerAutoload;
-class ComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c
+class ComposerStaticInit8454f2701641055150b8e4897e686c14
{
public static $files = array (
'23c18046f52bef3eea034657bafda50f' => __DIR__ . '/..' . '/symfony/polyfill-php81/bootstrap.php',
@@ -451,6 +451,7 @@
'WCPOS\WooCommercePOS\Templates\Received' => __DIR__ . '/../..' . '/includes/Templates/Received.php',
'WCPOS\WooCommercePOS\Templates\Renderers\Legacy_Php_Renderer' => __DIR__ . '/../..' . '/includes/Templates/Renderers/Legacy_Php_Renderer.php',
'WCPOS\WooCommercePOS\Templates\Renderers\Logicless_Renderer' => __DIR__ . '/../..' . '/includes/Templates/Renderers/Logicless_Renderer.php',
+ 'WCPOS\WooCommercePOS\Templates\Renderers\Thermal_Html_Renderer' => __DIR__ . '/../..' . '/includes/Templates/Renderers/Thermal_Html_Renderer.php',
'WCPOS\WooCommercePOS\Templates\Thermal\Epos_Xml_Thermal_Emitter' => __DIR__ . '/../..' . '/includes/Templates/Thermal/Epos_Xml_Thermal_Emitter.php',
'WCPOS\WooCommercePOS\Templates\Thermal\Escpos_Thermal_Emitter' => __DIR__ . '/../..' . '/includes/Templates/Thermal/Escpos_Thermal_Emitter.php',
'WCPOS\WooCommercePOS\Templates\Thermal\Html_Thermal_Emitter' => __DIR__ . '/../..' . '/includes/Templates/Thermal/Html_Thermal_Emitter.php',
@@ -466,10 +467,10 @@
public static function getInitializer(ClassLoader $loader)
{
return Closure::bind(function () use ($loader) {
- $loader->prefixLengthsPsr4 = ComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::$prefixLengthsPsr4;
- $loader->prefixDirsPsr4 = ComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::$prefixDirsPsr4;
- $loader->prefixesPsr0 = ComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::$prefixesPsr0;
- $loader->classMap = ComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::$classMap;
+ $loader->prefixLengthsPsr4 = ComposerStaticInit8454f2701641055150b8e4897e686c14::$prefixLengthsPsr4;
+ $loader->prefixDirsPsr4 = ComposerStaticInit8454f2701641055150b8e4897e686c14::$prefixDirsPsr4;
+ $loader->prefixesPsr0 = ComposerStaticInit8454f2701641055150b8e4897e686c14::$prefixesPsr0;
+ $loader->classMap = ComposerStaticInit8454f2701641055150b8e4897e686c14::$classMap;
}, null, ClassLoader::class);
}
--- a/woocommerce-pos/vendor/composer/installed.php
+++ b/woocommerce-pos/vendor/composer/installed.php
@@ -1,9 +1,9 @@
<?php return array(
'root' => array(
'name' => 'wcpos/woocommerce-pos',
- 'pretty_version' => 'v1.9.14',
- 'version' => '1.9.14.0',
- 'reference' => 'c4feb3ef4d026c4126fd134a6f688f7503adfad7',
+ 'pretty_version' => 'v1.9.15',
+ 'version' => '1.9.15.0',
+ 'reference' => '2bb3527d14fb42fb558c2901d0107b3cd91a9ea8',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -89,9 +89,9 @@
'dev_requirement' => false,
),
'wcpos/woocommerce-pos' => array(
- 'pretty_version' => 'v1.9.14',
- 'version' => '1.9.14.0',
- 'reference' => 'c4feb3ef4d026c4126fd134a6f688f7503adfad7',
+ 'pretty_version' => 'v1.9.15',
+ 'version' => '1.9.15.0',
+ 'reference' => '2bb3527d14fb42fb558c2901d0107b3cd91a9ea8',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
--- a/woocommerce-pos/woocommerce-pos.php
+++ b/woocommerce-pos/woocommerce-pos.php
@@ -3,7 +3,7 @@
* Plugin Name: WCPOS – Point of Sale for WooCommerce
* Plugin URI: https://wordpress.org/plugins/woocommerce-pos/
* Description: A simple front-end for taking WooCommerce orders at the Point of Sale. Requires <a href="http://wordpress.org/plugins/woocommerce/">WooCommerce</a>.
- * Version: 1.9.14
+ * Version: 1.9.15
* Author: kilbot
* Author URI: http://wcpos.com
* Text Domain: woocommerce-pos
@@ -25,7 +25,7 @@
// Define plugin constants (use define() with checks to avoid conflicts when Pro plugin is active).
if ( ! defined( __NAMESPACE__ . 'VERSION' ) ) {
- define( __NAMESPACE__ . 'VERSION', '1.9.14' );
+ define( __NAMESPACE__ . 'VERSION', '1.9.15' );
}
if ( ! defined( __NAMESPACE__ . 'TRANSLATION_VERSION' ) ) {
define( __NAMESPACE__ . 'TRANSLATION_VERSION', '2026.7.8' );