Developing a secure front end post editing form

I want to add a front end post editing functionality to one of my WordPress sites. I found a few plugins that do this, but they don’t fits all my needs, so I decided to adapt an existing solution to develop a my own plugin that will return with a shortcode a front end editing form. My plugin works, I can edit and save posts, but I can’t solve a warning and I don’t have (yet) sufficient knowledge to make this plugin more secure. Any suggestions?

The warning:

Warning: Cannot modify header information – headers already sent by
(output started at …/wp-content/plugins/front-post-edit.php:139) in
…/wp-includes/pluggable.php on line 1228

P.S. The 139 line is the last line in my plugin (a line without code).

My plugin code:

<?php
/*
 * Plugin Name: Front Post Editor
 * 
 */

add_shortcode( 'front_post_edit', 'post_shortcode' );

function post_shortcode() {
    return getForm();
}

function getForm() {

    if ( !is_user_logged_in()) {
        echo '<p class="alert-box notice">You must be <a href="' . esc_url( wp_login_url( get_permalink() ) ) . '" title="Login">logged in</a>!';
    } else {

        if( 'POST' == $_SERVER['REQUEST_METHOD'] && !empty( $_POST['action'] ) &&  $_POST['action'] == "edit_post" && isset($_POST['postid'])) {
            $post_to_edit = array();
            $post_to_edit = get_post($_POST['postid']);

            /* these are the fields that we are editing in the form below */
            $title = $_POST['item_title'];
            $description = $_POST['item_description'];
            $category = $_POST['item_category'];
            $location = $_POST['item_location'];
            $location2 = $_POST['item_location2'];

            /* this code will save the title and description into the post_to_edit array */
            $post_to_edit->post_title = $title;
            $post_to_edit->post_content = $description;

            /* this code is a must */
            $pid = wp_update_post($post_to_edit);

            /* save taxonomies: post ID, form field name, taxonomy name, if it appends(true) or rewrite(false) */
            wp_set_post_terms($pid, array($_POST['item_category']),'category',false);
            wp_set_post_terms($pid, array($_POST['item_location']),'location',false);

            /* update custom fields with the new info */
            update_post_meta($pid, 'item_location2', $location2);

            /* redirect user after done editing */
            wp_redirect( home_url( '/myposts' ) );

        }

        /* get post to edit */
        $post_to_edit = get_post($_POST['postid']);

        /* get this post's category taxonomy term id */
        $term_name = strip_tags( get_the_term_list( $post_to_edit->ID, 'category', '', ', ', '' ) );
        $term_obj = get_term_by('name', $term_name, 'category');
        $term_id = $term_obj->term_id;

        /* array for wp_dropdown_category to display with the current post category selected by default */
        $args_cat = array(
            'selected'           => $term_id,
            'name'               => 'item_category',
            'class'              => 'postform',
            'tab_index'          => 10,
            'depth'              => 2,
            'hierarchical'       => 1,
            'taxonomy'           => 'category',
            'hide_empty'      => false );

        /* get this post's location taxonomy term id */
        $term_name2 = strip_tags( get_the_term_list( $post_to_edit->ID, 'location', '', ', ', '' ) );
        $term_obj2 = get_term_by('name', $term_name2, 'location');
        $term_id2 = $term_obj2->term_id;

        $args_loc = array(
            'selected'           => $term_id2,
            'name'               => 'item_location',
            'class'              => 'postform',
            'tab_index'          => 10,
            'depth'              => 2,
            'hierarchical'       => 1,
            'taxonomy'           => 'location',
            'hide_empty'      => false ); 

    ?>

<!-- EDIT FORM -->

    <form id="edit_post" name="edit_post" method="post" action="" enctype="multipart/form-data">

        <!-- post name -->
        <fieldset name="item_title">
            <label for="item_title">Item title:</label><br />
            <input type="text" id="item_title" value="<?php echo $post_to_edit->post_title; ?>" tabindex="5" name="item_title" />
        </fieldset>

        <!-- post Content -->
        <fieldset class="item_description">
            <label for="item_description">Item description:</label><br />
            <textarea id="item_description"  tabindex="15" name="item_description"><?php echo $post_to_edit->post_content; ?></textarea>
        </fieldset>

        <!-- post Category -->
        <fieldset id="item_category">
            <label for="item_category">Item category:</label>
            <?php wp_dropdown_categories( $args_cat ); ?>
        </fieldset>

        <!-- post Location -->
        <fieldset id="item_location">
            <label for="item_location">Item location:</label>
            <?php wp_dropdown_categories( $args_loc ); ?>
        </fieldset>

        <!-- custom fields -->

        <fieldset class="item_location2">
            <label for="item_location2">Location 2:</label><br />
            <input type="text" value="<?php echo get_post_meta($post_to_edit->ID,'item_location2', true); ?>" id="item_location2" tabindex="20" name="item_location2" />
        </fieldset>

        <!-- submit button -->
        <fieldset class="submit">
            <input type="submit" value="Save Post" tabindex="40" id="submit" name="submit" />
        </fieldset>

        <input type="hidden" name="postid" value="<?php echo $post_to_edit->ID; ?>" /> <!-- DONT REMOVE OR CHANGE -->
        <input type="hidden" name="action" value="edit_post" />                           <!-- DONT REMOVE OR CHANGE -->
        <input type="hidden" name="change_cat" value="" />                                <!-- DONT REMOVE OR CHANGE -->
    <?php // wp_nonce_field( 'new-post' ); ?>
    </form>
    <!-- END OF FORM -->

<?php } ?><!-- user is logged in -->
<?php } ?><!-- getForm -->

