Custom WordPress plugin update server

Gepubliceerd op:

Tags: deployment | english | webdevelopment | wordpress

(Photo by Taylor Vick on Unsplash)

Recently I had to implement a custom update server for our private and internal plugins. The reason is because I’m trying to create a (more) stable deployment strategy. At the moment updates to our custom components had to be manually built and uploaded through FTP. This is obviously a potential risk, as humans tend to make mistakes. Even (or especially) if it’s a task you’ve repeated a hundred times.

My usual goto ‘solution’ is to utilize a Bitbucket Pipeline, in which the plugin is tested, built and shipped off to the server. Then I’d upload the plugin through an SSH connection with rsync. While this works in most cases, it does have some downsides especially if the website has moderate to high traffic. This is because as the pipeline is uploading the files through rsync, the user viewing the site might encounter errors as the file(s) have not yet been fully written to disk. So I set out to think of another solution.

The most obvious one (to me) was to utilize the built in update capabilities of WordPress. Whenever a plugin, theme or WordPress itself gets updated, the maintenance mode is triggered. The user will see a screen informing them of the maintenance and to check back later. But, since this is about custom components, I didn’t want the plugins on the public WordPress repository. Thanks to @YahnisElsts there is an existing library and server to use the integrated update functionality of WordPress, without the plugin (or theme) being publically available. Great!

Let’s start off with the update server itself: WP Update Server (https://github.com/YahnisElsts/wp-update-server). Setting up this server is dead easy and is basically nothing more than uploading the GIT repository to your server and making sure some directories are writeable. Unfortunately, the default setup does not put the ‘packages’ folder (which contains your private plugins and themes) behind the public root folder. This means somebody might be able to guess the names of the archives and download them. Furthermore, no additional license checking (or something like it) are built in. Luckily it’s quite easy to add them.

Move everything behind the public folder

Start off by moving the index.php to your public folder (i.e. public, public_html, httpddocs, etc.) and the other folders and files besides your public folder. For example:

my-server/
├─ includes/
├─ packages/
│  ├─ your.zip
│  ├─ packages.zip
│  ├─ here.zip
├─ **public/**
│  ├─ index.php
├─ .gitignore
├─ composer.json
├─ composer.lock
├─ loader.php

Next, update the first line in index.php as the path to loader.php is now incorrect. We need to move one directory up, which we can do with dirname():

// Original
require **DIR** . '/loader.php';

// Updated
require dirname(**DIR**) . '/loader.php';

License validation

Right now, anyone who knows the URL to your update server, will get a valid download link to your private plugin. That’s not what we want. We want to make sure only ‘authorized’ servers can access those. Basically, a license has to be provided in order to receive the plugin archive. In order to secure this, we need to make our own update server implementation, by extending the included Wpup_UpdateServer class.

Let’s start with a little bit of background. A valid download link looks like this: https://yourserver.com/?action=get_metadata&slug=your-plugin. The update server will then respond with something like this:

{
  "name": "Your Plugin",
  "version": "1.0.1",
  "author": "Author",
  "author_homepage": "https:\\/\\/awesome-server.com\\/",
  "last_updated": "2021-05-25 14:29:55",
  "slug": "your-plugin",
  "download_url": "https:\\/\\/yourserver.com\\/?action=download&slug=your-plugin",
  "request_time_elapsed": "0.001"
}

Notice the download_url property. If you were to access it, a download would start immediately of the requested plugin. We need to do two things:

  1. Remove the download_url property from the response if no valid license was given,
  2. Add a (expiring) key to the download_url which is cryptographically signed with the license. Capturing a download URL would be useless without the license as we can validate it before starting the download.

Remove the download_url

Let’s start by creating your own implementation of the Wpup_UpdateServer class. If we view the source, we see we can use the filterMetadata($meta, $request) method to change the default output of a ‘get_metadata‘ request. In there we can unset the ‘download_url‘ entry from the $meta array if the current request is not authorized.

There is also a checkAuthorization($request) method available where we might be able to validate the given license. I opted for a custom header in which the license should be present.

Below is a simplified version of what I have implemented. It checks if the current request is valid in checkAuthorization() and sets the $isAuthorized property accordingly. In filterMetadata this property is read and if the user is unauthorized, the download_url is removed from the metadata array.

This example does require for you to implement some of your own logic in the licenseIsValid() method :).

<?php

class CustomUpdateServer extends Wpup_UpdateServer
{
    /**
     * The custom header name which should contain the license.
     * @var string
     */
    protected $authHeader = 'X-CUSTOM-AUTH';

    /**
     * Wether or not the current request is authorized.
     * @var bool
     */
    protected $isAuthorized = false;

    /**
     * Change the default metadata output
     * @param  array $meta
     * @param  Wpup_Request $request
     * @return array
     */
    protected function filterMetadata(array $meta, Wpup_Request $request): array
    {
        $meta = parent::filterMetadata($meta, $request);

        if (! $this->isAuthorized()) {
            unset($meta['download_url']);
        }

        return $meta;
    }

    /**
     * Check if the current request is authorized.
     * @param  Wpup_Request $request 
     * @return CustomUpdateServer
     */
    protected function checkAuthorization(Wpup_Request $request): CustomUpdateServer
    {
        $authHeader = $this->getAuthHeaderFromRequest($request);
        
        if ($this->licenseIsValid($authHeader)) {
            return $this->markAuthorized();
        }

        return $this->markUnauthorized();
    }

    /**
     * Return the contents of our custom header.
     * @param  Wpup_Request $request 
     * @return string 
     */
    protected function getAuthHeaderFromRequest(Wpup_Request $request): string
    {
        return (string) $request->headers->get($this->authHeader);
    }

    /**
     * Mark the current request as unauthorized.
     * @return CustomUpdateServer
     */
    protected function markUnauthorized(): CustomUpdateServer
    {
        $this->isAuthorized = false;

        return $this;
    }

    /**
     * Mark the current request as authorized.
     * @return CustomUpdateServer
     */
    protected function markAuthorized(): CustomUpdateServer
    {
        $this->isAuthorized = true;

        return $this;
    }

    /**
     * Wether or not the current request has a valid license.
     * @return bool 
     */
    protected function isAuthorized(): bool
    {
        return $this->isAuthorized;
    }

    /**
     * Stub to implement your own backend of some sorts.
     * @param  string $license 
     * @return bool          
     */
    protected function licenseIsValid(string $license): bool
    {
        // Your implementation of a backend of some sorts,
        // which contains all of your valid licenses.
    }
}

