02/26/2014
Including a PHP file from a WordPress shortcode, safely
The short version
The hole
The 2014 shortcode passes a file path out of the post straight into include(). Any author can read wp-config.php with one line typed in the editor.
The fix
An allow-list keyed by a name the author types, with the path assembled in code. There is then no path to validate, which is the point.
The nuance
A fragment that escapes nothing is a second hole behind the first, and if the thing is not genuinely inline it belongs in a template rather than a shortcode.
A shortcode that renders a PHP fragment inside post content is genuinely
useful. The version of it that has been circulating since about 2014 is
also a way for anyone who can edit a post to read wp-config.php.
Here is the same idea with the one thing changed that matters: the code decides which files can be included, not the person writing the post.
add_shortcode( 'insert_script', 'ab_insert_fragment' );
/**
* The fragments a post is allowed to embed.
*
* The KEY is what an author types. The PATH is decided here and never
* comes out of the post, which is the whole of the fix.
*/
function ab_insert_fragment_allowed() {
$base = get_stylesheet_directory() . '/fragments/';
return array(
'pricing-table' => $base . 'pricing-table.php',
'staff-list' => $base . 'staff-list.php',
);
}
function ab_insert_fragment( $atts ) {
$atts = shortcode_atts(
array(
'name' => '',
'id' => '',
),
$atts,
'insert_script'
);
$allowed = ab_insert_fragment_allowed();
if ( ! isset( $allowed[ $atts['name'] ] ) ) {
return '';
}
$path = $allowed[ $atts['name'] ];
if ( ! is_readable( $path ) ) {
return '';
}
ob_start();
include $path;
return ob_get_clean();
}
Used with a name rather than a path:
[insert_script name="pricing-table" id="42"]
An author can embed the two fragments you decided on. They cannot embed a third, and they cannot name a file at all.
Why the 2014 version is dangerous
function insert_script($atts){
extract(shortcode_atts(array("path" => '',"vals"=>''), $atts));
ob_start();
include($_SERVER['DOCUMENT_ROOT'].$path);
return ob_get_clean();
}
$path arrives from the post. It reaches include() with nothing in
between. That is a local file inclusion primitive, and the attack is
one line typed into the editor:
[insert_script path="/wp-config.php"]
Because include on a PHP file executes it rather than printing it, that
particular one often renders nothing visible — which is worse rather than
better, because the attempt leaves no trace on the page. ../ walks
straight out of the document root, and a file that is not PHP is printed
exactly as it is:
[insert_script path="/../../../etc/passwd"]

"Only editors can do that" is not the reassurance it sounds like. The
capability involved is edit_posts, which contributors have, which any
compromised account of any level has, and which plugins use to create
posts programmatically. Post content is data supplied by a user, and this
code treats it as a filename.
The other three faults, which cost people afternoons
extract() creates variables you did not name from an array you do
not fully control, and the WordPress coding standards say not to use it
for precisely the reason it bites here. Assign what you need by name.
The prose described parse_str() and the code never called it. The
original explained at length how the vars attribute would be parsed into
variables. Nothing in the function did that, so the documented feature did
not exist. As written, parsestr() is not a function at all — the real
one is parse_str().
The handler declared vals and the example passed vars, and nothing
used either. Three ways of saying the same thing: the code and the
description of it had drifted apart and nobody re-read them together.
Passing data to the fragment
Named attributes, declared in shortcode_atts, read by the fragment from
$atts — which is in scope because include runs inside the function:
// fragments/pricing-table.php
$plan_id = isset( $atts['id'] ) ? absint( $atts['id'] ) : 0;
if ( $plan_id ) {
echo esc_html( get_the_title( $plan_id ) );
}
Two things carry across: absint() because the value came out of a post,
and esc_html() on the way out. A fragment that escapes nothing is a
second hole behind the first one you just closed.
When not to use a shortcode for this at all
A shortcode is for something an author places inline, in a spot only they know. If the fragment always appears in the same place on a template, it belongs in the template:
get_template_part( 'fragments/pricing-table' );
And on any site built since the block editor arrived, a block or a block pattern gives an author something they can see while placing it, which is most of why they wanted the shortcode.
The shortcode earns its place when authors genuinely need to drop something into the middle of prose. It does not need to be able to include arbitrary files to do that.
Questions this keeps raising
Is it safe if only administrators can edit posts?
That reduces the number of people who can trigger it and does not make the code safe. The capability involved is edit_posts, which contributors have, which a compromised account of any level has, and which plugins use to create posts programmatically. Code that treats post content as a filename is the problem, not who is typing it.
Can I validate the path instead of using an allow-list?
You can, and it is much harder to get right than it looks - realpath() plus a prefix check, symlinks, null bytes on older PHP, and Windows path separators all have to be handled, and each is a place to be subtly wrong. An allow-list has none of those failure modes because there is no path to validate. Use it unless you have a concrete reason you cannot.
Why return the output instead of echoing it?
A shortcode handler must return its output. If it echoes, the content is printed at the moment WordPress runs the shortcode, which is usually above the rest of the post, so the fragment appears in the wrong place. That is what ob_start() and ob_get_clean() are for, and the 2014 version got that part right.
Does this work in widgets?
Shortcodes do not run in classic text widgets unless the theme adds do_shortcode to the widget_text filter. In the block editor a shortcode block handles it. Either way that is a separate question from whether the shortcode itself is safe.