An Edit button was added to the footer of each entry generated by a loop with this form:

<form class="edit-post" action="<?php echo home_url( '/edit'); ?>" method="post">
    <input type="hidden" name="postid" value="<?php the_ID(); ?>" />
    <input type="submit"  value="Edit" />
</form>

Solutions Collecting From Web of "Developing a secure front end post editing form"

From the code it seems like your warning comes from doing the redirect too late. redirects should be done, as a rule of thumb, not later then the init action. And after the redirect you should die() (I don’t think the wp_redirect does it for you)

As for security, it is not enough to check that the user is logged-in, you need to check if he has the capability to edit the post, something like if current_user_can('edit_post',$post_id). You need to check it both on the UI side and server side. just because you do not show the ability to the hacker doesn’t mean he will not construct a special HTTP request to change the post if you don’t have protection on the server side.

After some learning and research I abandoned the approach described in my question, indeed I figured out how to add the front end editing functionality using the @TheDeadMedic solution. Now I have only two little questions:

1) Is there a better way to use/set the post ID and the post date? I used for this two hidden inputs: foo_id and foo_date.

2) How secure is the communication between forms?

This is the form that gets the post ID into the “postid” variable and pass it to the “post-form” page:

<form action="<?php echo home_url( '/post-form'); ?>" method="post">
    <input type="hidden" name="postid" value="<?php the_ID(); ?>" /> 
    <input type="submit"  value="Edit" />
</form>

<?php

