4

I have created a small search tool that unless you know the EXACT code, you will not be taken to the post. For example, if I enter "OI812" it will take me to a custom post type that is ../CPT/OI812.

The CPT is not part of the regular search and I've removed anything with that slug from a canonical redirect. Unless they use my small search tool and enter the code exactly, it will not take them to the page.

So far, so good. However, I would like to have a uniquely generated URL every time they access the page. E.G. ../CPT/HASHorSOMETHINGcrazyANDrandom. This would make sharing the URL useless unless they visit the page and enter the code into the search tool.

I'm curious how one would go about this? I've searched around but the search terms for something like this seems to be a little ubiquitous. Any help appreciated.

4

You could use a partial implementation of JWTs to pass a unique token including identifying information and the requested post ID (or slug) as the endpoint, and check the identifying info upon validation.

URLs containing a unique token can be rewritten to pass the token as a specific variable. A 'parse_query' action hook can then check for the presence of the token variable, and replace the query with one that will return the proper post if the token is valid - or an error if it isn't.

In this manner, only the visitor issued the token could use it to access the post (unless someone else both acquires the token and spoofs the original visitor's IP - this could be further secured with a cookie or session ID of some sort). Forging tokens is impossible without your secret.

$secret           = '{insert randomly generated string here}';
$custom_post_type = 'my_cpt';
$unique_url_base  = 'cpt';

add_action( 'init', 'wpse_212309_rewrite_unique_token_url' );

/**
 * Adds a rewrite rule to forward URLs in the format /cpt/{unique token} to
 * index.php?post_type=my_cpt&unique_token={unique token}. Supersedes WordPress
 * rewrites for the endpoint.
 **/
function wpse_212309_rewrite_unique_token_url(){
    add_rewrite_rule(
        trailingslashit( $unique_url_base ) . '([^\.]*.[^\.]*)$',
        'index.php?post_type=' . $custom_post_type . '&unique_token=$matches[1]',
        'top'
    );
}

add_action( 'parse_query', 'wpse_212309_decode_unique_token_query' );

/**
 * Replaces queries for the 'my_cpt' post-type containing a unique token with
 * the appropriate 'my_cpt' post if the token is valid (i.e., passed to the
 * server from the client IP to which it was assigned).
 **/
function wpse_212309_decode_unique_token_query( $wp ) {
    if( is_admin() )
        return;

    if( isset( $wp->query_vars[ 'p' ] ) || $custom_post_type != $wp->query_vars[ 'post_type' ] || empty( $_GET[ 'unique_token' ] ) )
        return;

    $post_id = wpse_212309_get_post_id_from_unique_slug( $_GET[ 'unique_token' ] );

    if( ! $post_id ) {
        $wp->set_404();
        status_header( 404 );
        return;
    }

    $wp->parse_request( 'p=' . $post_id );
}

/**
 * Encodes data into a URL-friendly JWT-esque token including IP information
 * for the requesting party, as well as an optional expiration timestamp.
 **/
function wpse_212309_encode_token( $payload, $expiration = null ) {    
    $payload[ 'aud' ] = hash( 'md5', $_SERVER[ 'REMOTE_ADDR' ] . $_SERVER[ 'HTTP_X_FORWARDED_FOR' ] );
    $payload[ 'iss' ] = time();

    if( isset( $expiration ) )
        $payload[ 'exp' ] = $expiration;

    $payload = base64_encode( json_encode( $payload ) );
    $hash    = hash( 'md5', $payload . $secret );

    return urlencode( $payload . '.' . $hash );
}

/**
 * Decodes a token generated by 'wpse_212309_encode_token()', returning the
 * payload if the token is both unaltered and sent by the original client IP
 * or false otherwise.
 **/
function wpse_212309_decode_token( $token ) {
    if( empty( $token ) || -1 === strpos( $token, '.' ) )
        return false;

    $token   = urldecode( $token );
    $token   = explode( '.', $token );
    $hash    = $token[1];
    $payload = $token[0];

    // If the payload or the hash is missing, the token's invalid.
    if( empty( $payload ) || empty( $hash ) )
        return false;

    $hash_check = hash( 'md5', $payload . $secret );

    // Has the payload and/or hash been modified since the token was issued?
    if( $hash_check !== $hash )
        return false;

    $payload = base64_decode( $payload );

    if( ! $payload )
        return false;

    $payload = json_decode( $payload, true );

    if( ! $payload )
        return false;

    $audience_check = hash( 'md5', $_SERVER[ 'REMOTE_ADDR' ] . $_SERVER[ 'HTTP_X_FORWARDED_FOR' ] );

    // Was this token passed to the server by the IP that it was issued to?
    if( $audience_check != $payload[ 'aud' ] )
        return false;

    // Does the payload have an expiration date - if so, has it expired?
    if( ! empty( $payload[ 'exp' ] ) && $payload[ 'exp' ] > time() )
        return false;

    // Token validated - return the payload as legitimate data.
    return $payload;
}

/**
 * Produces a token associating a post ID with a particular client, suitable
 * for inclusion in a URL. Optionally takes a "time to live" argument, in
 * in seconds, before the token should expire.
 **/
function wpse_212309_generate_unique_slug( $post_id, $ttl = null ) {
    $expiration = null;

    if( $ttl )
        $expiration = time() + $ttl * 1000;

    return wpse_212309_encode_token( array( 'pid' => $post_id ), $expiration );
}

/**
 * Returns a post ID from a token if the token was in fact issued to the 
 * requesting client IP, or false otherwise.
 **/
function wpse_212309_get_post_id_from_unique_slug( $token ) {
    $payload = wpse_212309_decode_token( $token );

    if( ! $payload )
        return false;

    return $payload[ 'pid' ];
}

How you actually send visitors to the unique URLs containing a token depends on your application (i.e. how you set up your "search tool"), but using the implementation above you can retrieve a visitor-unique slug for a post ID like this:

// A slug that only works for the visitor it was issued to:
$unique_slug = wpse_212309_generate_unique_slug( $post_id );

// OR, for one that additionally expires in an hour:
$unique_slug = wpse_212309_generate_unique_slug( $post_id, 3600 )
  • That looks pretty legit ;-) – jgraup Dec 19 '15 at 3:56
  • Complex anyway :P. But after reading @gmazzap's answer, really just seems I've really just re-implemented nonces with a payload, d'oh! Still, this was a fun problem :) – bosco Dec 29 '15 at 0:16
