Completed
Push — master ( 345022...5c5fe2 )
by Oscar
03:17
created

Csrf::validateRequest()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 28
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 3 Features 0
Metric Value
c 5
b 3
f 0
dl 0
loc 28
rs 8.439
cc 5
eloc 15
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 RuntimeException;
10
use ArrayAccess;
11
12
/**
13
 * Middleware for CSRF protection
14
 * Code inspired from https://github.com/paragonie/anti-csrf.
15
 */
16
class Csrf
17
{
18
    use Utils\FormTrait;
19
20
    /**
21
     * @var int Max number of CSRF tokens
22
     */
23
    private $maxTokens = 100;
24
25
    /**
26
     * @var string field name with the CSRF index
27
     */
28
    private $formIndex = '_CSRF_INDEX';
29
30
    /**
31
     * @var string field name with the CSRF token
32
     */
33
    private $formToken = '_CSRF_TOKEN';
34
35
    /*
36
     * @var array|ArrayAccess CSRF storage
37
     */
38
    private $storage;
39
40
    /**
41
     * @var string Index used in the storage
42
     */
43
    private $sessionIndex = 'CSRF';
44
45
    /**
46
     * Set the storage of the CSRF
47
     * 
48
     * @param array|ArrayAccess|null $storage
49
     */
50
    public function __construct(&$storage = null)
51
    {
52
        if (is_array($storage)) {
53
            $this->storage = &$storage;
54
        } elseif ($storage instanceof ArrayAccess) {
55
            $this->storage = $storage;
56
        } elseif ($storage !== null) {
57
            throw new InvalidArgumentException('The storage argument must be an array, ArrayAccess or null');
58
        }
59
    }
60
61
    /**
62
     * Execute the middleware.
63
     *
64
     * @param ServerRequestInterface $request
65
     * @param ResponseInterface      $response
66
     * @param callable               $next
67
     *
68
     * @return ResponseInterface
69
     */
70
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
0 ignored issues
show
Coding Style introduced by
__invoke uses the super-global variable $_SESSION which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
71
    {
72
        if (!Middleware::hasAttribute($request, FormatNegotiator::KEY)) {
73
            throw new RuntimeException('Csrf middleware needs FormatNegotiator executed before');
74
        }
75
76
        if (!Middleware::hasAttribute($request, ClientIp::KEY)) {
77
            throw new RuntimeException('Csrf middleware needs ClientIp executed before');
78
        }
79
80
        if ($this->storage === null) {
81
            if (session_status() !== PHP_SESSION_ACTIVE) {
82
                throw new RuntimeException('Csrf middleware needs an active php session or a storage defined');
83
            }
84
85
            if (!isset($_SESSION[$this->sessionIndex])) {
86
                $_SESSION[$this->sessionIndex] = [];
87
            }
88
89
            $this->storage = &$_SESSION[$this->sessionIndex];
90
        }
91
92
        if (FormatNegotiator::getFormat($request) !== 'html') {
93
            return $next($request, $response);
94
        }
95
96
        if (Utils\Helpers::isPost($request) && !$this->validateRequest($request)) {
97
            return $response->withStatus(403);
98
        }
99
100
        $response = $next($request, $response);
101
102
        return $this->insertIntoPostForms($response, function ($match) use ($request) {
103
            preg_match('/action=["\']?([^"\'\s]+)["\']?/i', $match[0], $matches);
104
105
            $action = empty($matches[1]) ? $request->getUri()->getPath() : $matches[1];
106
107
            return $match[0].$this->generateTokens($request, $action);
108
        });
109
    }
110
111
    /**
112
     * Generate and retrieve the tokens.
113
     * 
114
     * @param ServerRequestInterface $request
115
     * @param string                 $lockTo
116
     *
117
     * @return array
118
     */
119
    private function generateTokens(ServerRequestInterface $request, $lockTo)
120
    {
121
        $index = self::encode(random_bytes(18));
0 ignored issues
show
Unused Code introduced by
The call to random_bytes() has too many arguments starting with 18.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
122
        $token = self::encode(random_bytes(32));
0 ignored issues
show
Unused Code introduced by
The call to random_bytes() has too many arguments starting with 32.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

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