diff --git a/.gitignore b/.gitignore index ff332dc..739c2e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /vendor/ -/bin/ /composer.phar /phpunit.xml /composer.lock diff --git a/.travis.yml b/.travis.yml index 7971e57..32e4c9d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,56 @@ language: php +sudo: false + +cache: + directories: + - $HOME/.composer/cache + php: - - 5.3.3 - 5.4 - 5.5 - 5.6 + - 7.0 + - 7.1 - hhvm + - nightly -before_script: - - composer self-update - - composer install --no-interaction --prefer-source - -script: phpunit --coverage-text +env: + global: + - SYMFONY_VERSION="" matrix: + fast_finish: true + include: + - php: 7.0 + env: SYMFONY_VERSION="2.3.*" + - php: 7.0 + env: SYMFONY_VERSION="2.4.*" + - php: 7.0 + env: SYMFONY_VERSION="2.5.*" + - php: 7.0 + env: SYMFONY_VERSION="2.6.*" + - php: 7.0 + env: SYMFONY_VERSION="2.7.*" + - php: 7.0 + env: SYMFONY_VERSION="2.8.*" + - php: 7.0 + env: SYMFONY_VERSION="3.0.*" + - php: 7.0 + env: SYMFONY_VERSION="3.1.*" + - php: 7.0 + env: SYMFONY_VERSION="dev-master" allow_failures: - php: hhvm - fast_finish: true + - php: nightly + - env: SYMFONY_VERSION="dev-master" + +before_install: + - composer selfupdate + - .travis/update_symfony_deps_version.sh + +install: + - composer install --no-interaction --prefer-source + +script: + - vendor/bin/phpunit --coverage-text diff --git a/.travis/update_symfony_deps_version.sh b/.travis/update_symfony_deps_version.sh new file mode 100755 index 0000000..054957d --- /dev/null +++ b/.travis/update_symfony_deps_version.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +if [ "$SYMFONY_VERSION" != "" ]; then + composer require --dev --no-update \ + symfony/http-kernel:$SYMFONY_VERSION \ + symfony/http-foundation:$SYMFONY_VERSION +fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5671fde --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +Contributing +============ + +First of all, **thank you** for contributing, **you are awesome**! + +Here are a few rules to follow in order to ease code reviews, and discussions +before maintainers accept and merge your work: + + * You MUST follow the [PSR-1](http://www.php-fig.org/psr/1/) and + [PSR-2](http://www.php-fig.org/psr/2/) recommendations. Use the [PHP-CS-Fixer + tool](http://cs.sensiolabs.org/) to fix the syntax of your code automatically. + * You MUST run the test suite. + * You MUST write (or update) unit tests. + * You SHOULD write documentation. + +Please, write [commit messages that make +sense](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html), +and [rebase your branch](http://git-scm.com/book/en/Git-Branching-Rebasing) +before submitting your Pull Request. + +One may ask you to [squash your +commits](http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html) +too. This is used to "clean" your Pull Request before merging it (we don't want +commits such as `fix tests`, `fix 2`, `fix 3`, etc.). + +Also, while creating your Pull Request on GitHub, you MUST write a description +which gives the context and/or explains why you are creating it. + +Thank you! + +Running tests +------------- + +Before running the test suite, execute the following Composer command to install +the dependencies used by the bundle: + +```bash +$ composer install --dev +``` + +Then, execute the tests executing: + +```bash +$ vendor/bin/phpunit +``` diff --git a/LICENSE b/LICENSE index c12101d..c544829 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014 Sylvain Mauduit +Copyright (c) 2016 Sylvain Mauduit Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ee62cbb..98b5ba0 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,25 @@ -# Github WebHook Stack middleware +Github WebHook Stack middleware +================== -![Travis status](http://img.shields.io/travis/Swop/stack-github-webhook.svg) ![Packagist version](http://img.shields.io/packagist/v/Swop/stack-github-webhook.svg) +[![Build +Status](https://secure.travis-ci.org/Swop/github-webhook-stackphp.png?branch=master)](http://travis-ci.org/Swop/github-webhook-stackphp) [Stack](http://stackphp.com) middleware to restrict application access to GitHub Event bot with signed payload. -Every incoming request will have its X-Hub-Signature header checked in order to see if the request was originally performed by GitHub. -Any requests which doesn't have correct signature will provoke a _401 Unauthorized_ response. +Every incoming request will see its `X-Hub-Signature` header checked in order to validate that the request was originally performed by GitHub. +Any requests which doesn't have correct signature will lead to a `401 Unauthorized` JSON response. -## Usage +Installation +------------ + +The recommended way to install this library is through [Composer](https://getcomposer.org/): + +``` +composer require "swop/github-webhook-stackphp" +``` + +Usage +------------ ### Silex example ```php require __DIR__ . '/../vendor/autoload.php'; @@ -15,14 +27,14 @@ require __DIR__ . '/../vendor/autoload.php'; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -$app = new Silex\Application(); +$app = new \Silex\Application(); $app->get('/', function(Request $request) { return new Response('Hello world!', 200); }); -$app = (new Stack\Builder()) - ->push('Swop\Stack\GitHubWebHook', 'MyGitHubWebhookSecret') +$app = (new \Stack\Builder()) + ->push('Swop\GitHubWebHookStackPHP\GitHubWebHook', 'my_secret') ->resolve($app) ; @@ -48,7 +60,8 @@ $kernel = new AppKernel('dev', true); $kernel->loadClassCache(); $stack = (new Stack\Builder()) - ->push('Swop\Stack\GitHubWebHook', 'MyGitHubWebhookSecret') + ->push('Swop\GitHubWebHookStackPHP\GitHubWebHook', 'my_secret') +; $kernel = $stack->resolve($kernel); @@ -59,18 +72,18 @@ $response->send(); $kernel->terminate($request, $response); ``` -## Intallation +Contributing +------------ -The recommended way to install this library is through [Composer](http://getcomposer.org/): +See [CONTRIBUTING](https://github.com/Swop/github-webhook-stackphp.png/blob/master/CONTRIBUTING.md) file. + +Original Credits +------------ + +* [Sylvain MAUDUIT](https://github.com/Swop) ([@Swop](https://twitter.com/Swop)) as main author. -``` json -{ - "require": { - "swop/stack-github-webhook": "~1.0" - } -} -``` -## License +License +------------ -This library is released under the MIT License. See the bundled LICENSE file for details. +This library is released under the MIT license. See the complete license in the bundled [LICENSE](https://github.com/Swop/github-webhook-stackphp.png/blob/master/LICENSE) file. diff --git a/composer.json b/composer.json index 6d7de9b..2975ee6 100644 --- a/composer.json +++ b/composer.json @@ -1,40 +1,44 @@ { - "name": "swop/stack-github-webhook", + "name": "swop/github-webhook-stackphp", "license": "MIT", "type": "library", - "description": "Stack middleware to restrict application access to GitHub Event bot with signed payload", + "description": "Stack middleware which will verify if the incoming GitHub web hook request is correctly signed.", "keywords": [ "stack", "github", "stack middleware", - "webhook" + "webhook", + "middleware", + "security" ], "authors": [ { "name": "Sylvain Mauduit", - "email": "swop@swop.io", + "email": "sylvain@mauduit.fr", "homepage": "https://github.com/Swop" } ], "require": { - "symfony/http-foundation": "~2.1", - "symfony/http-kernel": "~2.1", - "php": ">=5.3.3" + "php": ">=5.4", + "symfony/http-foundation": "^2.3|^3.0", + "symfony/http-kernel": "^2.3|^3.0", + "stack/builder": "^1.0", + "symfony/psr-http-message-bridge": "^1.0", + "zendframework/zend-diactoros": "^1.3", + "http-interop/http-middleware": "^0.2.1", + "swop/github-webhook-middleware": "^1.0" }, "require-dev": { - "phpunit/phpunit": "~3.7" + "phpunit/phpunit": "^4.5.0" }, "autoload": { "psr-4": { - "Swop\\Stack\\": "src/" + "Swop\\GitHubWebHookStackPHP\\": "src/" } }, - "config": { - "bin-dir": "bin" - }, "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0.x-dev" } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 10a917b..2201c3d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,7 +6,7 @@ bootstrap = "tests/bootstrap.php" > - + ./tests diff --git a/src/GitHubWebHook.php b/src/GitHubWebHook.php index 44d7fc3..40d3ea3 100644 --- a/src/GitHubWebHook.php +++ b/src/GitHubWebHook.php @@ -2,34 +2,39 @@ /* * This file licensed under the MIT license. * - * (c) Sylvain Mauduit + * (c) Sylvain Mauduit * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -namespace Swop\Stack; +namespace Swop\GitHubWebHookStackPHP; -use Symfony\Component\HttpFoundation\JsonResponse; +use Swop\GitHubWebHook\Security\SignatureValidator; +use Swop\GitHubWebHookMiddleware\GithubWebHook as GithubWebHookMiddleware; +use Symfony\Bridge\PsrHttpMessage\Factory\DiactorosFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; +/** + * This class offers a StackPHP middleware which could be used to verify that a request coming from GitHub + * in a web hook context contains proper signature based on the provided secret. + * + * @author Sylvain Mauduit + */ class GitHubWebHook implements HttpKernelInterface { /** @var HttpKernelInterface */ - private $kernel; - /** @var string */ - private $gitHubWebHookSecret; + private $next; /** - * @param HttpKernelInterface $kernel Application kernel - * @param string $gitHubWebHookSecret GitHub secret key configured in the WebHook + * @param HttpKernelInterface $next Next middleware + * @param string $secret GitHub web hook secret */ - public function __construct(HttpKernelInterface $kernel, $gitHubWebHookSecret) + public function __construct(HttpKernelInterface $next, $secret) { - $this->kernel = $kernel; - $this->gitHubWebHookSecret = $gitHubWebHookSecret; + $this->next = $this->createBridge($next, $secret); } /** @@ -37,30 +42,33 @@ public function __construct(HttpKernelInterface $kernel, $gitHubWebHookSecret) */ public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = true) { - if (!$this->isSignatureValid($request)) { - return new JsonResponse( - array('error' => Response::HTTP_UNAUTHORIZED, 'message' => 'Unauthorized'), - Response::HTTP_UNAUTHORIZED - ); - } - - return $this->kernel->handle($request, $type, $catch); + return $this->next->handle($request, $type, $catch); } +// +// /** +// * @param HttpKernelInterface $next Next middleware +// * @param string $secret GitHub web hook secret +// * +// * @return $this +// */ +// static public function create(HttpKernelInterface $next, $secret) +// { +// return new self($next, $secret); +// } - private function isSignatureValid(Request $request) + /** + * @param HttpKernelInterface $next + * @param string $secret + * + * @return HttpKernelInterface + */ + private function createBridge(HttpKernelInterface $next, $secret) { - $hubSignature = $request->headers->get('X-Hub-Signature'); - $explodeResult = explode('=', $hubSignature, 2); - - if (2 !== count($explodeResult)) { - return false; - } - - list($algorithm, $hash) = $explodeResult; - $payload = $request->getContent(); - - $payloadHash = hash_hmac($algorithm, $payload, $this->gitHubWebHookSecret); - - return $hash === $payloadHash; + return new Psr7MiddlewareBridge( + $next, + new GithubWebHookMiddleware(new SignatureValidator(), $secret), + new DiactorosFactory(), + new HttpFoundationFactory() + ); } } diff --git a/src/Psr7MiddlewareBridge.php b/src/Psr7MiddlewareBridge.php new file mode 100644 index 0000000..d4b89d5 --- /dev/null +++ b/src/Psr7MiddlewareBridge.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Swop\GitHubWebHookStackPHP; + +use Interop\Http\Middleware\DelegateInterface; +use Interop\Http\Middleware\ServerMiddlewareInterface; +use Psr\Http\Message\RequestInterface; +use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface; +use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * This Middleware implementation allow a compatibility between PSR-15 middlewares and StackPHP ones. + * + * @author Sylvain Mauduit + */ +class Psr7MiddlewareBridge implements HttpKernelInterface, DelegateInterface +{ + /** @var HttpKernelInterface */ + private $next; + /** @var ServerMiddlewareInterface */ + private $psrMiddleware; + /** @var HttpMessageFactoryInterface */ + private $httpMessageFactory; + /** @var HttpFoundationFactoryInterface */ + private $httpFoundationFactory; + + /** + * @param HttpKernelInterface $next Next middleware + * @param ServerMiddlewareInterface $psrMiddleware Wrapped middleware + * @param HttpMessageFactoryInterface $httpMessageFactory PSR-7 message factory + * @param HttpFoundationFactoryInterface $httpFoundationFactory Symfony's Http Foundation request/response factory + */ + public function __construct( + HttpKernelInterface $next, + ServerMiddlewareInterface $psrMiddleware, + HttpMessageFactoryInterface $httpMessageFactory, + HttpFoundationFactoryInterface $httpFoundationFactory + ) { + $this->next = $next; + $this->psrMiddleware = $psrMiddleware; + $this->httpMessageFactory = $httpMessageFactory; + $this->httpFoundationFactory = $httpFoundationFactory; + } + + /** + * {@inheritdoc} + */ + public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = true) + { + $psr7Request = $this->httpMessageFactory->createRequest($request); + + $psr7Response = $this->psrMiddleware->process($psr7Request, $this); + + return $this->httpFoundationFactory->createResponse($psr7Response); + } + + /** + * {@inheritdoc} + */ + public function process(RequestInterface $request) + { + $httpFoundationRequest = $this->httpFoundationFactory->createRequest($request); + + $httpFoundationResponse = $this->next->handle($httpFoundationRequest); + + return $this->httpMessageFactory->createResponse($httpFoundationResponse); + } +} + diff --git a/tests/GitHubWebHookTest.php b/tests/GitHubWebHookTest.php new file mode 100644 index 0000000..6c8fd68 --- /dev/null +++ b/tests/GitHubWebHookTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Swop\GitHubWebHookStackPHP\Tests; + +use Prophecy\Argument; +use Swop\GitHubWebHookStackPHP\GitHubWebHook; +use Symfony\Bridge\PsrHttpMessage\Factory\DiactorosFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * @author Sylvain Mauduit + */ +class GitHubWebHookTest extends \PHPUnit_Framework_TestCase +{ + public function testInvalidRequestShouldReturnA401Response() + { + $next = $this->prophesize('Symfony\Component\HttpKernel\HttpKernelInterface'); + $next->handle(Argument::any())->shouldNotBeCalled(); + + $request = Request::create('http://localhost/'); + + $middleware = new GitHubWebHook($next->reveal(), 'my_secret'); + $response = $middleware->handle($request); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals(json_encode(array('error' => 401, 'message' => 'Unauthorized')), $response->getContent()); + } + + public function testValidRequestShouldBeHandledByTheNextMiddleware() + { + $content = '{"content": "This is the content"}'; + $request = Request::create('http://localhost/', 'GET', [], [], [], [], $content); + $request->headers->set('X-Hub-Signature', sprintf('sha1=%s', hash_hmac('sha1', $content, 'my_secret'))); + + $psrFactory = new DiactorosFactory(); + $foundationFactory = new HttpFoundationFactory(); + $psrRequest = $psrFactory->createRequest($request); + $expectedRequest = $foundationFactory->createRequest($psrRequest); + + $response = new Response('OK'); + $expectedResponse = $foundationFactory->createResponse($psrFactory->createResponse($response)); + + $next = $this->prophesize('Symfony\Component\HttpKernel\HttpKernelInterface'); + $next->handle($expectedRequest) + ->shouldBeCalledTimes(1) + ->willReturn($response) + ; + + $middleware = new GitHubWebHook($next->reveal(), 'my_secret'); + $response = $middleware->handle($request); + + $this->assertEquals($expectedResponse, $response); + } +} diff --git a/tests/Stack/GitHubWebHookTest.php b/tests/Stack/GitHubWebHookTest.php deleted file mode 100644 index c7b0dc0..0000000 --- a/tests/Stack/GitHubWebHookTest.php +++ /dev/null @@ -1,119 +0,0 @@ -requestStub = $this->getMockBuilder('Symfony\Component\HttpFoundation\Request') - ->disableOriginalConstructor() - ->getMock(); - - $this->headerBagStub = $this->getMockBuilder('Symfony\Component\HttpFoundation\HeaderBag') - ->disableOriginalConstructor() - ->getMock(); - - $this->requestStub->headers = $this->headerBagStub; -// $this->requestStub->expects($this->at(0)) -// ->method('__get') -// ->with($this->equalTo('headers')) -// ->will($this->returnValue($this->headerBagStub)); - - $this->responseStub = $this->getMockBuilder('Symfony\Component\HttpFoundation\Response') - ->disableOriginalConstructor() - ->getMock(); - - $this->kernelStub = $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface'); - } - - /** - * @dataProvider correctSignatures - */ - public function testCorrectSignature($requestContent, $requestSignature, $gitHubWebHookSecret) - { - $this->configureHeaderBagStub($requestSignature); - $this->configureRequestStub($requestContent); - - $this->kernelStub->expects($this->once()) - ->method('handle') - ->will($this->returnValue($this->responseStub)); - - $stackGitHubWebHook = new GitHubWebHook($this->kernelStub, $gitHubWebHookSecret); - $response = $stackGitHubWebHook->handle($this->requestStub); - - $this->assertEquals($response, $this->responseStub); - } - - /** - * @dataProvider incorrectSignatures - */ - public function testIncorrectSignature($requestContent, $requestSignature, $gitHubWebHookSecret) - { - $this->configureHeaderBagStub($requestSignature); - $this->configureRequestStub($requestContent); - - $this->kernelStub->expects($this->never()) - ->method('handle'); - - $stackGitHubWebHook = new GitHubWebHook($this->kernelStub, $gitHubWebHookSecret); - $response = $stackGitHubWebHook->handle($this->requestStub); - - $this->assertEquals(401, $response->getStatusCode()); - $this->assertEquals(array('error' => 401, 'message' => 'Unauthorized'), json_decode($response->getContent(), true)); - } - - public function correctSignatures() - { - // $requestContent, $requestSignature, $gitHubWebHookSecret - - return array( - array('{"foo": "bar"}', "sha1=" . hash_hmac('sha1', '{"foo": "bar"}', 'ThisIsMySecret'), 'ThisIsMySecret'), - array('{"foo": "bar"}', "md5=" . hash_hmac('md5', '{"foo": "bar"}', 'ThisIsMyOtherSecret'), 'ThisIsMyOtherSecret'), - array('{"foo": "bar", "baz": true}', "sha256=" . hash_hmac('sha256', '{"foo": "bar", "baz": true}', 'ThisIsMySecret'), 'ThisIsMySecret'), - ); - } - - public function incorrectSignatures() - { - // $requestContent, $requestSignature, $gitHubWebHookSecret - - return array( - array('{"foo": "bar"}', "sha1=WrongHash", 'ThisIsMySecret'), - array('{"foo": "bar"}', null, 'ThisIsMySecret'), - array('{"foo": "bar"}', "NoAlgorithm", 'ThisIsMySecret'), - ); - } - - private function configureHeaderBagStub($requestSignature) - { - $this->headerBagStub->expects($this->once()) - ->method('get') - ->with($this->equalTo('X-Hub-Signature')) - ->will($this->returnValue($requestSignature)); - } - - private function configureRequestStub($requestContent) - { - $this->requestStub->expects($this->any()) - ->method('getContent') - ->will($this->returnValue($requestContent)); - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9a6e7af..ab9fac5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,3 +1,5 @@ setPsr4("Swop\\GitHubWebHookStackPHP\\Tests\\", "tests");