Signed download URL

Now if the current request would be authorized, it’s a good idea to ‘sign’ the download URL with some sort of key. If that download URL is accessed without or with an invalid key, the download would be rejected.

This ‘signed’ part of the URL could be the license, but signed with your private server key. I’d recommend to use an existing encryption solution, like Sodium. Below are examples (taken from jedisct1/libsodium-php), expanded to fit this example.


<?php

$secretKey = $this->hypotheticalMethodToLoadServerKey();
$message = $this->getAuthHeaderFromRequest($request);

$blockSize = 16;
$paddedMessage = sodium_pad($message, $blockSize);
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

// sodium_crypto_secretbox() outputs binary data, so we'll
// convert it to hexidecimal to make it URL safe.
$binMessage = sodium_crypto_secretbox($paddedMessage, $nonce, $secretKey);
$encryptedMessage = bin2hex($binMessage);

echo $encryptedMessage;
// 092e56a4ef693d4106e910ebb5f....

To check if the given signed URL is valid, we need to reverse the encryption and compare the result with the license provided in the header:

<?php

$secretKey = $this->hypotheticalMethodToLoadServerKey();
$encryptedMessage = $this->hypotheticalMethodToGetSignedPartOfURL();

$binMessage = hex2bin($digest);
$decryptedPaddedMessage = sodium_crypto_secretbox_open($binMessage, $nonce, $secretKey);

$blockSize = 16;
$decryptedMessage = sodium_unpad($decryptedPaddedMessage, $blockSize);

// Now compare the $decryptedMessage with the license in the request

Below is an example implementation, continued from the previous example. Note the updated filterMetadata() method, where a key and nonce are added to the URL.

<?php

class CustomUpdateServer extends Wpup_UpdateServer
{
    /**
     * The block size for Sodium. Used to hide the length of the message.
     * @var integer
     */
    protected $blockSize = 16;

    /**
     * Change the default metadata output. 
     * @param  array $meta
     * @param  Wpup_Request $request
     * @return array
     */
    protected function filterMetadata($meta, $request): array
    {
        $meta = parent::filterMetadata($meta, $request);

        if (! $this->isAuthorized()) {
            unset($meta['download_url']);
        } else {
            [$sign, $nonce] = $this->getSignedAuthenticationKey($request);
            $meta['download_url'] .= '&sign=' . $sign . '&nonce=' . $nonce;
        }

        return $meta;
    }

    /**
     * Return the signed license and the nonce used.  
     * @param  Wpup_Request $request
     * @return array
     */
    protected function getSignedAuthenticationKey($request): array
    {
        // To do: implement your own logic to load a private server key.
        $secretKey = $this->hypotheticalMethodToLoadServerKey();
        $license = $this->getAuthHeaderFromRequest($request);

        $paddedLicense = sodium_pad($license, $this->blockSize);
        $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

        // sodium_crypto_secretbox() outputs binary data, so some
        // converting is needed to make it URL safe.
        $binLicense = sodium_crypto_secretbox($paddedLicense, $nonce, $secretKey);
        $encryptedLicense = bin2hex($binLicense);

        return [$encryptedLicense, $nonce];
    }

    /**
     * Provide the download. Exits with a 403 if the request is not valid.
     * @param  Wpup_Request $request 
     * @return void
     */
    protected function actionDownload(Wpup_Request $request)
    {
        // Resolve the signed part and the nonce from the URL.
        $encryptedLicense = $request->query['sign'] ?? '';
        $nonce = $request->query['nonce'] ?? '';

        // To do: implement your own logic to load a private server key.
        $secretKey = $this->hypotheticalMethodToLoadServerKey();

        $binLicense = hex2bin($encryptedLicense);
        $paddedLicense = sodium_crypto_secretbox_open($binLicense, $nonce, $secretKey);

        $license = sodium_unpad($paddedLicense, $this->blockSize);

        // Check if the decrypted $license exists in your backend and 
        // if it's the same as the provided license in the header.
        // If it's not, do not provide the download. E.g.:
        if (
            $this->licenseIsValid($license) === false ||
            $license !== $this->getAuthHeaderFromRequest($request)
        ) {
            return $this->exitWithError('Unauthorized.', 403);
        }

        parent::actionDownload($request);
    }
        
    protected function hypotheticalMethodToLoadServerKey(): string
    {
        // @todo implement your own logic here.
    }
}

So far so good! It seems the back-end is now ready to serve plugin updates. The next part is to setup the client which contacts this back-end. Unfortunately I haven’t written that part yet.

You can of course nag me about it through an e-mail. My details are over on the contact page.