4

Use add_rewrite_rule or add_rewrite_endpoint to capture the HASHorSOMETHINGcrazyANDrandom.

Hashids could also help you generate a hash that you could read back later.

$hashids = new Hashids\Hashids('this is my salt');

$post_id = 1;
$request_id = 2;
$random = 3;

$crazy_id = $hashids->encode($post_id, $request_id, $random);

$numbers = $hashids->decode($crazy_id);

UPDATE #1

This creates two endpoints:

http://example.com/CPT/{CODE}/{HASH}

http://example.com/CPT/{CODE}/

In either case a new hash will be generated with a new link everytime. Because I'm using wp_hash_password I loop until a password doesn't contain / so it won't break the URL. I'm sure there are better ways but... it works for this test. The plain password is based on the SERVER_NAME + {CODE} which was taken from the first param.

Each hash URL is unique but always validates if used with the correct code.


if( ! class_exists('HashPoint')):

    class HashPoint {

        const ENDPOINT_NAME       = 'CPT'; // endpoint to capture
        const ENDPOINT_QUERY_NAME = '__cpt'; // turns to param

        // WordPress hooks
        public function init() {
            add_filter('query_vars', array($this, 'add_query_vars'), 0);
            add_action('parse_request', array($this, 'sniff_requests'), 0);
            add_action('init', array($this, 'add_endpoint'), 0);
        }

        // Add public query vars
        public function add_query_vars($vars) {
            $vars[] = static::ENDPOINT_QUERY_NAME;
            $vars[] = 'code';
            $vars[] = 'hash';

            return $vars;
        }

        // Add API Endpoint
        public function add_endpoint() {
            add_rewrite_rule('^' . static::ENDPOINT_NAME . '/([^/]*)/([^/]*)/?', 'index.php?' . static::ENDPOINT_QUERY_NAME . '=1&code=$matches[1]&hash=$matches[2]', 'top');
            add_rewrite_rule('^' . static::ENDPOINT_NAME . '/([^/]*)/?', 'index.php?' . static::ENDPOINT_QUERY_NAME . '=1&code=$matches[1]', 'top');

            flush_rewrite_rules(false); //// <---------- REMOVE THIS WHEN DONE
        }

        // Sniff Requests
        public function sniff_requests($wp_query) {
            global $wp;

            if(isset($wp->query_vars[ static::ENDPOINT_QUERY_NAME ])) {
                $this->handle_request(); // handle it
            }
        }

        // Handle Requests
        protected function handle_request() {
            // Control the template used

            add_filter('template_include', function($original_template) {

                // global $wp_query;
                // var_dump ( $wp_query->query_vars );

                // var_dump($original_template);

                return get_template_directory() . '/crazy-hash.php';
            });
        }
    }

    $hashEP = new HashPoint();
    $hashEP->init();

