Completed
Pull Request — master (#1)
by
unknown
01:57
created

Webhook::checkSecurity()   B

Complexity

Conditions 5
Paths 3

Size

Total Lines 28
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
dl 0
loc 28
rs 8.439
c 1
b 0
f 0
cc 5
eloc 16
nc 3
nop 1
1
<?php
2
3
namespace Smalot\Bitbucket\Webhook;
4
5
use Smalot\Bitbucket\Webhook\Event\EventBase;
6
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
7
use Symfony\Component\HttpFoundation\Request;
8
9
/**
10
 * Class Webhook
11
 * @package Smalot\Bitbucket\Webhook
12
 */
13
class Webhook
14
{
15
    /**
16
     * @var EventDispatcherInterface
17
     */
18
    protected $eventDispatcher;
19
20
    /**
21
     * @var string
22
     */
23
    protected $eventName;
24
25
    /**
26
     * @var string
27
     */
28
    protected $payload;
29
30
    /**
31
     * @var int
32
     */
33
    protected $attemptCount;
34
35
    /**
36
     * @var string
37
     */
38
    protected $requestUuid;
39
40
    /**
41
     * @var array
42
     */
43
    protected $eventMap;
44
45
    /**
46
     * Webhook constructor.
47
     * @param EventDispatcherInterface $eventDispatcher
48
     */
49
    public function __construct(EventDispatcherInterface $eventDispatcher = null)
50
    {
51
        $this->eventDispatcher = $eventDispatcher;
52
        $this->eventMap = array();
53
    }
54
55
    /**
56
     * @param string $event
57
     * @return string
58
     */
59
    public function getEventClassName($event)
60
    {
61
        $map = $this->getEventMap();
62
63
        return $map[$event];
64
    }
65
66
    /**
67
     * @return array
68
     */
69
    public function getDefaultEventNames()
70
    {
71
        return array(
72
            'repo:push',
73
            'repo:fork',
74
            'repo:commit_comment_created',
75
            'repo:commit_status_created',
76
            'repo:commit_status_updated',
77
            'issue:created',
78
            'issue:updated',
79
            'issue:comment_created',
80
            'pullrequest:created',
81
            'pullrequest:updated',
82
            'pullrequest:approved',
83
            'pullrequest:unapproved',
84
            'pullrequest:fulfilled',
85
            'pullrequest:rejected',
86
            'pullrequest:comment_created',
87
            'pullrequest:comment_updated',
88
            'pullrequest:comment_deleted',
89
        );
90
    }
91
92
    /**
93
     * @return array
94
     */
95
    public function getEventMap()
96
    {
97
        if (empty($this->eventMap)) {
98
            $this->eventMap = array();
99
            $namespace = '\\Smalot\\Bitbucket\\Webhook\\Event\\';
100
            $eventNames = $this->getDefaultEventNames();
101
102
            $classNames = array_map(
103
                function($event) use ($namespace) {
104
                    $className = str_replace(' ', '', ucwords(str_replace(array('_', ':'), ' ', $event)));
105
106
                    return $namespace . $className . 'Event';
107
                },
108
                $eventNames
109
            );
110
111
            $this->eventMap = array_combine($eventNames, $classNames);
112
        }
113
114
        return $this->eventMap;
115
    }
116
117
    /**
118
     * @param Request $request
119
     * @return bool
120
     */
121
    public function isValidRequest(Request $request)
122
    {
123
        try {
124
            $valid = $this->checkSecurity($request);
125
        } catch (\Exception $e) {
126
            return false;
127
        }
128
129
        return $valid;
130
    }
131
132
    /**
133
     * @param Request $request
134
     * @param bool $dispatch
135
     * @return EventBase
136
     *
137
     * @throws \InvalidArgumentException
138
     */
139
    public function parseRequest(Request $request, $dispatch = true)
140
    {
141
        if (!$this->checkSecurity($request)) {
142
            throw new \InvalidArgumentException('Invalid security, check remote IP.');
143
        }
144
145
        if ($className = $this->getEventClassName($this->eventName)) {
146
            $event = new $className(
147
                $this->eventName,
148
                $this->payload,
149
                $this->attemptCount,
150
                $this->requestUuid
151
            );
152
        } else {
153
            throw new \InvalidArgumentException('Unknown event type: ' . $this->eventName . '.');
154
        }
155
156
        if (null !== $this->eventDispatcher && $dispatch) {
157
            $this->eventDispatcher->dispatch(Events::WEBHOOK_REQUEST, $event);
158
        }
159
160
        return $event;
161
    }
162
163
    /**
164
     * @return array
165
     */
166
    protected function getTrustedIpRanges()
167
    {
168
        return array(
169
            '131.103.20.160/27',
170
            '165.254.145.0/26',
171
            '104.192.143.0/21',
172
        );
173
    }
174
175
    /**
176
     * @param Request $request
177
     * @return bool
178
     *
179
     * @throws \InvalidArgumentException
180
     */
181
    protected function checkSecurity(Request $request)
182
    {
183
        $trustedIpRanges = $this->getTrustedIpRanges();
184
185
        // Reset any previously payload set.
186
        $this->requestUuid = $this->eventName = $this->payload = $this->attemptCount = null;
187
188
        // Extract Bitbucket headers from request.
189
        $requestUuid = (string)$request->headers->get('X-Request-Uuid');
190
        $event = (string)$request->headers->get('X-Event-Key');
191
        $count = (int)$request->headers->get('X-Attempt-Number');
192
        $payload = (string)$request->getContent();
193
194
        if (empty($requestUuid) || empty($event) || empty($count)) {
195
            throw new \InvalidArgumentException('Missing Bitbucket headers.');
196
        }
197
198
        if ($this->ipInRanges($request->getClientIp(), $trustedIpRanges)) {
199
            $this->requestUuid = $requestUuid;
200
            $this->eventName = $event;
201
            $this->attemptCount = $count;
202
            $this->payload = $payload;
203
204
            return true;
205
        }
206
207
        return false;
208
    }
209
210
    /**
211
     * @param string $ip
212
     * @param array $ranges
213
     * @return bool
214
     */
215
    protected function ipInRanges($ip, $ranges)
216
    {
217
        foreach ($ranges as $range) {
218
            if ($this->ipInRange($ip, $range)) {
219
                return true;
220
            }
221
        }
222
223
        return false;
224
    }
225
226
    /**
227
     * Check if a given ip is in a network
228
     * @link https://gist.github.com/tott/7684443
229
     *
230
     * @param  string $ip IP to check in IPV4 format eg. 127.0.0.1
231
     * @param  string $range IP/CIDR netmask eg. 127.0.0.0/24, also 127.0.0.1 is accepted and /32 assumed
232
     * @return boolean true if the ip is in this range / false if not.
233
     */
234
    protected function ipInRange($ip, $range)
235
    {
236
        // $range is in IP/CIDR format eg 127.0.0.1/24
237
        list($range, $netmask) = explode('/', $range, 2);
238
        $range_decimal = ip2long($range);
239
        $ip_decimal = ip2long($ip);
240
        $wildcard_decimal = pow(2, (32 - $netmask)) - 1;
241
        $netmask_decimal = ~$wildcard_decimal;
242
243
        return (($ip_decimal & $netmask_decimal) == ($range_decimal & $netmask_decimal));
244
    }
245
}
246