Completed
Push — master ( 1b2d0a...89ea1a )
by Oscar
03:00
created

Csrf::__construct()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
c 1
b 1
f 0
dl 0
loc 10
rs 9.2
cc 4
eloc 7
nc 4
nop 1
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 InvalidArgumentException;
10
use RuntimeException;
11
use ArrayAccess;
12
13
/**
14
 * Middleware for CSRF protection
15
 * Code inspired from https://github.com/paragonie/anti-csrf.
16
 */
17
class Csrf
18
{
19
    use Utils\FormTrait;
20
21
    const KEY = 'CSRF';
22
    const KEY_GENERATOR = 'CSRF_GENERATOR';
23
24
    /**
25
     * @var int Max number of CSRF tokens
26
     */
27
    private $maxTokens = 100;
28
29
    /**
30
     * @var string field name with the CSRF index
31
     */
32
    private $formIndex = '_CSRF_INDEX';
33
34
    /**
35
     * @var string field name with the CSRF token
36
     */
37
    private $formToken = '_CSRF_TOKEN';
38
39
    /**
40
     * Returns a callable to generate the inputs.
41
     *
42
     * @param ServerRequestInterface $request
43
     *
44
     * @return callable|null
45
     */
46
    public static function getGenerator(ServerRequestInterface $request)
47
    {
48
        return Middleware::getAttribute($request, self::KEY_GENERATOR);
49
    }
50
51
    /**
52
     * Execute the middleware.
53
     *
54
     * @param ServerRequestInterface $request
55
     * @param ResponseInterface      $response
56
     * @param callable               $next
57
     *
58
     * @return ResponseInterface
59
     */
60
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
61
    {
62
        if (!Middleware::hasAttribute($request, FormatNegotiator::KEY)) {
63
            throw new RuntimeException('Csrf middleware needs FormatNegotiator executed before');
64
        }
65
66
        if (!Middleware::hasAttribute($request, ClientIp::KEY)) {
67
            throw new RuntimeException('Csrf middleware needs ClientIp executed before');
68
        }
69
70
        if (!Middleware::hasAttribute($request, Middleware::STORAGE_KEY)) {
71
            throw new RuntimeException('Csrf middleware needs a storage defined');
72
        }
73
74
        if (FormatNegotiator::getFormat($request) !== 'html') {
75
            return $next($request, $response);
76
        }
77
78
        $storage = Middleware::getAttribute($request, Middleware::STORAGE_KEY);
79
        $tokens = $storage->get(self::KEY) ?: [];
80
81
        if (Utils\Helpers::isPost($request) && !$this->validateRequest($request, $tokens)) {
82
            return $response->withStatus(403);
83
        }
84
85
        $generator = function ($action) use ($request, &$tokens) {
86
            return $this->generateTokens($request, $action, $tokens);
87
        };
88
89
        $response = $next($request, $response);
90
91
        $response = $this->insertIntoPostForms($response, function ($match) use ($generator) {
92
            preg_match('/action=["\']?([^"\'\s]+)["\']?/i', $match[0], $matches);
93
94
            $action = empty($matches[1]) ? $request->getUri()->getPath() : $matches[1];
0 ignored issues
show
Bug introduced by
The variable $request does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
95
96
            return $match[0].$generator($action);
97
        });
98
99
        $storage->set(self::KEY, $tokens);
100
101
        return $response;
102
    }
103
104
    /**
105
     * Generate and retrieve the tokens.
106
     * 
107
     * @param ServerRequestInterface $request
108
     * @param string                 $lockTo
109
     * @param array                 $tokens
110
     *
111
     * @return string
112
     */
113
    private function generateTokens(ServerRequestInterface $request, $lockTo, array &$tokens)
114
    {
115
        $index = self::encode(random_bytes(18));
116
        $token = self::encode(random_bytes(32));
117
118
        $tokens[$index] = [
119
            'created' => intval(date('YmdHis')),
120
            'uri' => $request->getUri()->getPath(),
121
            'token' => $token,
122
            'lockTo' => $lockTo,
123
        ];
124
125
        $this->recycleTokens($tokens);
126
127
        $token = self::encode(hash_hmac('sha256', ClientIp::getIp($request), base64_decode($token), true));
128
129
        return '<input type="hidden" name="'.$this->formIndex.'" value="'.htmlentities($index, ENT_QUOTES, 'UTF-8').'">'
130
               .'<input type="hidden" name="'.$this->formToken.'" value="'.htmlentities($token, ENT_QUOTES, 'UTF-8').'">';
131
    }
132
133
    /**
134
     * Validate the request.
135
     * 
136
     * @param ServerRequestInterface $request
137
     * @param array &$tokens
138
     *
139
     * @return bool
140
     */
141
    private function validateRequest(ServerRequestInterface $request, array &$tokens)
142
    {
143
        $data = $request->getParsedBody();
144
145
        if (!isset($data[$this->formIndex]) || !isset($data[$this->formToken])) {
146
            return false;
147
        }
148
149
        $index = $data[$this->formIndex];
150
        $token = $data[$this->formToken];
151
152
        if (!isset($tokens[$index])) {
153
            return false;
154
        }
155
156
        $stored = $tokens[$index];
157
        unset($tokens[$index]);
158
159
        $lockTo = $request->getUri()->getPath();
160
161
        if (!Utils\Helpers::hashEquals($lockTo, $stored['lockTo'])) {
162
            return false;
163
        }
164
165
        $expected = self::encode(hash_hmac('sha256', ClientIp::getIp($request), base64_decode($stored['token']), true));
166
167
        return Utils\Helpers::hashEquals($token, $expected);
168
    }
169
170
    /**
171
     * Enforce an upper limit on the number of tokens stored in session state
172
     * by removing the oldest tokens first.
173
     * 
174
     * @param array &$tokens
175
     */
176
    private function recycleTokens(array &$tokens)
177
    {
178
        if (!$this->maxTokens || count($tokens) <= $this->maxTokens) {
179
            return;
180
        }
181
182
        uasort($tokens, function ($a, $b) {
183
            return $a['created'] - $b['created'];
184
        });
185
186
        while (count($tokens) > $this->maxTokens) {
187
            array_shift($tokens);
188
        }
189
    }
190
191
    /**
192
     * Encode string with base64, but strip padding.
193
     * PHP base64_decode does not croak on that.
194
     *
195
     * @param string $value
196
     *
197
     * @return string
198
     */
199
    private static function encode($value)
200
    {
201
        return rtrim(base64_encode($value), '=');
202
    }
203
}
204