This is the front end form for posting an editing posts (code added by me is marked with // MY CODE):

class WPSE_Submit_From_Front {
    const NONCE_VALUE = 'front_end_new_post';
    const NONCE_FIELD = 'fenp_nonce';

    protected $pluginPath;
    protected $pluginUrl;
    protected $errors = array();
    protected $data = array();

    function __construct() {
        $this->pluginPath = plugin_dir_path( __file__ );
        $this->pluginUrl  = plugins_url( '', __file__ );

        add_action( 'wp_enqueue_scripts', array( $this, 'addStyles' ) );
        add_shortcode( 'post_from_front', array( $this, 'shortcode' ) );

        // Listen for the form submit & process before headers output
        add_action( 'template_redirect',  array( $this, 'handleForm' ) );
    }

    function addStyles() {
        wp_enqueue_style( 'submitform-style', "$this->pluginUrl/submitfromfront.css" );
    }

    /**
     * Shortcodes should return data, NOT echo it.
     *
     * @return string
     */
    function shortcode() {
        if ( ! current_user_can( 'publish_posts' ) )
            return sprintf( '<p>Please <a href="%s">login</a> to post links.</p>', esc_url( wp_login_url(  get_permalink() ) ) );
        elseif ( $this->isFormSuccess() )
            return '<p class="success">Nice one, post created.</p>';
        else
            return $this->getForm();
    }

    /**
     * Process the form and redirect if sucessful.
     */
    function handleForm() {
        if ( ! $this->isFormSubmitted() )
            return false;

        // http://php.net/manual/en/function.filter-input-array.php
        $data = filter_input_array( INPUT_POST, array(
            // MY CODE
            'foo_id'      => FILTER_DEFAULT,
            'foo_date'    => FILTER_DEFAULT,
            // END MY CODE
            'postTitle'   => FILTER_DEFAULT,
            'postContent' => FILTER_DEFAULT,
            'location2'   => FILTER_DEFAULT,
        ));

        $data = wp_unslash( $data );
        $data = array_map( 'trim', $data );

        // You might also want to more aggressively sanitize these fields
        // By default WordPress will handle it pretty well, based on the current user's "unfiltered_html" capability

        $data['postTitle']   = sanitize_text_field( $data['postTitle'] );
        $data['postContent'] = wp_check_invalid_utf8( $data['postContent'] );
        $data['location2']   = sanitize_text_field( $data['location2'] );

        $this->data = $data;

        if ( ! $this->isNonceValid() )
            $this->errors[] = 'Security check failed, please try again.';

        if ( ! $data['postTitle'] )
            $this->errors[] = 'Please enter a title.';

        if ( ! $data['postContent'] )
            $this->errors[] = 'Please enter the content.';

        if ( ! $this->errors ) {
            $post_id = wp_insert_post( array(
                // MY CODE
                'ID'           => $data['foo_id'],
                'post_date'    => $data['foo_date'],
                // END MY CODE
                'post_title'   => $data['postTitle'],
                'post_content' => $data['postContent'],
                'post_status'  => 'publish',
            ));

            if ( $post_id ) {
                add_post_meta( $post_id, 'location2', $data['location2'] );

                // Redirect to avoid duplicate form submissions
                wp_redirect( add_query_arg( 'success', 'true' ) );
                exit;

            } else {
                $this->errors[] = 'Whoops, please try again.';
            }
        }
    }

    /**
     * Use output buffering to *return* the form HTML, not echo it.
     *
     * @return string
     */
    function getForm() {

    // MY CODE
    if( 'POST' == $_SERVER['REQUEST_METHOD'] && isset( $_POST['postid'] ) ) {
        $post_to_edit                   = array();
        $post_to_edit                   = get_post( $_POST['postid'] );
        $this->data['foo_id']           = $post_to_edit->ID;
        $this->data['foo_date']         = $post_to_edit->post_date;
        $this->data['item_name']        = $post_to_edit->post_title;
        $this->data['item_description'] = $post_to_edit->post_content;
    }
    // END MY CODE

        ob_start();
        ?>

<div id ="frontpostform">
    <?php foreach ( $this->errors as $error ) : ?>

        <p class="error"><?php echo $error ?></p>

    <?php endforeach ?>

    <form id="formpost" method="post">
        <fieldset>
            <label for="postTitle">Post Title</label>
            <input type="text" name="postTitle" id="postTitle" value="<?php

                // "Sticky" field, will keep value from last POST if there were errors
                if ( isset( $this->data['postTitle'] ) )
                    echo esc_attr( $this->data['postTitle'] );

            ?>" />
        </fieldset>

        <fieldset>
            <label for="postContent">Content</label>
            <textarea name="postContent" id="postContent" rows="10" cols="35" ><?php

                if ( isset( $this->data['postContent'] ) )
                    echo esc_textarea( $this->data['postContent'] );

            ?></textarea>
        </fieldset>

        <fieldset>
        <label for="location2">Location 2</label>
        <input type="text" name="location2" id="location2" title="Location 2 (opțional)" value="<?php

            // "Sticky" field, will keep value from last POST if there were errors
            if ( isset( $this->data['location2'] ) )
                echo esc_attr( $this->data['location2'] );

        ?>" />
        </fieldset>

        <fieldset>
            // MY CODE
            <input type="hidden" name="foo_id" id="foo_id" value="<?php

            // "Sticky" field, will keep value from last POST if there were errors
            if ( isset( $this->data['foo_id'] ) )
                echo esc_attr( $this->data['foo_id'] );

        ?>" />

            <input type="hidden" name="foo_date" id="foo_date" value="<?php

            // "Sticky" field, will keep value from last POST if there were errors
            if ( isset( $this->data['foo_date'] ) )
                echo esc_attr( $this->data['foo_date'] );

        ?>" />
            // END MY CODE
            <button type="submit" name="submitForm" >Create Post</button>
        </fieldset>

        <?php wp_nonce_field( self::NONCE_VALUE , self::NONCE_FIELD ) ?>
    </form>
</div>

        <?php
        return ob_get_clean();
    }

    /**
     * Has the form been submitted?
     *
     * @return bool
     */
    function isFormSubmitted() {
        return isset( $_POST['submitForm'] );
    }

    /**
     * Has the form been successfully processed?
     *
     * @return bool
     */
    function isFormSuccess() {
        return filter_input( INPUT_GET, 'success' ) === 'true';
    }

    /**
     * Is the nonce field valid?
     *
     * @return bool
     */
    function isNonceValid() {
        return isset( $_POST[ self::NONCE_FIELD ] ) && wp_verify_nonce( $_POST[ self::NONCE_FIELD ], self::NONCE_VALUE );
    }
}

new WPSE_Submit_From_Front;