1

I am implementing the REST API with fetch() promises as requests on a Password protected page with a custom table without using a plugin (only using the WP REST API that has been merged into WordPress core - thumbs up 👍 WordPress team, that was a smart move, thank you).

All worked fine, except I couldn't make the Authorization work in the header of the HTTP request (always passed, even with no JWT or false token, see last step 6.) at the end).

Here are my steps:

1.) Create custom table in MySQL

MySQL database on my WordPress installation with custom table your_custom_table, 4 columns (id, column_1, column_2, timestamp) and 6 initials records

MySQL database on my WordPress installation with custom table your_custom_table, 4 columns (id, column_1, column_2, timestamp) and 6 initials records.

2.) Create Password Protected Page

Added Password Protected page "Your Private Page"

Code (HTML with inline <script> ... </script>):

<form id="form1" name="form1">
  <button id="get" onclick="getValues()">GET</button>
  <button id="insert" onclick="insertValues()">CREATE</button>
  <button id="update" onclick="updateValues()">UPDATE</button>
</form>

<script>
let yourData = [];
let yourDataNew = {};
let yourDataUpdated = {};
let token = "";

function getValues() {
  event.preventDefault();
  //READ data
  getYourData();
};
function insertValues() {
  event.preventDefault();
  //CREATE new datarecord
  yourDataNew = {"column_1": "test-1", "column_2": "test-2"};
  insertYourData(yourDataNew);
};
function updateValues() {
  event.preventDefault();
  //UPDATE datarecord
  let idOfLastRecord = yourData[yourData.length-1].id;
  yourDataUpdated = {"id": idOfLastRecord, "column_1": "test-1-modified", "column_2": "test-2-modified"};
  updateYourData(yourDataUpdated);
};

//GET value of Access Cookie wp-postpass_{hash}
token = ("; "+document.cookie).split("; wp-postpass_675xxxxxx   =").pop().split(";").shift();
//token = '24P%24BhPU2oE3ux8v4FFfSFbB9onTPNnglM.'
console.log('TOKEN: ' + token);

// Here comes the REST API part:
// HTTP requests with fetch() promises
function getYourData() {
  let url = 'https://oilamerica.com.pa/wp-json/wp/v2/your_private_page/data';
  fetch(url, {
    method: 'GET',
    credentials: 'same-origin',
    headers:{
        'Content-Type': 'application/json',
        //credentials: 'same-origin',   <-- no authorization needed
        'Accept': 'application/json',   
        //'Authorization': 'Bearer ' + token  <-- no authorization needed
    }
  }).then(res => res.json())
  .then(response => get_success(response))
  .catch(error => failure(error));
};

function insertYourData(data) {
  let url = 'https://oilamerica.com.pa/wp-json/wp/v2/your_private_page/data';
  fetch(url, {
    method: 'POST',
    credentials: 'same-origin',
    headers:{
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'Authorization': 'Bearer ' + token
    },
    body: JSON.stringify(data)
  }).then(res => res.json())
  .then(response => create_success(response))
  .catch(error => failure(error));
};

function updateYourData(data) {
  let url = 'https://oilamerica.com.pa/wp-json/wp/v2/your_private_page/data';
  fetch(url, {
    method: 'PUT',
    credentials: 'same-origin',
    headers:{
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'Authorization': 'Bearer ' + token
    },
    body: JSON.stringify(data)
  }).then(res => res.json())
  .then(response => update_success(response))
  .catch(error => failure(error));
};

// fetch() promises success functions:
function get_success(json) {
  data = JSON.stringify(json);
  yourData = JSON.parse(data);
  console.log('GET');
  console.log(yourData);
};
function create_success(json) {
  let insertResponse = JSON.stringify(json);
  insertResponse = JSON.parse(insertResponse);
  console.log('CREATE');
  console.log(insertResponse);
};
function update_success(json) {
  let updateResponse = JSON.stringify(json);
  updateResponse = JSON.parse(updateResponse);
  console.log('UPDATE');
  console.log(updateResponse);
};
function failure(error) {
  console.log("Error: " + error);
};

</script>

After adding Password Protected page "Your Private Page" I got following result:

The resulting page with Password login

The resulting page with Password login.

Note that page shows in widgets of pages, even if you don't include page in your menu, whereas in "Private" the page is invisible for non-authorized visitors.

3.) Edited function.php of my installed theme

Added my php-code in function.php of my installed theme

Code (Added my PHP code in function.php of my installed theme)

/**
 * Add here your custom CRUD functions
 */
function get_your_data($request) {
    global $wpdb;
    $yourdata = $wpdb->get_results("SELECT * FROM your_custom_table");
    return rest_ensure_response( $yourdata );
};

