Server::send()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 19
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 13
nc 2
nop 3
dl 0
loc 19
rs 9.8333
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\cdc;
6
7
use SimpleSAML\Assert\Assert;
8
use SimpleSAML\Configuration;
9
use SimpleSAML\Error;
10
use SimpleSAML\Logger;
11
use SimpleSAML\SAML2\Exception\ProtocolViolationException;
12
use SimpleSAML\Utils;
13
14
/**
15
 * CDC server class.
16
 *
17
 * @package SimpleSAMLphp
18
 */
19
20
class Server
21
{
22
    /**
23
     * The domain.
24
     *
25
     * @var string
26
     */
27
    private string $domain;
28
29
    /**
30
     * The URL to the server.
31
     *
32
     * @var string
33
     */
34
    private string $server;
35
36
    /**
37
     * Our shared key.
38
     *
39
     * @var string
40
     */
41
    private string $key;
42
43
44
    /**
45
     * The lifetime of our cookie, in seconds.
46
     *
47
     * If this is 0, the cookie will expire when the browser is closed.
48
     *
49
     * @var int
50
     */
51
    private int $cookieLifetime;
52
53
54
    /**
55
     * Initialize a CDC server.
56
     *
57
     * @param string $domain  The domain we are a server for.
58
     * @throws \SimpleSAML\Error\Exception
59
     */
60
    public function __construct(string $domain)
61
    {
62
        $cdcConfig = Configuration::getConfig('module_cdc.php');
63
        $config = $cdcConfig->getOptionalConfigItem($domain, null);
64
65
        if ($config === null) {
66
            throw new Error\Exception('Unknown CDC domain: ' . var_export($domain, true));
67
        }
68
69
        $this->domain = $domain;
70
        $this->server = $config->getString('server');
71
        $this->key = $config->getString('key');
72
        $this->cookieLifetime = $config->getOptionalInteger('cookie.lifetime', 0);
73
74
        if ($this->key === 'ExampleSharedKey') {
75
            throw new Error\Exception(
76
                'Key for CDC domain ' . var_export($domain, true) . ' not changed from default.',
77
            );
78
        }
79
    }
80
81
82
    /**
83
     * Send a request to this CDC server.
84
     *
85
     * @param array $request  The CDC request.
86
     */
87
    public function sendRequest(array $request): void
88
    {
89
        Assert::keyExists($request, 'return');
90
        Assert::keyExists($request, 'op');
91
92
        $request['domain'] = $this->domain;
93
        $this->send($this->server, 'CDCRequest', $request);
94
    }
95
96
97
    /**
98
     * Parse and validate response received from a CDC server.
99
     *
100
     * @return array|null  The response, or NULL if no response is received.
101
     * @throws \SimpleSAML\Error\Exception
102
     */
103
    public function getResponse(): ?array
104
    {
105
        $response = self::get('CDCResponse');
106
        if ($response === null) {
107
            return null;
108
        }
109
110
        if ($response['domain'] !== $this->domain) {
111
            throw new Error\Exception('Response received from wrong domain.');
112
        }
113
114
        $this->validate('CDCResponse');
115
116
        return $response;
117
    }
118
119
120
    /**
121
     * Parse and process a CDC request.
122
     * @throws \SimpleSAML\Error\BadRequest
123
     */
124
    public static function processRequest(): void
125
    {
126
        $request = self::get('CDCRequest');
127
        if ($request === null) {
128
            throw new Error\BadRequest('Missing "CDCRequest" parameter.');
129
        }
130
131
        $domain = $request['domain'];
132
        $server = new Server($domain);
133
134
        $server->validate('CDCRequest');
135
        $server->handleRequest($request);
136
    }
137
138
139
    /**
140
     * Handle a parsed CDC requst.
141
     *
142
     * @param array $request
143
     * @throws \SimpleSAML\Error\Exception
144
     */
145
    private function handleRequest(array $request): void
146
    {
147
        if (!isset($request['op'])) {
148
            throw new Error\BadRequest('Missing "op" in CDC request.');
149
        }
150
        $op = (string) $request['op'];
151
152
        Logger::info('Received CDC request with "op": ' . var_export($op, true));
153
154
        if (!isset($request['return'])) {
155
            throw new Error\BadRequest('Missing "return" in CDC request.');
156
        }
157
        $return = (string) $request['return'];
158
159
        switch ($op) {
160
            case 'append':
161
                $response = $this->handleAppend($request);
162
                break;
163
            case 'delete':
164
                $response = $this->handleDelete($request);
165
                break;
166
            case 'read':
167
                $response = $this->handleRead($request);
168
                break;
169
            default:
170
                $response = 'unknown-op';
171
        }
172
173
        if (is_string($response)) {
174
            $response = [
175
                'status' => $response,
176
            ];
177
        }
178
179
        $response['op'] = $op;
180
        if (isset($request['id'])) {
181
            $response['id'] = (string) $request['id'];
182
        }
183
        $response['domain'] = $this->domain;
184
185
        $this->send($return, 'CDCResponse', $response);
186
    }
187
188
189
    /**
190
     * Handle an append request.
191
     *
192
     * @param array $request  The request.
193
     * @throws \SimpleSAML\Error\BadRequest
194
     * @return string The response.
195
     */
196
    private function handleAppend(array $request): string
197
    {
198
        if (!isset($request['entityID'])) {
199
            throw new Error\BadRequest('Missing entityID in append request.');
200
        }
201
        $entityID = (string) $request['entityID'];
202
203
        $list = $this->getCDC();
204
205
        $prevIndex = array_search($entityID, $list, true);
206
        if ($prevIndex !== false) {
207
            unset($list[$prevIndex]);
208
        }
209
        $list[] = $entityID;
210
211
        $this->setCDC($list);
212
213
        return 'ok';
214
    }
215
216
217
    /**
218
     * Handle a delete request.
219
     *
220
     * @param array $request  The request.
221
     * @return string The response.
222
     */
223
    private function handleDelete(array $request): string
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

223
    private function handleDelete(/** @scrutinizer ignore-unused */ array $request): string

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
224
    {
225
        $params = [
226
            'path' => '/',
227
            'domain' => '.' . $this->domain,
228
            'secure' => true,
229
            'httponly' => false,
230
        ];
231
232
        $httpUtils = new Utils\HTTP();
233
        $httpUtils->setCookie('_saml_idp', null, $params, false);
234
        return 'ok';
235
    }
236
237
238
    /**
239
     * Handle a read request.
240
     *
241
     * @param array $request  The request.
242
     * @return array  The response.
243
     */
244
    private function handleRead(array $request): array
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

244
    private function handleRead(/** @scrutinizer ignore-unused */ array $request): array

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
245
    {
246
        $list = $this->getCDC();
247
248
        return [
249
            'status' => 'ok',
250
            'cdc' => $list,
251
        ];
252
    }
253
254
255
    /**
256
     * Helper function for parsing and validating a CDC message.
257
     *
258
     * @param string $parameter  The name of the query parameter.
259
     * @throws \SimpleSAML\Error\BadRequest
260
     * @return array|null  The response, or NULL if no response is received.
261
     */
262
    private static function get(string $parameter): ?array
263
    {
264
        if (!isset($_REQUEST[$parameter])) {
265
            return null;
266
        }
267
        $message = (string) $_REQUEST[$parameter];
268
        Assert::validBase64($message, ProtocolViolationException::class);
269
270
        $message = @base64_decode($message);
271
        if ($message === false) {
272
            throw new Error\BadRequest('Error base64-decoding CDC message.');
273
        }
274
275
        $message = @json_decode($message, true);
276
        if ($message === false) {
277
            throw new Error\BadRequest('Error json-decoding CDC message.');
278
        }
279
280
        if (!isset($message['timestamp'])) {
281
            throw new Error\BadRequest('Missing timestamp in CDC message.');
282
        }
283
        $timestamp = (int) $message['timestamp'];
284
285
        if ($timestamp + 60 < time()) {
286
            throw new Error\BadRequest('CDC signature has expired.');
287
        }
288
        if ($timestamp - 60 > time()) {
289
            throw new Error\BadRequest('CDC signature from the future.');
290
        }
291
292
        if (!isset($message['domain'])) {
293
            throw new Error\BadRequest('Missing domain in CDC message.');
294
        }
295
296
        return $message;
297
    }
298
299
300
    /**
301
     * Helper function for validating the signature on a CDC message.
302
     *
303
     * Will throw an exception if the message is invalid.
304
     *
305
     * @param string $parameter  The name of the query parameter.
306
     * @throws \SimpleSAML\Error\BadRequest
307
     */
308
    private function validate(string $parameter): void
309
    {
310
        Assert::keyExists($_REQUEST, $parameter);
311
312
        $message = (string) $_REQUEST[$parameter];
313
314
        if (!isset($_REQUEST['Signature'])) {
315
            throw new Error\BadRequest('Missing Signature on CDC message.');
316
        }
317
        $signature = (string) $_REQUEST['Signature'];
318
319
        $cSignature = $this->calcSignature($message);
320
        if ($signature !== $cSignature) {
321
            throw new Error\BadRequest('Invalid signature on CDC message.');
322
        }
323
    }
324
325
326
    /**
327
     * Helper function for sending CDC messages.
328
     *
329
     * @param string $to  The URL the message should be delivered to.
330
     * @param string $parameter  The query parameter the message should be sent in.
331
     * @param array $message  The CDC message.
332
     */
333
    private function send(string $to, string $parameter, array $message): void
334
    {
335
        $message['timestamp'] = time();
336
        $message = json_encode($message);
337
        $message = base64_encode($message);
338
339
        $signature = $this->calcSignature($message);
340
341
        $params = [
342
            $parameter => $message,
343
            'Signature' => $signature,
344
        ];
345
346
        $httpUtils = new Utils\HTTP();
347
        $url = $httpUtils->addURLParameters($to, $params);
348
        if (strlen($url) < 2048) {
349
            $httpUtils->redirectTrustedURL($url);
350
        } else {
351
            $httpUtils->submitPOSTData($to, $params);
352
        }
353
    }
354
355
356
    /**
357
     * Calculate the signature on the given message.
358
     *
359
     * @param string $rawMessage  The base64-encoded message.
360
     * @return string  The signature.
361
     */
362
    private function calcSignature(string $rawMessage): string
363
    {
364
        return sha1($this->key . $rawMessage . $this->key);
365
    }
366
367
368
    /**
369
     * Get the IdP entities saved in the common domain cookie.
370
     *
371
     * @return array  List of IdP entities.
372
     */
373
    private function getCDC(): array
374
    {
375
        if (!isset($_COOKIE['_saml_idp'])) {
376
            return [];
377
        }
378
379
        $ret = (string) $_COOKIE['_saml_idp'];
380
381
        $ret = explode(' ', $ret);
382
        foreach ($ret as &$idp) {
383
            Assert::validBase64($idp, ProtocolViolationException::class);
384
            $idp = base64_decode($idp);
385
            if ($idp === false) {
386
                // Not properly base64 encoded
387
                Logger::warning('CDC - Invalid base64-encoding of CDC entry.');
388
                return [];
389
            }
390
            Assert::validURI($idp, ProtocolViolationException::class);
391
        }
392
393
        return $ret;
394
    }
395
396
397
    /**
398
     * Build a CDC cookie string.
399
     *
400
     * @param array $list  The list of IdPs.
401
     * @return string  The CDC cookie value.
402
     */
403
    private function setCDC(array $list): string
404
    {
405
        foreach ($list as &$value) {
406
            $value = base64_encode($value);
407
        }
408
409
        $cookie = implode(' ', $list);
410
411
        while (strlen($cookie) > 4000) {
412
            // The cookie is too long. Remove the oldest elements until it is short enough
413
            $tmp = explode(' ', $cookie, 2);
414
            if (count($tmp) === 1) {
415
                /*
416
                 * We are left with a single entityID whose base64
417
                 * representation is too long to fit in a cookie.
418
                 */
419
                break;
420
            }
421
            $cookie = $tmp[1];
422
        }
423
424
        $params = [
425
            'lifetime' => $this->cookieLifetime,
426
            'path' => '/',
427
            'domain' => '.' . $this->domain,
428
            'secure' => true,
429
            'httponly' => false,
430
        ];
431
432
        $httpUtils = new Utils\HTTP();
433
        $httpUtils->setCookie('_saml_idp', $cookie, $params, false);
434
435
        return '_saml_idp';
436
    }
437
}
438