Skip to content

Update request validator to support new webhook signature #178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"require": {
"php": ">=7.3|^8.0",
"ext-curl": "*",
"ext-json": "*"
"ext-json": "*",
"firebase/php-jwt": "^5.4"
},
"require-dev": {
"phpunit/phpunit": "^8.0|^9.0",
Expand Down
26 changes: 26 additions & 0 deletions examples/signed-request-validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

require_once(__DIR__ . '/../autoload.php');

// Create the validator for incoming requests.
$requestValidator = new \MessageBird\RequestValidator('YOUR_SIGNING_KEY');

// Verify the incoming request from the PHP global variables.
try {
$request = $requestValidator->validateRequestFromGlobals();
} catch (\MessageBird\Exceptions\ValidationException $e) {
// The request was invalid, so respond accordingly.
http_response_code(412);
}

// Or directly verify the signature of the incoming request
$signature = 'JWT_TOKEN_STRING';
$url = 'https://yourdomain.com/path';
$body = 'REQUEST_BODY';

try {
$request = $requestValidator->validateSignature($signature, $url, $body);
} catch (\MessageBird\Exceptions\ValidationException $e) {
// The request was invalid, so respond accordingly.
http_response_code(412);
}
19 changes: 0 additions & 19 deletions examples/signedrequest-verification.php

This file was deleted.

4 changes: 4 additions & 0 deletions src/MessageBird/Objects/SignedRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
namespace MessageBird\Objects;

use MessageBird\Exceptions\ValidationException;
use MessageBird\RequestValidator;

/**
* Class SignedRequest
*
* @package MessageBird\Objects
*
* @link https://developers.messagebird.com/docs/verify-http-requests
* @deprecated Use {@link RequestValidator} instead.
*/
class SignedRequest extends Base
{
Expand Down Expand Up @@ -46,6 +48,7 @@ class SignedRequest extends Base
*
* @return SignedRequest
* @throws ValidationException when a required parameter is missing.
* @deprecated Use {@link RequestValidator::validateRequestFromGlobals()} instead.
*/
public static function createFromGlobals()
{
Expand All @@ -70,6 +73,7 @@ public static function createFromGlobals()
* @param string $body The request body
* @return SignedRequest
* @throws ValidationException when a required parameter is missing.
* @deprecated Use {@link RequestValidator::validateSignature()} instead.
*/
public static function create($query, $signature, $requestTimestamp, $body)
{
Expand Down
102 changes: 98 additions & 4 deletions src/MessageBird/RequestValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

namespace MessageBird;

use Firebase\JWT\JWT;
use Firebase\JWT\SignatureInvalidException;
use MessageBird\Exceptions\ValidationException;
use MessageBird\Objects\SignedRequest;

/**
* Class RequestValidator
* Class RequestValidator validates request signature signed by MessageBird services.
*
* @package MessageBird
* @see https://developers.messagebird.com/docs/verify-http-requests
*/
class RequestValidator
{
const BODY_HASH_ALGO = 'sha256';
const HMAC_HASH_ALGO = 'sha256';
const ALLOWED_ALGOS = array('HS256', 'HS384', 'HS512');

/**
* The key with which requests will be signed by MessageBird.
Expand All @@ -21,21 +26,36 @@ class RequestValidator
*/
private $signingKey;

/**
* This field instructs Validator to not validate url_hash claim.
* It is recommended to not skip URL validation to ensure high security.
* but the ability to skip URL validation is necessary in some cases, e.g.
* your service is behind proxy or when you want to validate it yourself.
* Note that when true, no query parameters should be trusted.
* Defaults to false.
*
* @var bool
*/
private $skipURLValidation;

/**
* RequestValidator constructor.
*
* @param string $signingKey
* @param string $signingKey customer signature key. Can be retrieved through <a href="https://dashboard.messagebird.com/developers/settings">Developer Settings</a>. This is NOT your API key.
* @param bool $skipURLValidation whether url_hash claim validation should be skipped. Note that when true, no query parameters should be trusted.
*/
public function __construct($signingKey)
public function __construct(string $signingKey, bool $skipURLValidation = false)
{
$this->signingKey = $signingKey;
$this->skipURLValidation = $skipURLValidation;
}

/**
* Verify that the signed request was submitted from MessageBird using the known key.
*
* @param SignedRequest $request
* @return bool
* @deprecated Use {@link RequestValidator::validateSignature()} instead.
*/
public function verify(SignedRequest $request)
{
Expand All @@ -47,6 +67,9 @@ public function verify(SignedRequest $request)
return \hash_equals($expectedSignature, $calculatedSignature);
}

/**
* @deprecated Use {@link RequestValidator::validateSignature()} instead.
*/
private function buildPayloadFromRequest(SignedRequest $request): string
{
$parts = [];
Expand All @@ -71,9 +94,80 @@ private function buildPayloadFromRequest(SignedRequest $request): string
* @param SignedRequest $request The signed request object.
* @param int $offset The maximum number of seconds that is allowed to consider the request recent
* @return bool
* @deprecated Use {@link RequestValidator::validateSignature()} instead.
*/
public function isRecent(SignedRequest $request, $offset = 10)
{
return (\time() - (int) $request->requestTimestamp) < $offset;
return (\time() - (int)$request->requestTimestamp) < $offset;
}

/**
* Validate JWT signature.
* This JWT is signed with a MessageBird account unique secret key, ensuring the request is from MessageBird and a specific account.
* The JWT contains the following claims:
* - "url_hash" - the raw URL hashed with SHA256 ensuring the URL wasn't altered.
* - "payload_hash" - the raw payload hashed with SHA256 ensuring the payload wasn't altered.
* - "jti" - a unique token ID to implement an optional non-replay check (NOT validated by default).
* - "nbf" - the not before timestamp.
* - "exp" - the expiration timestamp is ensuring that a request isn't captured and used at a later time.
* - "iss" - the issuer name, always MessageBird.
*
* @param string $signature the actual signature taken from request header "MessageBird-Signature-JWT".
* @param string $url the raw url including the protocol, hostname and query string, {@code https://example.com/?example=42}.
* @param string $body the raw request body.
* @return object JWT token payload
* @throws ValidationException if signature validation fails.
*
* @see https://developers.messagebird.com/docs/verify-http-requests
*/
public function validateSignature(string $signature, string $url, string $body)
{
if (empty($signature)) {
throw new ValidationException("Signature cannot be empty.");
}
if (!$this->skipURLValidation && empty($url)) {
throw new ValidationException("URL cannot be empty");
}

JWT::$leeway = 1;
try {
$decoded = JWT::decode($signature, $this->signingKey, self::ALLOWED_ALGOS);
} catch (\InvalidArgumentException | \UnexpectedValueException | SignatureInvalidException $e) {
throw new ValidationException($e->getMessage(), $e->getCode(), $e);
}

if (empty($decoded->iss) || $decoded->iss !== 'MessageBird') {
throw new ValidationException('invalid jwt: claim iss has wrong value');
}

if (!$this->skipURLValidation && !hash_equals(hash(self::HMAC_HASH_ALGO, $url), $decoded->url_hash)) {
throw new ValidationException('invalid jwt: claim url_hash is invalid');
}

switch (true) {
case empty($body) && !empty($decoded->payload_hash):
throw new ValidationException('invalid jwt: claim payload_hash is set but actual payload is missing');
case !empty($body) && empty($decoded->payload_hash):
throw new ValidationException('invalid jwt: claim payload_hash is not set but payload is present');
case !empty($body) && !hash_equals(hash(self::HMAC_HASH_ALGO, $body), $decoded->payload_hash):
throw new ValidationException('invalid jwt: claim payload_hash is invalid');
}

return $decoded;
}

/**
* Validate request signature from PHP globals.
*
* @return object JWT token payload
* @throws ValidationException if signature validation fails.
*/
public function validateRequestFromGlobals()
{
$signature = $_SERVER['MessageBird-Signature-JWT'] ?? null;
$url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]";
$body = file_get_contents('php://input');

return $this->validateSignature($signature, $url, $body);
}
}
Loading