endif; // HashPoint

CRAZY HASH TEMPLATE

<?php
/**
 * Template Name: Crazy Hash
 */
get_header();

global $wp_query;
$code = $wp_query->query_vars[ 'code' ];
$hash = empty($wp_query->query_vars[ 'hash' ]) ? 'NONE' : $wp_query->query_vars[ 'hash' ];
$hash = urldecode($hash);

echo 'Code : ' . $code;
echo '<br />';
echo 'Hash : ' . $hash;
echo '<br />';
echo '<br />';

require_once(ABSPATH . 'wp-includes/class-phpass.php');
$wp_hasher = new PasswordHash(8, true);

$plain = $_SERVER[ 'SERVER_NAME' ] . '-' . $code;
$hash_mash = wp_hash_password($plain);

// make sure we don't have any `/` to break the url
while(strpos($hash_mash, '/')) {
    $hash_mash = wp_hash_password($plain);
}

echo 'Valid?<br />';

if($wp_hasher->CheckPassword($plain, $hash)) {
    echo "YES, Matched<br /><br />";
}
else {
    echo "No, BAD HASH!!!<br /><br />";
}

$url = get_home_url(NULL, 'CPT/' . $code . '/' . urlencode($hash_mash));

echo "Try this Hash : <a href=\"$url\">$hash_mash</a>";
echo '<br /><br />';

// ... more ...

get_footer();

UPDATE #2 | LIFETIME NONCE

For @birgire - To make a lifetime nonce wouldn't you just need to remove the wp_nonce_tick() from wp_create_nonce?

function wp_create_lifetime_nonce($action = - 1) {
    $user = wp_get_current_user();
    $uid = (int) $user->ID;
    if( ! $uid) { 
        $uid = apply_filters('lifetime_nonce_user_logged_out', $uid, $action);
    }

    $token = wp_get_session_token();
    $i = 0;//wp_nonce_tick(); -- time is not a factor anymore

    return substr(wp_hash($i . '|' . $action . '|' . $uid . '|' . $token, 'nonce'), - 12, 10);
}

function wp_verify_lifetime_nonce($nonce, $action = - 1) {
    $nonce = (string) $nonce;
    $user = wp_get_current_user();
    $uid = (int) $user->ID;
    if( ! $uid) {
        $uid = apply_filters('lifetime_nonce_user_logged_out', $uid, $action);
    }

    if(empty($nonce)) {
        return false;
    }

    $token = wp_get_session_token();
    $i = 0; //wp_nonce_tick();  -- time is not a factor anymore

    // Nonce generated anytime ago
    $expected = substr(wp_hash($i . '|' . $action . '|' . $uid . '|' . $token, 'nonce'), - 12, 10);
    if(hash_equals($expected, $nonce)) {
        return 1;
    }

    do_action('wp_verify_lifetime_nonce_failed', $nonce, $action, $user, $token);

    // Invalid nonce
    return false;
}

$code = 'OI812';

$lifetime_nonce = wp_create_lifetime_nonce($code);
$nonce = wp_create_nonce($code);

echo "<pre>";
print_r(
    array(
        $code,
        $lifetime_nonce,
        $nonce,
        ! wp_verify_nonce($nonce, $code) ? 'FAILED' : 'WORKED',
        ! wp_verify_lifetime_nonce($lifetime_nonce, $code) ? 'FAILED' : 'WORKED',
    ));
echo "</pre>";
  • Any luck with this? – jgraup Nov 3 '16 at 2:10
3

An alternative simple solution.

Creative uses of Nonces

WordPress has internal system to generate unique hash that can be then verified: nonces. Normally nonces are used to prevent CSRF attacks, but considering that out-of-the-box a WP nonce:

  • is an hash
  • may be verified
  • is valid only for a limited frame of time
  • is coupled to a specific user