function insert_your_data($request) {
    global $wpdb;
    $contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';

    if ($contentType === "application/json") {
        $content = trim(file_get_contents("php://input"));
        $decoded = json_decode($content, true);
        $newrecord = $wpdb->insert( 'your_custom_table', array( 'column_1' => $decoded['column_1'], 'column_2' => $decoded['column_2']));
    };
    if($newrecord){
        return rest_ensure_response($newrecord);
    }else{
        //something gone wrong
        return rest_ensure_response('failed');
    };
    header("Content-Type: application/json; charset=UTF-8");
};
function update_your_data() {
    global $wpdb;
    $contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';

    if ($contentType === "application/json") {
        $content = trim(file_get_contents("php://input"));
        $decoded = json_decode($content, true);
        $updatedrecord = $wpdb->update( 'your_custom_table', array( 'column_1' => $decoded['column_1'], 'column_2' => $decoded['column_2']), array('id' => $decoded['id']), array( '%s' ));
    };
    if($updatedrecord){
        return rest_ensure_response($updatedrecord);
    }else{
        //something gone wrong
        return rest_ensure_response('failed');
    };
    header("Content-Type: application/json; charset=UTF-8");
};

add_action('rest_api_init', function() {
    /**
    * Register here your custom routes for your CRUD functions here
    */
    register_rest_route( 'wp/v2/your_private_page', '/data', array(
        array(
           'methods'  => 'GET',
           'callback' => 'get_your_data'
        ),
        array(
           'methods'  => 'POST',
           'callback' => 'insert_your_data'
        ),
        array(
           'methods'  => 'PUT',
           'callback' => 'update_your_data'
        ),
    ));
});

4.) Testing the Cookie Value retrieving part (JWT token)

Retrieving the cookie value (which is the JWT token)

Retrieving the cookie wp_postpass_ 675xxxxxx value (which is the JWT token) and worked fine

5.) Testing my CRUD functions (GET, POST and PUT)

GET: worked fine (without Authorization, as reading the data is public on our frontend anyway) GET worked as expected

POST: worked fine (with Authorization, i.e. protected) POST worked as expected

New record was inserted correctly to my custom table:

New record inserted correctly

PUT: worked fine (with Authorization, i.e. protected) PUT worked as expected

Last record has been updated correctly at my custom table

enter image description here

6.) Testing my CRUD functions WITHOUT token

I simulated unauthorized access by:

  1. Eliminating token manually
  2. Posting a false token in HTTP header 'Authorization': 'Bearer ' + false-token
  3. trying to execute my inline <script> ... </script> from another page that has no password protection

And then came the BIG SURPRISE 😮:

In all 3 cases my 'POST' and 'PUT' requests PASSED !! (I expected a FAIL).

That means, 'Authorization': 'Bearer ' + token did not force automatically Authorization in order to prevent unauthorized access to my REST API.

Now therefore, here comes my Question:

How can I force Authorization to my requests to my REST API, so that unauthorized visitors cannot modify our data ('POST' and 'PUT')?

Any comments and suggestions are so welcome.

Thank you.

1 Answer 1

1

After studying carefully 🤓 the WordPress REST API Handbook concerning

Home / REST API Handbook / Extending the REST API / Routes and Endpoints
Home / REST API Handbook / Extending the REST API / Adding Custom Endpoints

I realized I made a couple of mistakes.

Therefore, I wanted to share with you my findings.

Also, at the end I have some additional questions out of curiosity.

1.) Pass object '$request' to the callback functions:

