Skip to content

Commit 266ca83

Browse files
authored
Merge pull request #5 from SoapBox/feature/prevent-replay-attacks
[Feature] Automatic Replay Request Prevention
2 parents 016dd83 + 0763763 commit 266ca83

File tree

11 files changed

+511
-17
lines changed

11 files changed

+511
-17
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"guzzlehttp/guzzle": "^6.2",
1616
"illuminate/http": "^5.4",
1717
"illuminate/support": "^5.4",
18+
"nesbot/carbon": "^1.22",
1819
"ramsey/uuid": "^3.6"
1920
},
2021
"require-dev": {

resources/config/signed-requests.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,19 @@
66
| The algorithm to sign the request with
77
|--------------------------------------------------------------------------
88
|
9-
| This is the algorithm we'll use to sign the
9+
| This is the algorithm we'll use to sign the request.
1010
*/
1111
'algorithm' => env('SIGNED_REQUEST_ALGORITHM', 'sha256'),
1212

13+
/*
14+
|--------------------------------------------------------------------------
15+
| The prefix to use for all of our cache values
16+
|--------------------------------------------------------------------------
17+
|
18+
| This is the prefix we'll use for all of our keys.
19+
*/
20+
'cache-prefix' => env('SIGNED_REQUEST_CACHE_PREFIX', 'signed-requests'),
21+
1322
/*
1423
|--------------------------------------------------------------------------
1524
| Available header overrides
@@ -32,5 +41,19 @@
3241
| key is expected from the environment. You can change this behaviour,
3342
| however it is not recommended.
3443
*/
35-
'key' => env('SIGNED_REQUEST_KEY', 'key')
44+
'key' => env('SIGNED_REQUEST_KEY', 'key'),
45+
46+
/*
47+
|--------------------------------------------------------------------------
48+
| Allows the management and tolerance of request replay's
49+
|--------------------------------------------------------------------------
50+
|
51+
| This allows you to configure if the middleware should prevent the same
52+
| request being replayed to your application, and adjust the tolerance
53+
| for request expiry.
54+
*/
55+
'request-replay' => [
56+
'allow' => env('SIGNED_REQUEST_ALLOW_REPLAYS', false),
57+
'tolerance' => env('SIGNED_REQUEST_TOLERANCE_SECONDS', 30)
58+
],
3659
];
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace SoapBox\SignedRequests\Exceptions;
4+
5+
use Exception;
6+
use Throwable;
7+
use Symfony\Component\HttpFoundation\Response;
8+
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
9+
10+
class ExpiredRequestException extends Exception implements HttpExceptionInterface
11+
{
12+
/**
13+
* The default exception message.
14+
*
15+
* @var string
16+
*/
17+
const MESSAGE = 'The provided request has expired';
18+
19+
/**
20+
* Provides a default error message for an expired request.
21+
*
22+
* @param string $message
23+
* A customizable error message.
24+
*/
25+
public function __construct(string $message = self::MESSAGE)
26+
{
27+
parent::__construct($message);
28+
}
29+
30+
/**
31+
* Returns an HTTP BAD REQUEST status code.
32+
*
33+
* @return int
34+
* An HTTP BAD REQUEST response status code
35+
*/
36+
public function getStatusCode()
37+
{
38+
return Response::HTTP_BAD_REQUEST;
39+
}
40+
41+
/**
42+
* Returns response headers.
43+
*
44+
* @return array
45+
* Response headers
46+
*/
47+
public function getHeaders()
48+
{
49+
return [];
50+
}
51+
}

src/Middlewares/VerifySignature.php

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
use Closure;
66
use Illuminate\Http\Request;
7-
use Illuminate\Contracts\Config\Repository;
87
use SoapBox\SignedRequests\Requests\Verifier;
8+
use Illuminate\Contracts\Cache\Repository as Cache;
9+
use Illuminate\Contracts\Config\Repository as Configurations;
10+
use SoapBox\SignedRequests\Exceptions\ExpiredRequestException;
911
use SoapBox\SignedRequests\Exceptions\InvalidSignatureException;
1012