It fits quite well all your needs.

No rewrite rules

I'll be honest: I don't like WordPress rewrite API. Rules are stored in database, they need to be "flushed" before being used, and the API itself is far from ideal.

In cases like this, I would just go a bit low-level, and use the 'do_parse_request' filter hook to check the secret url and set proper vars if needed.

The url format

For the code below I'll assume a link like:

home_url( '/s/'. $post_id . '|' . wp_create_nonce('my-cpt'.$post_id) )

This makes use of nonces so it is unique per-user and even the same user can use it for a limited amount of time, by default 1 day, but it can be changed using 'nonce_life' filter.

It will look like this https://example.com/s/MTUy|ab5f9370de.

Decript url and send to post

With 'do_parse_request' filter, we can intercept the request for an url like the one above before is parsed by WordPres, and we can prevent WordPress to further process the url.

is_admin() or add_filter('do_parse_request', function($do, $wp) {

     // quick way to get current url
     $url = trim(esc_url_raw(add_query_arg(array())), '/');
     // check if WP has some subfolder in home url
     $path = trim(parse_url(home_url(),  PHP_URL_PATH), '/');
     $path and $path .= '/';

     // this is not one of ours secret urls, just do nothing
     if (strpos($url, $path.'s/') !== 0) {
         return $do;

     // extract post id and nonce from url
     $sectretUrl = explode('|', preg_replace('~^'.$path.'s/~', '', $url), 2);
     $id = (int) base64_decode(urldecode($sectretUrl[0])) ;
     $nonce = empty($sectretUrl[1]) ? false : $sectretUrl[1];

     // verify nonce, if not valid let WordPress continue the flow
     // that very likely ends on a 404
     if (!$id || !$nonce || ! wp_verify_nonce($nonce, 'my-cpt'.$id)) {
         return $do;

     // everything ok, let's set query var and tell WP don't parse request
     $wp->query_vars = array('p' => $id);
     $wp->is_secret_ok = $id;
     return false;

}, PHP_INT_MAX, 2);

That's it, it works. All with a single code snippet of just 15 lines of code (excluding comments). Not even need to flush rewrite rules.

But you still have a problem.

Disabling standard access

If you look at the secret url example I wrote above, https://example.com/s/MTUy|ab5f9370de, it's quite easy to understand that first part is something base64-encoded.

If someone tries to decode it, will find the post id. And using an url like "http://example.com?p={$decoded_id}" that person will be able to view the post.

Moreover, I guess that the title of the post is visible. If you use the slug that WordPress autogenerates, using an url like "http://example.com?p={$guessed_slug}" your post will be visible again.

This means you need to prevent access to standard url for the CPT post. Maybe only allow privileged users, like administrators and editors.

add_action('template_redirect', function() {
  // when not our CPT or user is privileged, do nothing
  if (! is_singular('my-cpt') || current_user_can('edit_others_posts')) {
    return;
  }
  global $wp;
  // if this is from "standard" url, exit with error
  if (! isset($wp->is_secret_ok) || $wp->is_secret_ok !== (int) get_queried_object_id()) {
     wp_die('Not allowed.');
  }
  // prevent canonical redirect that will ends in a 404 request
  add_filter('redirect_canonical', '__return_false');
}, -1);

To prevent access to users that comes from "standard" url, I used a variable $wp->is_secret_ok that I set in the previous code snippet, when the hashed url is verified.

Creating the secret CPT link

The url that we need is a bit complex, so we may want to create a function that builds it, taking as param the post ID.

It is also possible to use that function to filter the permalink and let WordPress automatically output the "secret" url when you just call the_permalink().

Something like this:

function my_cpt_secret_url($postId) {
   $id = urlencode(base64_encode((string)$postId));

   return home_url('/s/'.$id.'|'.wp_create_nonce('my-cpt'.$postId));
}

is_admin() or add_filter('post_type_link', function($link, $post) {
  if ($post->post_type === 'my-cpt') {
      return my_cpt_secret_url($post->ID)
  return $link;
}, 30, 2);
  • I like this approach (it would be handy to have a custom nonce_lifetime value for this setup only) – birgire Dec 20 '15 at 9:42
  • @birgire -- wordpress.stackexchange.com/a/212316/84219 added a custom lifetime nonce to my answer just for you. – jgraup Dec 21 '15 at 18:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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