Completed
Push — master ( 00ed35...63fbb3 )
by Oscar
10:20
created

Csrf::__invoke()   C

Complexity

Conditions 8
Paths 6

Size

Total Lines 37
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 0
loc 37
rs 5.3846
cc 8
eloc 20
nc 6
nop 3
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;
0 ignored issues
show
Unused Code introduced by
return $response; does not seem to be reachable.

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 or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

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.

Loading history...
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)
0 ignored issues
show
Coding Style introduced by
generateTokens 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...
80
    {
81
        if (!isset($_SESSION[$this->sessionIndex])) {
82
            $_SESSION[$this->sessionIndex] = [];
83
        }
84
85
        $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...
86
        $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...
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)
0 ignored issues
show
Coding Style introduced by
validateRequest 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...
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()
0 ignored issues
show
Coding Style introduced by
recycleTokens 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...
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