1113
class VerifySignature
@@ -17,17 +19,28 @@ class VerifySignature
1719
*/
1820
protected $configurations;
1921

22+
/**
23+
* An instance of the cache repository.
24+
*
25+
* @var \Illuminate\Contracts\Cache\Repository
26+
*/
27+
protected $cache;
28+
2029
/**
2130
* Expect an instance of the configurations repository so we can lookup
2231
* where to find our signature, algorithm, and key from.
2332
*
2433
* @param \Illuminate\Contracts\Config\Repository $configurations
2534
* An instance of the Illuminate configurations repository to lookup
2635
* configurations with.
36+
* @param \Illuminate\Contracts\Cache\Repository $cache
37+
* An instance of the Illuminate cache repository for preventing
38+
* replay attacks.
2739
*/
28-
public function __construct(Repository $configurations)
40+
public function __construct(Configurations $configurations, Cache $cache)
2941
{
3042
$this->configurations = $configurations;
43+
$this->cache = $cache;
3144
}
3245

3346
/**
@@ -36,6 +49,10 @@ public function __construct(Repository $configurations)
3649
*
3750
* @throws \SoapBox\SignedRequests\Exceptions\InvalidSignatureException
3851
* Thrown when the signature of the request is not valid.
52+
* @throws \SoapBox\SignedRequests\Exceptions\ExpiredRequestException
53+
* Thrown if request replays are disabled and either the request
54+
* timestamp is outside the window of tolerance, or the request has
55+
* previously been served.
3956
*
4057
* @param \Illuminate\Http\Request $request
4158
* An instance of the request.
@@ -48,6 +65,22 @@ public function handle(Request $request, Closure $next)
4865
{
4966
$signed = new Verifier($request);
5067

68+
$key = sprintf(
69+
'%s.%s',
70+
$this->configurations->get('signed-requests.cache-prefix'),
71+
$signed->getId()
72+
);
73+
74+
$tolerance = $this->configurations->get('signed-requests.request-replay.tolerance');
75+
76+
if (false == $this->configurations->get('signed-requests.request-replay.allow')) {
77+
$isExpired = $signed->isExpired($tolerance);
78+
79+
if ($isExpired || $this->cache->has($key)) {
80+
throw new ExpiredRequestException();
81+
}
82+
}
83+
5184
$signed
5285
->setSignatureHeader($this->configurations->get('signed-requests.headers.signature'))
5386
->setAlgorithmHeader($this->configurations->get('signed-requests.headers.algorithm'));
@@ -56,6 +89,8 @@ public function handle(Request $request, Closure $next)
5689
throw new InvalidSignatureException();
5790
}
5891

92+
$this->cache->put($key, $key, $tolerance / 60);
93+
5994
return $next($request);
6095
}
6196
}

src/Requests/Generator.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace SoapBox\SignedRequests\Requests;
44

5+
use Carbon\Carbon;
56
use Ramsey\Uuid\Uuid;
67
use GuzzleHttp\Psr7\Request;
78
use SoapBox\SignedRequests\Signature;
@@ -47,6 +48,7 @@ public function sign(Request $request) : Request
4748
$key = $this->repository->get('signed-requests.key');
4849

4950
$request = $request->withHeader('X-SIGNED-ID', (string) Uuid::uuid4());
51+
$request = $request->withHeader('X-SIGNED-TIMESTAMP', (string) Carbon::now());
5052

5153
$signature = new Signature(new Payload($request), $algorithm, $key);
5254

src/Requests/Payload.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,13 @@ protected function generateFromGuzzleRequest(GuzzleRequest $request) : string
4141
{
4242
$id = isset($this->request->getHeader('X-SIGNED-ID')[0]) ?
4343
$this->request->getHeader('X-SIGNED-ID')[0] : '';
44+
$timestamp = isset($this->request->getHeader('X-SIGNED-TIMESTAMP')[0]) ?
45+
$this->request->getHeader('X-SIGNED-TIMESTAMP')[0] : '';
4446

4547
return json_encode([
4648
'id' => (string) $id,
4749
'method' => $this->request->getMethod(),
50+
'timestamp' => $timestamp,
4851
'uri' => (string) $this->request->getUri(),
4952
'content' => $this->request->getBody()
5053
], JSON_UNESCAPED_SLASHES);
@@ -62,10 +65,12 @@ protected function generateFromGuzzleRequest(GuzzleRequest $request) : string
6265
protected function generateFromIlluminateRequest(IlluminateRequest $request) : string
6366
{
6467
$id = $this->request->headers->get('X-SIGNED-ID', '');
68+
$timestamp = $this->request->headers->get('X-SIGNED-TIMESTAMP', '');
6569

6670
return json_encode([
6771
'id' => (string) $id,
6872
'method' => $this->request->getMethod(),
73+
'timestamp' => $timestamp,
6974
'uri' => (string) $this->request->fullUrl(),
7075
'content' => $this->request->getContent()
7176
], JSON_UNESCAPED_SLASHES);

src/Requests/Verifier.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace SoapBox\SignedRequests\Requests;
44

5+
use Carbon\Carbon;
56
use Illuminate\Http\Request;
67
use SoapBox\SignedRequests\Signature;
78
use SoapBox\SignedRequests\Requests\Payload;
@@ -172,4 +173,33 @@ public function isValid(string $key) : bool
172173

173174
return $signature->equals($this->getSignature());
174175
}
176+
177+
/**
178+
* Checks if this request was issued within tolerance seconds of now.
179+
*
180+
* @param int $tolerance
181+
* The number of seconds we'll tolerate for a request delay.
182+
*
183+
* @return bool
184+
* true if the request was issued within tolerance seconds, false
185+
* otherwise.
186+
*/
187+
public function isExpired(int $tolerance) : bool
188+
{
189+
$issuedAt =
190+
Carbon::parse($this->headers->get('X-SIGNED-TIMESTAMP', '1901-01-01 12:00:00'));
191+
192+
return Carbon::now()->diffInSeconds($issuedAt) > 60;
193+
}
194+
195+
/**
196+
* Returns the X-SIGNED-ID header value.
197+
*
198+
* @return string
199+
* The configured header.
200+
*/
201+
public function getId() : string
202+
{
203+
return $this->headers->get('X-SIGNED-ID', '');
204+
}
175205
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Tests\Exceptions;
4+
5+
use Tests\TestCase;
6+
use Symfony\Component\HttpFoundation\Response;
7+
use SoapBox\SignedRequests\Exceptions\ExpiredRequestException;
8+
9+
class ExpiredRequestExceptionTest extends TestCase
10+
{
11+
/**
12+
* @test
13+
*/
14+
public function an_invalid_signature_exception_is_constructed_with_a_default_message()
15+
{
16+
$exception = new ExpiredRequestException();
17+
$this->assertEquals(ExpiredRequestException::MESSAGE, $exception->getMessage());
18+
}
19+
20+
/**
21+
* @test
22+
*/
23+
public function the_message_for_the_exception_can_be_overwritten_during_construction()
24+
{
25+
$message = "So broken";
26+
$exception = new ExpiredRequestException($message);
27+
$this->assertNotEquals(ExpiredRequestException::MESSAGE, $exception->getMessage());
28+
$this->assertEquals($message, $exception->getMessage());
29+
}
30+
31+
/**
32+
* @test
33+
*/
34+
public function it_returns_a_bad_request_status_code()
35+
{
36+
$exception = new ExpiredRequestException();
37+
$this->assertEquals(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
38+
}
39+
40+
/**
41+
* @test
42+
*/
43+
public function it_returns_an_empty_set_of_response_headers()
44+
{
45+
$exception = new ExpiredRequestException();
46+
$this->assertEmpty($exception->getHeaders());
47+
}
48+
}

0 commit comments

Comments
 (0)