1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace Psr7Middlewares\Middleware; |
4
|
|
|
|
5
|
|
|
use Psr7Middlewares\Middleware; |
6
|
|
|
use Psr7Middlewares\Utils; |
7
|
|
|
use Psr\Http\Message\ServerRequestInterface; |
8
|
|
|
use Psr\Http\Message\ResponseInterface; |
9
|
|
|
use RuntimeException; |
10
|
|
|
|
11
|
|
|
/** |
12
|
|
|
* Middleware for CSRF protection |
13
|
|
|
* Code inspired from https://github.com/paragonie/anti-csrf. |
14
|
|
|
*/ |
15
|
|
|
class Csrf |
16
|
|
|
{ |
17
|
|
|
use Utils\FormTrait; |
18
|
|
|
|
19
|
|
|
protected $maxTokens = 100; |
20
|
|
|
protected $sessionIndex = 'CSRF'; |
21
|
|
|
protected $formIndex = '_CSRF_INDEX'; |
22
|
|
|
protected $formToken = '_CSRF_TOKEN'; |
23
|
|
|
|
24
|
|
|
/** |
25
|
|
|
* Execute the middleware. |
26
|
|
|
* |
27
|
|
|
* @param ServerRequestInterface $request |
28
|
|
|
* @param ResponseInterface $response |
29
|
|
|
* @param callable $next |
30
|
|
|
* |
31
|
|
|
* @return ResponseInterface |
32
|
|
|
*/ |
33
|
|
|
public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next) |
34
|
|
|
{ |
35
|
|
|
if (!Middleware::hasAttribute($request, FormatNegotiator::KEY)) { |
36
|
|
|
throw new RuntimeException('Csrf middleware needs FormatNegotiator executed before'); |
37
|
|
|
} |
38
|
|
|
|
39
|
|
|
if (!Middleware::hasAttribute($request, ClientIp::KEY)) { |
40
|
|
|
throw new RuntimeException('Csrf middleware needs ClientIp executed before'); |
41
|
|
|
} |
42
|
|
|
|
43
|
|
|
if (session_status() !== PHP_SESSION_ACTIVE) { |
44
|
|
|
throw new RuntimeException('Csrf middleware needs an active php session'); |
45
|
|
|
} |
46
|
|
|
|
47
|
|
|
if (FormatNegotiator::getFormat($request) !== 'html') { |
48
|
|
|
return $next($request, $response); |
49
|
|
|
} |
50
|
|
|
|
51
|
|
|
if (Utils\Helpers::isPost($request) && !$this->validateRequest($request)) { |
52
|
|
|
return $response->withStatus(403); |
53
|
|
|
} |
54
|
|
|
|
55
|
|
|
$response = $next($request, $response); |
56
|
|
|
|
57
|
|
|
return $this->insertIntoPostForms($response, function ($match) use ($request) { |
58
|
|
|
preg_match('/action=["\']?([^"\'\s]+)["\']?/i', $match[0], $matches); |
59
|
|
|
|
60
|
|
|
$action = empty($matches[1]) ? $request->getUri()->getPath() : $matches[1]; |
61
|
|
|
list($index, $token) = $this->generateTokens($request, $action); |
62
|
|
|
|
63
|
|
|
return $match[0] |
64
|
|
|
.'<input type="text" name="'.$this->formIndex.'" value="'.htmlentities($index, ENT_QUOTES, 'UTF-8').'">' |
65
|
|
|
.'<input type="text" name="'.$this->formToken.'" value="'.htmlentities($token, ENT_QUOTES, 'UTF-8').'">'; |
66
|
|
|
}); |
67
|
|
|
|
68
|
|
|
return $response; |
|
|
|
|
69
|
|
|
} |
70
|
|
|
|
71
|
|
|
/** |
72
|
|
|
* Generate and retrieve the tokens. |
73
|
|
|
* |
74
|
|
|
* @param ServerRequestInterface $request |
75
|
|
|
* @param string $lockTo |
76
|
|
|
* |
77
|
|
|
* @return array |
78
|
|
|
*/ |
79
|
|
|
private function generateTokens(ServerRequestInterface $request, $lockTo) |
|
|
|
|
80
|
|
|
{ |
81
|
|
|
if (!isset($_SESSION[$this->sessionIndex])) { |
82
|
|
|
$_SESSION[$this->sessionIndex] = []; |
83
|
|
|
} |
84
|
|
|
|
85
|
|
|
$index = self::encode(random_bytes(18)); |
|
|
|
|
86
|
|
|
$token = self::encode(random_bytes(32)); |
|
|
|
|
87
|
|
|
|
88
|
|
|
$_SESSION[$this->sessionIndex][$index] = [ |
89
|
|
|
'created' => intval(date('YmdHis')), |
90
|
|
|
'uri' => $request->getUri()->getPath(), |
91
|
|
|
'token' => $token, |
92
|
|
|
'lockTo' => $lockTo, |
93
|
|
|
]; |
94
|
|
|
|
95
|
|
|
$this->recycleTokens(); |
96
|
|
|
|
97
|
|
|
$token = self::encode(hash_hmac('sha256', ClientIp::getIp($request), base64_decode($token), true)); |
98
|
|
|
|
99
|
|
|
return [$index, $token]; |
100
|
|
|
} |
101
|
|
|
|
102
|
|
|
/** |
103
|
|
|
* Validate the request. |
104
|
|
|
* |
105
|
|
|
* @param ServerRequestInterface $request |
106
|
|
|
* |
107
|
|
|
* @return bool |
108
|
|
|
*/ |
109
|
|
|
private function validateRequest(ServerRequestInterface $request) |
|
|
|
|
110
|
|
|
{ |
111
|
|
|
if (!isset($_SESSION[$this->sessionIndex])) { |
112
|
|
|
$_SESSION[$this->sessionIndex] = []; |
113
|
|
|
|
114
|
|
|
return false; |
115
|
|
|
} |
116
|
|
|
|
117
|
|
|
$data = $request->getParsedBody(); |
118
|
|
|
|
119
|
|
|
if (!isset($data[$this->formIndex]) || !isset($data[$this->formToken])) { |
120
|
|
|
return false; |
121
|
|
|
} |
122
|
|
|
|
123
|
|
|
$index = $data[$this->formIndex]; |
124
|
|
|
$token = $data[$this->formToken]; |
125
|
|
|
|
126
|
|
|
if (!isset($_SESSION[$this->sessionIndex][$index])) { |
127
|
|
|
return false; |
128
|
|
|
} |
129
|
|
|
|
130
|
|
|
$stored = $_SESSION[$this->sessionIndex][$index]; |
131
|
|
|
unset($_SESSION[$this->sessionIndex][$index]); |
132
|
|
|
|
133
|
|
|
$lockTo = $request->getUri()->getPath(); |
134
|
|
|
|
135
|
|
|
if (!Utils\Helpers::hash_equals($lockTo, $stored['lockTo'])) { |
136
|
|
|
return false; |
137
|
|
|
} |
138
|
|
|
|
139
|
|
|
$expected = self::encode(hash_hmac('sha256', ClientIp::getIp($request), base64_decode($stored['token']), true)); |
140
|
|
|
|
141
|
|
|
return Utils\Helpers::hash_equals($token, $expected); |
142
|
|
|
} |
143
|
|
|
|
144
|
|
|
/** |
145
|
|
|
* Enforce an upper limit on the number of tokens stored in session state |
146
|
|
|
* by removing the oldest tokens first. |
147
|
|
|
*/ |
148
|
|
|
private function recycleTokens() |
|
|
|
|
149
|
|
|
{ |
150
|
|
|
if (!$this->maxTokens || count($_SESSION[$this->sessionIndex]) <= $this->maxTokens) { |
151
|
|
|
return; |
152
|
|
|
} |
153
|
|
|
|
154
|
|
|
uasort($_SESSION[$this->sessionIndex], function ($a, $b) { |
155
|
|
|
return $a['created'] - $b['created']; |
156
|
|
|
}); |
157
|
|
|
|
158
|
|
|
while (count($_SESSION[$this->sessionIndex]) > $this->maxTokens) { |
159
|
|
|
array_shift($_SESSION[$this->sessionIndex]); |
160
|
|
|
} |
161
|
|
|
} |
162
|
|
|
|
163
|
|
|
/** |
164
|
|
|
* Encode string with base64, but strip padding. |
165
|
|
|
* PHP base64_decode does not croak on that. |
166
|
|
|
* |
167
|
|
|
* @param string $value |
168
|
|
|
* |
169
|
|
|
* @return string |
170
|
|
|
*/ |
171
|
|
|
private static function encode($value) |
172
|
|
|
{ |
173
|
|
|
return rtrim(base64_encode($value), '='); |
174
|
|
|
} |
175
|
|
|
} |
176
|
|
|
|
This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.
Unreachable code is most often the result of
return
,die
orexit
statements that have been added for debug purposes.In the above example, the last
return false
will never be executed, because a return statement has already been met in every possible execution path.