snicco/signed-url

A small, framework agnostic library to create and validate signed-urls.

v2.0.0-beta.9 2024-09-07 14:27 UTC

README

codecov Psalm Type-Coverage Psalm level PhpMetrics - Static Analysis PHP-Versions

Table of contents

  1. Motivation
  2. Installation
  3. Usage
    1. Creating a secret
    2. Creating a signed-url
    3. Validating a signed url
      1. PSR-15 middleware
      2. Other PHP applications
    4. Storage types
      1. Session
      2. Null
      3. InMemory
      4. PSR-16
      5. Implement your own
  4. Contributing
  5. Issues and PR's
  6. Security

Motivation

While developing the Snicco project we couldn't find any good standalone PHP-libraries for signing urls. We needed this functionality in a couple of places, so we decided to roll our own implementation.

Features:

  • Uses strong, random secrets, generated by a CSPRNG and secure hash functions.
  • Validates the signature, the expiration and an enforced usage-limit on a per url basis.
  • PSR-7/15 compatible. No hidden dependencies on PHP super globals.
  • Protects against timing based side-channel attacks
  • Permanently invalidates a signed-url after the max usage. (Rotating your secret invalidates all signed-urls)
  • Defensively programmed, making incorrect usage very hard.
  • Support for multiple storage backends.
  • A properly tested and straightforward API.

While the term signed-url is technically incorrect (this package uses HMACs, not asymmetric signatures), we chose to stick to the way Symfony and Laravel name it.

Installation

composer require snicco/signed-url

Usage

Creating a secret

Run the following command from your project root and store the generated secret in a secure location that is outside your web root.

vendor/bin/generate-signed-url-secret

This will output a random, hex-encoded secret that looks like this: 32|1e21be67f2279e485c7c5e8291d05edda7e76ffb01ddb8eb290ce826528ad2ff

This secret should NEVER be stored in version control.

In your application, load the secret from an environment variable in your application using something like symfony/dotenv.

// require 'vendor/autoload.php';
$secret = \Snicco\Component\SignedUrl\Secret::fromHexEncoded(getenv('SIGNED_URL_SECRET'));

Creating a signed-url

$secret = /* */

$hmac = new Snicco\Component\SignedUrl\HMAC($secret, 'sha256')

// This is a simple interface.
// Use one of the inbuilt storages in the #storages section or provide your own.
$storage = /* */

$signer = new \Snicco\Component\SignedUrl\UrlSigner($storage, $hmac);

// The maximum lifetime in seconds that this link should be valid for.
$lifetime_in_sec = 60;

// The maximum amount that this link should be valid for.
// After each successfully validation this amount will be decreased by 1.
$usage_limit = 1;

// optional: adding request context that must be the same in order to
// successfully validate a signed-url.
$context = ($_SERVER['REMOTE_ADDR'] ?? '') . ($_SERVER['HTTP_USER_AGENT'] ?? '');

$signed_url = $signer->sign('https://example.com/unsubscribe?user_id=12' , $lifetime_in_sec, $usage_limit, $context);

$mailer = /* */

$href = $signed_url->asString();

// $href will be something like transformers:
// https://example.com/unsubscribe?user_id=12expires=1639783661&signature=Del1cGmLB1wVET6PJieCrQ==|1MTBBGIpEGPVuGaKDjjrHDBusMNoWB15Ng5lKBSSLQY=

$mailer->send('user12@gmail.com', "Click <a href='{{$href}}'> here <a/> to unsubscribe.")

Validating a signed-url

Validation of signed-urls should be performed in a middleware to avoid boilerplate.

The code samples below describe the manual way to validate urls in any PHP application.

PSR-15 middleware

If your favorite framework is PSR-7/PSR-15 compatible and supports middleware on a per-route basis, you can use our PSR-15 middleware bridge which makes this dead simple.

All PHP apps

$storage = /* */
$hmac = /* */

// Clean expired links periodically.
try {
    // 0-100
    $percentage = 2;
   \Snicco\Component\SignedUrl\GarbageCollector::clean($storage, $percentage);
   
} catch (UnavailableStorage $e) {
    // gc did not work for some reason. Log and continue.
    error_log($e->getMessage());
    
}

$validator = new \Snicco\Component\SignedUrl\SignedUrlValidator($storage, $hmac);

$target = $_SERVER['REQUEST_URI'].'?'.$_SERVER['QUERY_STRING'];

try {

    // optional context, has to be the same scheme used at creation.
    $context = ($_SERVER['REMOTE_ADDR'] ?? '') . ($_SERVER['HTTP_USER_AGENT'] ?? '');
    
    $validator->validate( $target, $context);
    
} catch (\Snicco\Component\SignedUrl\Exception\InvalidSignature $e ) {
        
   error_log("invalid signature.");     
   echo "This link has expired. Please request a new one."
    
} catch (\Snicco\Component\SignedUrl\Exception\SignedUrlExpired $e ) {

   error_log("signed url expired.");    
   echo "This link has expired. Please request a new one."
   
} catch (\Snicco\Component\SignedUrl\Exception\SignedUrlUsageExceeded $e ) {

   error_log("signed url usage exceeded.");  
   echo "This link has expired. Please request a new one."
}

// Everything is valid.
// If the link can be used multiple times the usage is decremented automatically by 1.
echo "You have been unsubscribed."

Storage types

The Snicco\SignedUrl\Contracts\SingedUrlStorage keeps an identifier for each signed-url that is created and ensures that your max usage limits are enforced.

Without some form of backend storage, signed-urls are valid any number of times until the expiration timestamp is passed. (If this is what you want you can use the NullStorage).

SessionStorage (included):

The SessionStorage accepts an array or any object that implements ArrayAccess (passed by reference).

// using an array.
$storage = new \Snicco\Component\SignedUrl\Storage\SessionStorage($_SESSION);

// using an object implementing ArrayAccess
$arr = new MyArrayAccess();
$storage = new \Snicco\Component\SignedUrl\Storage\SessionStorage($arr);

NullStorage (included)

The NullStorage does nothing. No signed-urls will be stored and no usage limits are enforced. Use this only if your signed-urls should be valid any number of times before expiring.

Validity of a signed-url will be based solely on the correct signature and expriation timestamp.

InMemory (included):

You can use the InMemoryStorage during unit tests.

$storage = new \Snicco\Component\SignedUrl\Storage\InMemoryStorage()

PSR16-Cache (bridge package):

We have a dedicated PSR-16 bridge that will allow you to use any PSR-16 cache as a storage.

Implementing your own storage:

Implementing your own storage is very easy. You only have to implement the simple SingedUrlStorage interface.

Use the snicco/signed-url-testing package to test your implementation against the contract of the interface.

Contributing

This repository is a read-only split of the development repo of the Snicco project.

This is how you can contribute.

Reporting issues and sending pull requests

Please report issues in the Snicco monorepo.

Security

If you discover a security vulnerability, please follow our disclosure procedure.