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 & b esc_attr=a & b
input "quoted" esc_html="quoted" esc_attr="quoted"
input 'single' esc_html='single' esc_attr='single'
input a<b>c esc_html=a<b>c esc_attr=a<b>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
& 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'%20onerror='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 <b>b</b> & c
wp_kses_post( esc_html( 'a <b>b</b> & c' ) ) = a <b>b</b> & 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 <b>b</b> & 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 & 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 & thanks
template esc_html of that : Nice work & 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'Brien & 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'Brien & 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.
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.