Before:function get_your_data() { ...
After : function get_your_data($request) { ...

2.) Create namespace and route outside of wp/v2 with your own version number:

Before:register_rest_route( 'wp/v2/your_private_page', '/data', array( ...
After : register_rest_route( 'your_private_page/v1', '/data', array( ...

Also adapt http-request URLs in your client side script:

Before:let url = 'https://example.com/wp-json/wp/v2/your_private_page/data';
After : let url = 'https://example.com/wp-json/your_private_page/v1/data';

3.) Add 'permission_callback' to your routes:

Before: I didn't have it (just had the main callbacks)
After : Added prefix_get_private_data_permissions_check() as Permission Callback function

//  Permission Callback 
// 'ypp' is the Prefix I chose (ypp = Your Private Page)

function ypp_get_private_data_permissions_check() {
    // Restrict endpoint to browsers that have the wp-postpass_ cookie.

    if ( !isset($_COOKIE['wp-postpass_'. COOKIEHASH] )) {
       return new WP_Error( 'rest_forbidden', esc_html__( 'OMG you can not create or edit private data.', 'my-text-domain' ), array( 'status' => 401 ) );
    };
    // This is a black-listing approach. You could alternatively do this via white-listing, by returning false here and changing the permissions check.
    return true;
};

// And then add the permission_callback to your POST and PUT routes:

add_action('rest_api_init', function() {
    /**
    * Register here your custom routes for your CRUD functions
    */
    register_rest_route( 'your_private_page/v1', '/data', array(
       array(
          'methods'  => WP_REST_Server::READABLE,
          'callback' => 'get_your_data',
          // Always allow.
          'permission_callback' => '__return_true' // <-- good practice, according to docs
       ),
       array(
          'methods'  => WP_REST_Server::CREATABLE,
          'callback' => 'insert_your_data',
          // Here we register our permissions callback. The callback is fired before the main callback to check if the current user can access the endpoint.
          'permission_callback' => 'ypp_get_private_data_permissions_check', // <-- that was the missing part
       ),
       array(
          'methods'  => WP_REST_Server::EDITABLE,
          'callback' => 'update_your_data',
          // Here we register our permissions callback. The callback is fired before the main callback to check if the current user can access the endpoint.
          'permission_callback' => 'ypp_get_private_data_permissions_check', // <-- that was the missing part
       ),
    ));
});

Obviously, that makes a lot of sense, because it's on the server side (WordPress, php) where the authorization has to take place (dummy me, he he he)


4.) Complete code:

Following code-snippets worked for my selfhosted WordPress installation:

WordPress: version 5.7.2
PHP: version 7.4
host: hostmonster.com
client: Windows 10
browsers: tested on Chrome, Firefox, even Edge 😜 worked

Code (HTML with inline <script> ... </script>):

<form id="form1" name="form1">
  <button id="get" onclick="getValues()">GET</button>
  <button id="insert" onclick="insertValues()">CREATE</button>
  <button id="update" onclick="updateValues()">UPDATE</button>
</form>

<script>
let yourData = [];
let yourDataNew = {};
let yourDataUpdated = {};
let token = "";

function getValues() {
  event.preventDefault();
  //READ data
  getYourData();
};
function insertValues() {
  event.preventDefault();
  //CREATE new datarecord
  yourDataNew = {"column_1": "test-1", "column_2": "test-2"};
  insertYourData(yourDataNew);
};
function updateValues() {
  event.preventDefault();
  //UPDATE datarecord
  let idOfLastRecord = yourData[yourData.length-1].id;
  yourDataUpdated = {"id": idOfLastRecord, "column_1": "test-1-modified", "column_2": "test-2-modified"};
  updateYourData(yourDataUpdated);
};

// We don't need to retrieve token form Cookie. See my EDIT at the end

//GET value of Access Cookie wp-postpass_{hash}
//token = ("; "+document.cookie).split("; wp-postpass_675xxxxxx   =").pop().split(";").shift(); 
//console.log('TOKEN: ' + token);

// Here comes the REST API part:
// HTTP requests with fetch() promises
function getYourData() {
  let url = 'https://example.com/wp-json/your_private_page/v1/data';
  fetch(url, {
    method: 'GET',
    credentials: 'same-origin',
    headers:{
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        //'Authorization': 'Bearer ' + token  <-- not needed, see EDIT at end
    }
  }).then(res => res.json())
  .then(response => get_success(response))
  .catch(error => failure(error));
};

function insertYourData(data) {
  let url = 'https://example.com/wp-json/your_private_page/v1/data';
  fetch(url, {
    method: 'POST',
    credentials: 'same-origin',
    headers:{
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        //'Authorization': 'Bearer ' + token  <-- not needed, see EDIT at end
    },
    body: JSON.stringify(data)
  }).then(res => res.json())
  .then(response => create_success(response))
  .catch(error => failure(error));
};

function updateYourData(data) {
  let url = 'https://example.com/wp-json/your_private_page/v1/data';
  fetch(url, {
    method: 'PUT',
    credentials: 'same-origin',
    headers:{
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        //'Authorization': 'Bearer ' + token  <-- not needed, see EDIT at end
    },
    body: JSON.stringify(data)
  }).then(res => res.json())
  .then(response => update_success(response))
  .catch(error => failure(error));
};

// fetch() promises success functions:
function get_success(json) {
  data = JSON.stringify(json);
  yourData = JSON.parse(data);
  console.log('GET');
  console.log(yourData);
};
function create_success(json) {
  let insertResponse = JSON.stringify(json);
  insertResponse = JSON.parse(insertResponse);
  console.log('CREATE');
  console.log(insertResponse);
};
function update_success(json) {
  let updateResponse = JSON.stringify(json);
  updateResponse = JSON.parse(updateResponse);
  console.log('UPDATE');
  console.log(updateResponse);
};
function failure(error) {
  console.log("Error: " + error);
};

</script>

Code (PHP code in function.php of my installed theme):

/**
 * Add here your custom CRUD functions
 */

/**
  * This is our callback function to return (GET) our data.
  *
  * @param WP_REST_Request $request This function accepts a rest request to process data.
  */
function get_your_data($request) {
    global $wpdb;
    $yourdata = $wpdb->get_results("SELECT * FROM your_custom_table");

    return rest_ensure_response( $yourdata );
};

/**
 * This is our callback function to insert (POST) new data record.
 *
 * @param WP_REST_Request $request This function accepts a rest request to process data.
 */
function insert_your_data($request) {
    global $wpdb;
    $contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';

    if ($contentType === "application/json") {
        $content = trim(file_get_contents("php://input"));
        $decoded = json_decode($content, true);
        $newrecord = $wpdb->insert( 'your_custom_table', array( 'column_1' => $decoded['column_1'], 'column_2' => $decoded['column_2']));
    };
    if($newrecord){
        return rest_ensure_response($newrecord);
    }else{
        //something gone wrong
        return rest_ensure_response('failed');
    };

    header("Content-Type: application/json; charset=UTF-8");
};
/**
 * This is our callback function to update (PUT) a data record.
 *
 * @param WP_REST_Request $request This function accepts a rest request to process data.
 */
function update_your_data($request) {
    global $wpdb;
    $contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';

    if ($contentType === "application/json") {
        $content = trim(file_get_contents("php://input"));
        $decoded = json_decode($content, true);
        $updatedrecord = $wpdb->update( 'your_custom_table', array( 'column_1' => $decoded['column_1'], 'column_2' => $decoded['column_2']), array('id' => $decoded['id']), array( '%s' ));
    };

    if($updatedrecord){
        return rest_ensure_response($updatedrecord);
    }else{
        //something gone wrong
        return rest_ensure_response('failed');
    };

    header("Content-Type: application/json; charset=UTF-8");
};

// 'ypp' is the Prefix I chose (ypp = Your Private Page)
function ypp_get_private_data_permissions_check() {
    // Restrict endpoint to browsers that have the wp-postpass_ cookie.

    if ( !isset($_COOKIE['wp-postpass_'. COOKIEHASH] )) {
        return new WP_Error( 'rest_forbidden', esc_html__( 'OMG you can not create or edit private data.', 'my-text-domain' ), array( 'status' => 401 ) );
    };
    // This is a black-listing approach. You could alternatively do this via white-listing, by returning false here and changing the permissions check.
    return true;
};

add_action('rest_api_init', function() {
    /**
    * Register here your custom routes for your CRUD functions
    */
    register_rest_route( 'your_private_page/v1', '/data', array(
        array(
           'methods'  => WP_REST_Server::READABLE,
           'callback' => 'get_your_data',
           // Always allow.
           'permission_callback' => '__return_true'
        ),
        array(
           'methods'  => WP_REST_Server::CREATABLE,
           'callback' => 'insert_your_data',
           // Here we register our permissions callback. The callback is fired before the main callback to check if the current user can access the endpoint.
           'permission_callback' => 'ypp_get_private_data_permissions_check',
        ),
        array(
           'methods'  => WP_REST_Server::EDITABLE,
           'callback' => 'update_your_data',
           // Here we register our permissions callback. The callback is fired before the main callback to check if the current user can access the endpoint.
           'permission_callback' => 'ypp_get_private_data_permissions_check',
        ),
    ));
});

5.) Testing

Finally got my so desperately anticipated FAILURE  😊

Finally got my so desperately anticipated FAILURE 😊


Final Question, though:

Is 'Authorization': 'Bearer ' + token necessary in header of HTTP request?

After some testing, I realized that if ( !isset($_COOKIE['wp-postpass_'. COOKIEHASH] )) { within the Permission Callback not only checks if the Cookie is set on client browser, but it seems also to check its value (the JWT token).

Because I dobble checked as with my initial code, passing a false token, eliminating the cookie, or leaving session open but changing in the back-end the password of site (hence WordPress would create a new token, hence value of set wp_postpass_ cookie would change) and all test went correctly - REST API blocked, not only verifying presence of cookie, but also its value (which is good - thank you WordPress team).

My Questions here:

  • Can I leave out the 'Authorization': 'Bearer ' + token within my HTTP request header ?
  • Hence, do I have to retrieve at all the cookie value (token) or was it not necessary?

I am not sure whether I got it completely right now. Any comments and suggestions are so welcome.

Thank you.

EDIT:

I found the answer to my 2 questions above in the FAQ section:

Why is the REST API not verifying the incoming Origin header? Does this expose my site to CSRF attacks?

Because the WordPress REST API does not verify the Origin header of incoming requests, public REST API endpoints may therefore be accessed from any site.
This is an intentional design decision.

However, WordPress has an existing CSRF protection mechanism which uses nonces.

And according to my testing so far, the WP-way of authentication works perfectly well.

Thumbs up 👍 for the WordPress team

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.