Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 14 / 18 30 MIN

Security and language

Escaping Across a Template Boundary

The plugin hands a courier name, a tracking code and a status label to templates a theme can replace. Where the escaping happens decides whether that is safe.

A courier company name is a string an administrator typed into a form. It reaches a public tracking page through a template, and that template is a file somebody other than you may replace. The question is not which function escapes HTML, because you can look that up. It is where in the journey the escaping runs, and who owns it when the file at the end is not yours.

Sifero Courier Timeline crosses that boundary in two different ways, and the second one is the reason the first is interesting. Its default templates are PHP files that include themselves into a function and call escaping functions at the point of output. Its alternative templates are plain HTML uploaded through the admin, which use a token language instead, and every token in that language was escaped before the template ever saw it. Both approaches can be correct. They fail in different ways, and the plugin contains a worked example of each failure.

The boundary is where your data meets someone else's file

Escaping is a decision made at the last moment before a value becomes part of a document, because that is the only point where you know the context the value is landing in. The same company name is safe inside <strong> after esc_html(), safe inside alt="..." after esc_attr(), and unsafe in either position if you did nothing.

When the file doing the output is yours and lives in includes/, you know the context and you place the escape. When the file is a template, the context moves. The plugin acknowledges this by documenting the variables at the top of each template rather than escaping them on the way in:

<?php
// templates/frontend-template.php, lines 1-16, shortened
/**
 * Public tracking template — Default Complete.
 *
 * Available variables from shortcode:
 * - $tracking_id
 * - $payload
 */
$sfct_result_only = isset( $sfct_result_only ) ? (bool) $sfct_result_only : false;
$sfct_has_payload = is_array( $payload );
$sfct_is_error    = is_wp_error( $payload );

Those variables arrive raw. $payload is the array sfct_get_tracking_payload() built, and with one exception this lesson returns to, it has not been through an escaping function. The template escapes each value as it prints it, which is the correct division of labour. The load path is a plain include inside an output buffer:

// includes/shortcodes.php, lines 1090-1112, shortened
$tracking_id     = sanitize_text_field( $atts['tracking_id'] );
$payload         = $tracking_id ? sfct_get_tracking_payload( $tracking_id, true, 'public_page' ) : null;

ob_start();
$template = function_exists( 'sfct_resolve_public_tracking_template' ) ? sfct_resolve_public_tracking_template() : SFCT_PATH . 'templates/frontend-template.php';
include $template;
return ob_get_clean();

Read that as a statement about trust. The template is executable PHP in your plugin's namespace, with access to every function and every global. There is no sandbox, so anything that can choose that path can run code. The plugin keeps the choice narrow on purpose, in the resolver:

// includes/templates-frontend.php, lines 199-201
function sfct_is_plugin_managed_php_template( $file ) {
	return 'php' === sfct_get_template_file_extension( $file ) && sfct_template_path_is_within( $file, SFCT_PATH );
}

and sfct_render_public_template_markup() uses it to fall back to the default whenever the resolved path is not inside the plugin. That guard is the single most important line for the security of this subsystem, and it has a consequence the next section gets to.

Tokens are escaped once, in the plugin, before substitution

The HTML templates receive no PHP at all. They receive a context array, built by sfct_build_safe_public_template_context(), and the string substitution happens afterwards:

// includes/templates-frontend.php, lines 463-479, shortened
foreach ( $raw as $key => $value ) {
	$template = str_replace( '{{{' . $key . '}}}', (string) $value, $template );
}

foreach ( $tokens as $key => $value ) {
	$template = str_replace( '{{' . $key . '}}', (string) $value, $template );
}

return preg_replace( '/\{\{\{?[a-zA-Z0-9_\-]+\}?\}\}/', '', $template );

Every ordinary token was already escaped when the array was built, and the escaping function chosen is esc_attr() for almost all of them:

// includes/templates-frontend.php, lines 639-660, shortened
'tokens'   => array(
	'tracking_id_input'      => esc_attr( $tracking_id ),
	'tracking_id'            => $has_payload && ! empty( $payload['tracking_id'] ) ? esc_attr( $payload['tracking_id'] ) : esc_attr( $tracking_id ),
	'status_label'           => $has_payload && ! empty( $payload['status_label'] ) ? esc_attr( $payload['status_label'] ) : '',
	'progress'               => $has_payload && isset( $payload['progress'] ) ? esc_attr( absint( $payload['progress'] ) ) : '0',
	'company_name'           => ! empty( $company['name'] ) ? esc_attr( $company['name'] ) : esc_attr__( 'No courier assigned', 'sifero-courier-timeline-for-woocommerce' ),
	'company_logo_url'       => ! empty( $company['logo_url'] ) ? esc_url( $company['logo_url'] ) : '',
	'error_message'          => $is_error ? esc_attr( $payload->get_error_message() ) : '',
),

Triple braces are the escape hatch and the name is accurate: {{{steps_html}}} is replaced with markup the plugin built in sfct_render_safe_template_steps_html(), which escapes every value it interpolates with esc_html() and esc_attr(). The raw keys are trusted because the plugin wrote them, and two of them flow through wp_kses() with a restricted allowlist rather than being printed directly:

<?php
// templates/frontend-template.php, line 93
echo wp_kses( $sfct_current_step_icon, function_exists( 'sfct_icon_kses_allowed_html' ) ? sfct_icon_kses_allowed_html() : 'post' );

That is the right shape for SVG: wp_kses_post() would strip the svg element and the icon would vanish, so the plugin supplies its own allowlist. The ordering is the part to carry away. In this design the escape happens once, on the value, before substitution, and the template writer never gets a chance to forget it. The cost is that every token is escaped for one context and the template author has to hope it was the right one.

esc_html and esc_attr are the same function in 7.0.7

That last point sounds like a criticism of the plugin, so it is worth knowing what the two functions actually do. Here is the whole of each, from wp-includes/formatting.php:

// wp-includes/formatting.php, WordPress 7.0.7
function esc_html( $text ) {
	$safe_text = wp_check_invalid_utf8( $text );
	$safe_text = _wp_specialchars( $safe_text, ENT_QUOTES );
	return apply_filters( 'esc_html', $safe_text, $text );
}

function esc_attr( $text ) {
	$safe_text = wp_check_invalid_utf8( $text );
	$safe_text = _wp_specialchars( $safe_text, ENT_QUOTES );
	return apply_filters( 'attribute_escape', $safe_text, $text );
}

The bodies are identical. The only difference is the filter name at the end, so on the current source there is no input for which they produce different output, and I confirmed that on the lab install:

input a & b      esc_html=a &amp; b             esc_attr=a &amp; b
input "quoted"   esc_html=&quot;quoted&quot;  esc_attr=&quot;quoted&quot;
input 'single'   esc_html=&#039;single&#039;  esc_attr=&#039;single&#039;
input a<b>c      esc_html=a&lt;b&gt;c       esc_attr=a&lt;b&gt;c

The third argument to _wp_specialchars() is worth one more minute, because it is the rule behind the double-escaping trap later in this lesson. Its signature ends $double_encode = false, so an input that already contains &amp; comes back unchanged. WordPress never re-encodes an entity that is already there.

So why does the plugin use esc_attr() for a company name that mostly lands between tags? Because the same token also lands inside an attribute in the same template, and the function that produces it has one value to produce. One escape per value means the value was escaped for whichever context is most dangerous. Had the design escaped at the point of output instead, the same name would be esc_html() for the heading and esc_attr() for the alt, and a reader would not have to reason about which context won.

The five values, and where each one gets its escape

Trace the real output paths in the default template. A courier name is read from $payload['company']['name'] into a local and printed twice as text and once as an attribute:

<?php
// templates/frontend-template.php, line 134 and line 264
<strong><?php echo esc_html( $sfct_company_name ); ?></strong>
<img src="<?php echo esc_url( $sfct_company_logo_url ); ?>" alt="<?php echo esc_attr( $sfct_company_name ); ?>" />

A tracking code that arrived from the request goes through one sanitising pass in the shortcode, then is escaped where it is printed into a form value, and again where the tracking id is shown:

