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"]

The insert_script shortcode used the old way, with a file path typed into the post

"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.