<?php
// includes/tracking.php, line 1105, the value arriving from the request
$tracking_id = sanitize_text_field( $atts['tracking_id'] );

// templates/frontend-template.php, line 34 and line 107
<input id="sfct_tracking_id" name="tracking_id" type="text" value="<?php echo esc_attr( $tracking_id ); ?>" ... />
<strong><?php echo esc_html( $payload['tracking_id'] ); ?></strong>

A status label is an administrator-editable string that comes out of the statuses configuration, and it is escaped as text at the point where it is the page's headline:

<?php
// templates/frontend-template.php, line 88
<h2><?php echo esc_html( $payload['status_label'] ); ?></h2>

A logo URL is escaped with esc_url(). That function is not a general attribute escaper, and it is worth seeing what it does and does not defend against, because the whole of it is one character class and one pair of replacements:

// wp-includes/formatting.php, WordPress 7.0.7, shortened
$url = str_replace( ' ', '%20', ltrim( $url ) );
$url = preg_replace( '|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\\x80-\\xff]|i', '', $url );
// ...scheme handling, then:
if ( 'display' === $_context ) {
	$url = wp_kses_normalize_entities( $url );
	$url = str_replace( '&', '&', $url );
	$url = str_replace( "'", ''', $url );
}

The character class has no double quote in it, so esc_url() deletes one rather than encoding it, and it does not encode the double quote that would otherwise close an attribute. I ran four hostile values through it and printed what a browser would receive:

raw : https://example.com/a.png" onerror="alert(1)
out : https://example.com/a.png%20onerror=alert(1)

raw : javascript:alert(1)
out : (empty string)

raw : data:text/html,<script>alert(1)</script>
out : (empty string)

raw : https://example.com/a.png' onerror='alert(1)
out : https://example.com/a.png&#039;%20onerror=&#039;alert(1)

The quote-break attempt fails, but it fails because the quote was removed, not encoded. The practical rule that falls out of this: esc_url() is the right function for a URL and it is only sufficient inside an attribute you have quoted yourself. The plugin does quote it, with double quotes, so the value in the template above is safe. Change that src="..." to unquoted src=... and the reasoning changes, because the space the function left behind is now a separator.

The fifth value is an API response body, and in this plugin it does not exist as a display path. The api_response column in wp_sfct_delivery_logs is written in includes/tracking.php and includes/orders.php, always through sanitize_textarea_field() or wp_json_encode(), and it is never read onto a public page. That is the correct answer for remote data you cannot verify: store it for support, do not render it. If you do render it, use esc_html() inside <pre> and nothing else.

Escaping early and then filtering is how you get entity text on screen

The trap has a shape you can reproduce in one command. Escape a value and then pass it through wp_kses_post(), and the filter sees entities where the tags used to be. It strips nothing, because there are no tags left:

esc_html( 'a <b>b</b> & c' )        = a &lt;b&gt;b&lt;/b&gt; &amp; c
wp_kses_post( esc_html( 'a <b>b</b> & c' ) ) = a &lt;b&gt;b&lt;/b&gt; &amp; c

The same input through the two functions in the other order gives a different result, and this is the version that bites in templates:

esc_html( wp_kses_post( 'a <b>b</b> & c' ) ) = a &lt;b&gt;b&lt;/b&gt; &amp; c
// printed to a browser, that string renders as the visible text:
//   a <b>b</b> & c

Now look at the one place the plugin escapes a payload value before the template sees it. Timeline comments are filtered on read, inside sfct_get_tracking_payload():

// includes/tracking.php, line 709
'comment'    => wp_kses_post( $log->comment ),

and printed as text in the template:

<?php
// templates/frontend-template.php, line 250
<p><?php echo esc_html( $sfct_item['comment'] ); ?></p>

Two filters over one value, and the second one decides what the customer sees. Because wp_kses_post() turns a bare & into &amp; and _wp_specialchars() will not re-encode an existing entity, the output for a comment containing an ampersand survives by luck rather than design. Change line 250 to wp_kses_post() to match the read, and the pair becomes consistent; leave both and the filter at line 709 buys you nothing, because esc_html() at the point of output already makes the value inert and sanitize_textarea_field() at the point of writing stripped the markup before either ran:

stored via sanitize_textarea_field : Nice work & thanks
read back via wp_kses_post        : Nice work &amp; thanks
template esc_html of that         : Nice work &amp; thanks

The comment field cannot contain markup from any path I could find, so the wp_kses_post() on read is a policy the rest of the pipeline does not honour. That is the general lesson, and it is more useful than a rule about which function to call: decide whether a field is text or HTML, once, and make every stage agree. Text is sanitised on write and escaped on output. HTML is sanitised with an allowlist on write and printed through wp_kses() on output. A field that is escaped on output and then filtered again has two owners and no consistency.

Two template mechanisms, and the guard between them

The token design is sound and the HTML uploader is careful. It rejects files containing <?php, <script, <iframe, on*= attributes and javascript: URLs, checks the extension, caps the size, and requires the template to include at least one tracking placeholder. The renderer then decides which of the two mechanisms to use, on the extension:

// includes/templates-frontend.php, lines 725-740, shortened
$template = $template_slug ? sfct_resolve_public_tracking_template_from_slug( $template_slug ) : sfct_resolve_public_tracking_template();

if ( in_array( sfct_get_template_file_extension( $template ), array( 'html', 'htm' ), true ) ) {
	return sfct_render_safe_template_file( $template, sfct_build_safe_public_template_context( $payload, $tracking_id, $sfct_result_only ) );
}

if ( ! sfct_is_plugin_managed_php_template( $template ) ) {
	$template = sfct_get_template_type_config( 'public' )['default_path'];
}

Uploaded templates land in wp-content/uploads/sifero-courier-timeline-for-woocommerce/templates/public/, which is outside SFCT_PATH, and they are HTML, so they leave through the first return and never meet the path guard. I ran the real renderer against a file placed exactly where the uploader puts one, with the settings option selecting it, and the uploaded file was what rendered:

sfct_get_custom_templates_dir('public') = .../uploads/sifero-courier-timeline-for-woocommerce/templates/public
inside SFCT_PATH? NO
is_plugin_managed_php_template(uploaded) = false
selected = custom/public/my-template.html
UPLOADED_TEMPLATE_MARKER O&#039;Brien &amp; Sons

That last line is the part worth pausing on. The template file contains {{company_name}} and nothing else, and the value that arrived in its place was O&#039;Brien &amp; Sons, already escaped, before the template had any say in the matter.

So the guard at line 733 is not protecting against uploaded templates, because those took the other branch. What it protects is the include at line 738, which runs whenever the resolved path is a .php file, and the only catalog entries that can produce one are scanned from inside the plugin. On a normal install the guard is reached only when the file has been deleted or the uploads directory has moved. It fails closed, which is the right direction, but it fails silently: the store owner selects a custom template, and the public page quietly renders the default. A _doing_it_wrong() or an admin notice would cost nothing and save an afternoon.

The two mechanisms also do not share their escaping contract, and that is the real design criticism. A PHP template receives $payload raw and must escape every value it prints, which the shipped templates do. An HTML template receives sfct_build_safe_public_template_context() and cannot forget to escape, because the values arrive escaped. Which one an author is writing for is decided by a file extension, and nothing tells them which contract applies. Make that explicit rather than removing either mechanism: name the two kinds of template and say in the admin UI that a custom template receives already-escaped values.

Whichever mechanism a plugin picks, the template boundary needs a documented override path. This plugin has none: the resolver builds paths from SFCT_PATH, the uploads directory and a hard-coded default, so a theme cannot replace templates/frontend-template.php the way it replaces a WooCommerce template. A one-line filter around the resolver would fix that and make the third-party boundary explicit rather than accidental:

function sfct_resolve_public_tracking_template() {
	$template = sfct_resolve_template( /* ... */ );

	return apply_filters( 'sfct_public_tracking_template', $template );
}

With that filter in place the template really is a file a third party may have replaced, which changes what the plugin owes it. The plugin then has to pass raw data and document the escaping contract, because the replacement file owns its context. That is exactly the arrangement the default templates already assume.

Lab 30 minutes

Audit templates/orders-frontend-1.php line by line. Produce a table with one row per echo: line number, the value being printed, the escaping function applied, and the HTML context it lands in. Include the style attributes and any class values built from data.

Then classify every printed value as text, URL, attribute or markup, and say whether the function used matches the classification. List anything that does not match.

Finally, add the sfct_public_tracking_template filter from the section above to sfct_resolve_public_tracking_template(), then write a two-line theme-side override that replaces the public template and prints the company name correctly for both its text position and its alt position. Save the theme file only long enough to load a tracking page once, then delete it.

Show the solution and the reasoning

The audit has a predictable result, and the value is in seeing how consistent the plugin is rather than in finding a hole. Every dynamic value in orders-frontend-1.php is escaped, and the function used is chosen by position: esc_html() for text nodes, esc_attr() for attributes and class names, esc_url() for links and image sources, wp_kses_post() for the WooCommerce-formatted order total, and wp_kses() with the plugin's icon allowlist for SVG. Two rows deserve a second look.

esc_html( wc_get_order_status_name( $order->get_status() ) ) is correct and also redundant, in the sense that the status name comes from WooCommerce's own translation of a registered status. Escaping it costs nothing and protects the case where someone has filtered the status label through wc_order_statuses, which this plugin itself does.

The order total is the interesting one. It is printed with wp_kses_post() rather than esc_html() because get_formatted_order_total() returns markup: a <span> around the amount and sometimes a <del> for a pre-sale price. The classification is markup, not text. Anything that prints a WooCommerce get_formatted_* value with esc_html() has a bug that only shows up on a discounted order, so grep the plugin for that pattern; here the totals all go through wp_kses_post().

The filter is not in the shipped code, so this is a small refactor rather than a one-line addition: the resolver has two returns and both need to run through the filter. Pull the resolution into a variable and filter once on the way out.

function sfct_resolve_public_tracking_template() {
	$settings = sfct_get_template_settings();
	$template = sfct_get_template_type_config( 'public' )['default_path'];

	if (
		current_user_can( 'manage_woocommerce' )
		&& isset( $_GET['_wpnonce'] )
		&& wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'sfct_template_preview' )
		&& isset( $_GET['sfct_public_template_preview'] )
	) {
		$template = sfct_resolve_public_tracking_template_from_slug( sanitize_text_field( wp_unslash( $_GET['sfct_public_template_preview'] ) ) );
	}

	return apply_filters( 'sfct_public_tracking_template', $template );
}

That version drops the branch that reads $settings['public_template'], so do not paste it over the original without keeping it; the point is the shape, with one exit point and the filter on it. The theme-side override is then two lines, and the escaping is the part that has to match the position, which is the whole point of the exercise:

// functions.php in a theme, minimum viable override
add_filter( 'sfct_public_tracking_template', function ( $template ) {
	return get_stylesheet_directory() . '/sfct/frontend-template.php';
} );
<?php
// wp-content/themes/your-theme/sfct/frontend-template.php
$sfct_company_name = ! empty( $payload['company']['name'] ) ? $payload['company']['name'] : __( 'No courier assigned', 'sifero-courier-timeline-for-woocommerce' );
?>
<h2><?php echo esc_html( $sfct_company_name ); ?></h2>
<img src="<?php echo esc_url( $payload['company']['logo_url'] ); ?>" alt="<?php echo esc_attr( $sfct_company_name ); ?>" />

Note what the override does not get: $payload arrives with exactly the fields the plugin builds, so the theme file has to know that status_label is a translated string and logo_url is a URL. That is the cost of a template boundary, and it is why the variables are documented at the top of the shipped template. A theme that prints $payload['status_label'] without escaping has introduced the vulnerability, not the plugin.

The argument for the other design is one sentence: with an escaped token language, that theme author could not have made that mistake. The tradeoff is that the token language needs a loop and a conditional before anyone can use it for a timeline, and this plugin solved that by generating the timeline HTML in PHP and handing it over as a raw token. Both designs eventually put author-unescaped markup in the page; the difference is where it comes from.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.