Test Failed
Push — master ( 0071b0...4282d1 )
by Stefan
05:33
created

OptionParser::checkUploadSanity()   B

Complexity

Conditions 10
Paths 10

Size

Total Lines 29
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 10
eloc 20
nc 10
nop 2
dl 0
loc 29
rs 7.6666
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/*
3
 * *****************************************************************************
4
 * Contributions to this work were made on behalf of the GÉANT project, a 
5
 * project that has received funding from the European Union’s Framework 
6
 * Programme 7 under Grant Agreements No. 238875 (GN3) and No. 605243 (GN3plus),
7
 * Horizon 2020 research and innovation programme under Grant Agreements No. 
8
 * 691567 (GN4-1) and No. 731122 (GN4-2).
9
 * On behalf of the aforementioned projects, GEANT Association is the sole owner
10
 * of the copyright in all material which was developed by a member of the GÉANT
11
 * project. GÉANT Vereniging (Association) is registered with the Chamber of 
12
 * Commerce in Amsterdam with registration number 40535155 and operates in the 
13
 * UK as a branch of GÉANT Vereniging.
14
 * 
15
 * Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. 
16
 * UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK
17
 *
18
 * License: see the web/copyright.inc.php file in the file structure or
19
 *          <base_url>/copyright.php after deploying the software
20
 */
21
22
namespace web\lib\admin;
23
24
use Exception;
25
26
?>
27
<?php
28
29
require_once dirname(dirname(dirname(dirname(__FILE__)))) . "/config/_config.php";
30
31
/**
32
 * This class parses HTML field input from POST and FILES and extracts valid and authorized options to be set.
33
 * 
34
 * @author Stefan Winter <[email protected]>
35
 */
36
class OptionParser extends \core\common\Entity {
37
38
    /**
39
     * an instance of the InputValidation class which we use heavily for syntax checks.
40
     * 
41
     * @var \web\lib\common\InputValidation
42
     */
43
    private $validator;
44
45
    /**
46
     * an instance of the UIElements() class to draw some UI widgets from.
47
     * 
48
     * @var UIElements
49
     */
50
    private $uiElements;
51
52
    /**
53
     * a handle for the Options singleton
54
     * 
55
     * @var \core\Options
56
     */
57
    private $optioninfoObject;
58
59
    /**
60
     * initialises the various handles.
61
     */
62
    public function __construct() {
63
        $this->validator = new \web\lib\common\InputValidation();
64
        $this->uiElements = new UIElements();
65
        $this->optioninfoObject = \core\Options::instance();
66
    }
67
68
    /**
69
     * Verifies whether an incoming upload was actually valid data
70
     * 
71
     * @param string $optiontype     for which option was the data uploaded
72
     * @param string $incomingBinary the uploaded data
73
     * @return boolean whether the data was valid
74
     */
75
    private function checkUploadSanity(string $optiontype, string $incomingBinary) {
76
        switch ($optiontype) {
77
            case "general:logo_file":
78
            case "fed:logo_file":
79
            case "internal:logo_from_url":
80
                // we check logo_file with ImageMagick
81
                return $this->validator->image($incomingBinary);
82
            case "eap:ca_file":
83
                // fall-through intended: both CA types are treated the same
84
            case "fed:minted_ca_file":
85
                // echo "Checking $optiontype with file $filename";
86
                $func = new \core\common\X509;
87
                $cert = $func->processCertificate($incomingBinary);
88
                if (is_array($cert)) { // could also be FALSE if it was incorrect incoming data
1 ignored issue
show
introduced by
The condition is_array($cert) is always false.
Loading history...
89
                    return TRUE;
90
                }
91
                // the certificate seems broken
92
                return FALSE;
93
            case "support:info_file":
94
                $info = new \finfo();
95
                $filetype = $info->buffer($incomingBinary, FILEINFO_MIME_TYPE);
96
97
                // we only take plain text files in UTF-8!
98
                if ($filetype == "text/plain" && iconv("UTF-8", "UTF-8", $incomingBinary) !== FALSE) {
99
                    return TRUE;
100
                }
101
                return FALSE;
102
            default:
103
                return FALSE;
104
        }
105
    }
106
107
    /**
108
     * Known-good options are sometimes converted, this function takes care of that.
109
     * 
110
     * Cases in point:
111
     * - CA import by URL reference: fetch cert from URL and store it as CA file instead
112
     * - Logo import by URL reference: fetch logo from URL and store it as logo file instead
113
     * - CA file: mangle the content so that *only* the valid content remains (raw input may contain line breaks or spaces which are valid, but some supplicants choke upon)
114
     * 
115
     * @param array $options the list of options we got
116
     * @param array $good    by-reference: the future list of actually imported options
117
     * @param array $bad     by-reference: the future list of submitted but rejected options
118
     * @return array the options, post-processed
119
     */
120
    private function postProcessValidAttributes(array $options, array &$good, array &$bad) {
121
        foreach ($options as $index => $iterateOption) {
122
            foreach ($iterateOption as $name => $optionPayload) {
123
                switch ($name) {
124
                    case "eap:ca_url": // eap:ca_url becomes eap:ca_file by downloading the file
125
                        $finalOptionname = "eap:ca_file";
126
                    // intentional fall-through, treatment identical to logo_url
127
                    case "general:logo_url": // logo URLs become logo files by downloading the file
128
                        $finalOptionname = $finalOptionname ?? "general:logo_file";
129
                        if (empty($optionPayload['content'])) {
130
                            break;
131
                        }
132
                        $bindata = \core\common\OutsideComm::downloadFile($optionPayload['content']);
133
                        unset($options[$index]);
134
                        if ($bindata === FALSE) {
135
                            $bad[] = $name;
136
                            break;
137
                        }
138
                        if ($this->checkUploadSanity($finalOptionname, $bindata)) {
139
                            $good[] = $name;
140
                            $options[] = [$finalOptionname => ['lang' => NULL, 'content' => base64_encode($bindata)]];
141
                        } else {
142
                            $bad[] = $name;
143
                        }
144
                        break;
145
                    case "eap:ca_file": 
146
                    case "fed:minted_ca_file":
147
                        // CA files get split (PEM files can contain more than one CA cert)
148
                        // the data being processed here is always "good": 
149
                        // if it was eap:ca_file initially then its sanity was checked in step 1;
150
                        // if it was eap:ca_url then it was checked after we downloaded it
151
                        if (empty($optionPayload['content'])) {
152
                            break;    
153
                        }
154
                        if (preg_match('/^ROWID-/', $optionPayload['content'])) {
155
                            // accounted for, already in DB
156
                            $good[] = $name;
157
                            break;
158
                        }
159
                        $content = base64_decode($optionPayload['content']);
160
                        unset($options[$index]);
161
                        $x509 = new \core\common\X509();
162
                        $cAFiles = $x509->splitCertificate($content);
163
                        foreach ($cAFiles as $cAFile) {
164
                            $options[] = [$name => ['lang' => NULL, 'content' => base64_encode($x509->pem2der($cAFile))]];
165
                        }
166
                        $good[] = $name;
167
                        break;
168
                    default:
169
                        $good[] = $name; // all other options were checked and are sane in step 1 already
170
                        break;
171
                }
172
            }
173
        }
174
175
        return $options;
176
    }
177
178
    /**
179
     * extracts a coordinate pair from _POST (if any) and returns it in our 
180
     * standard attribute notation
181
     * 
182
     * @param array $postArray data as sent by POST
183
     * @param array $good      options which have been successfully parsed
184
     * @return array
185
     */
186
    private function postProcessCoordinates(array $postArray, array &$good) {
187
        if (!empty($postArray['geo_long']) && !empty($postArray['geo_lat'])) {
188
189
            $lat = $this->validator->coordinate($postArray['geo_lat']);
190
            $lon = $this->validator->coordinate($postArray['geo_long']);
191
            $good[] = ("general:geo_coordinates");
192
            return [0 => ["general:geo_coordinates" => ['lang' => NULL, 'content' => json_encode(["lon" => $lon, "lat" => $lat])]]];
193
        }
194
        return [];
195
    }
196
197
    /**
198
     * creates HTML code for a user-readable summary of the imports
199
     * @param array $good           list of actually imported options
200
     * @param array $bad            list of submitted but rejected options
201
     * @param array $mlAttribsWithC list of language-variant options
202
     * @return string HTML code
203
     */
204
    private function displaySummaryInUI(array $good, array $bad, array $mlAttribsWithC) {
205
        \core\common\Entity::intoThePotatoes();
206
        $retval = "";
207
        // don't do your own table - only the <tr>s here
208
        // list all attributes that were set correctly
209
        $listGood = array_count_values($good);
210
        $uiElements = new UIElements();
211
        foreach ($listGood as $name => $count) {
212
            /// number of times attribute is present, and its name
213
            /// Example: "5x Support E-Mail"
214
            $retval .= $this->uiElements->boxOkay(sprintf(_("%dx %s"), $count, $uiElements->displayName($name)));
215
        }
216
        // list all atributes that had errors
217
        $listBad = array_count_values($bad);
218
        foreach ($listBad as $name => $count) {
219
            $retval .= $this->uiElements->boxError(sprintf(_("%dx %s"), (int) $count, $uiElements->displayName($name)));
220
        }
221
        // list multilang without default
222
        foreach ($mlAttribsWithC as $attribName => $isitsetornot) {
223
            if ($isitsetornot == FALSE) {
224
                $retval .= $this->uiElements->boxWarning(sprintf(_("You did not set a 'default language' value for %s. This means we can only display this string for installers which are <strong>exactly</strong> in the language you configured. For the sake of all other languages, you may want to edit the profile again and populate the 'default/other' language field."), $uiElements->displayName($attribName)));
225
            }
226
        }
227
        \core\common\Entity::outOfThePotatoes();
228
        return $retval;
229
    }
230
231
    /**
232
     * Incoming data is in $_POST and possibly in $_FILES. Collate values into 
233
     * one array according to our name and numbering scheme.
234
     * 
235
     * @param array $postArray  _POST
236
     * @param array $filesArray _FILES
237
     * @return array
238
     */
239
    private function collateOptionArrays(array $postArray, array $filesArray) {
240
241
        $optionarray = $postArray['option'] ?? [];
242
        $valuearray = $postArray['value'] ?? [];
243
        $filesarray = $filesArray['value']['tmp_name'] ?? [];
244
245
        $iterator = array_merge($optionarray, $valuearray, $filesarray);
246
247
        return $iterator;
248
    }
249
250
    /**
251
     * The very end of the processing: clean input data gets sent to the database
252
     * for storage
253
     * 
254
     * @param mixed  $object            for which object are the options
255
     * @param array  $options           the options to store
256
     * @param array  $pendingattributes list of attributes which are already stored but may need to be deleted
257
     * @param string $device            when the $object is Profile, this indicates device-specific attributes
258
     * @param int    $eaptype           when the $object is Profile, this indicates eap-specific attributes
259
     * @return array list of attributes which were previously stored but are to be deleted now
260
     * @throws Exception
261
     */
262
    private function sendOptionsToDatabase($object, array $options, array $pendingattributes, string $device = NULL, int $eaptype = NULL) {
263
        $retval = [];
264
        foreach ($options as $iterateOption) {
265
            foreach ($iterateOption as $name => $optionPayload) {
266
                $optiontype = $this->optioninfoObject->optionType($name);
267
                // some attributes are in the DB and were only called by reference
268
                // keep those which are still referenced, throw the rest away
269
                if ($optiontype["type"] == \core\Options::TYPECODE_FILE && preg_match("/^ROWID-.*-([0-9]+)/", $optionPayload['content'], $retval)) {
270
                    unset($pendingattributes[$retval[1]]);
271
                    continue;
272
                }
273
                switch (get_class($object)) {
274
                    case 'core\\ProfileRADIUS':
275
                        if ($device !== NULL) {
276
                            $object->addAttributeDeviceSpecific($name, $optionPayload['lang'], $optionPayload['content'], $device);
277
                        } elseif ($eaptype !== NULL) {
278
                            $object->addAttributeEAPSpecific($name, $optionPayload['lang'], $optionPayload['content'], $eaptype);
279
                        } else {
280
                            $object->addAttribute($name, $optionPayload['lang'], $optionPayload['content']);
281
                        }
282
                        break;
283
                    case 'core\\IdP':
284
                    case 'core\\User':
285
                    case 'core\\Federation':
286
                    case 'core\\DeploymentManaged':
287
                        $object->addAttribute($name, $optionPayload['lang'], $optionPayload['content']);
288
                        break;    
289
                    default:
290
                        throw new Exception("This type of object can't have options that are parsed by this file!");
291
                }
292
            }
293
        }
294
        return $pendingattributes;
295
    }
296
297
    /**
298
     * filters the input to find syntactically correctly submitted attributes
299
     * 
300
     * @param array $listOfEntries       list of POST and FILES entries
301
     * @param array $multilangAttrsWithC by-reference: future list of language-variant options and their "default lang" state
302
     * @param array $bad                 by-reference: future list of submitted but rejected options
303
     * @return array sanitised list of options
304
     * @throws Exception
305
     */
306
    private function sanitiseInputs(array $listOfEntries, array &$multilangAttrsWithC, array &$bad) {
307
        $retval = [];
308
        foreach ($listOfEntries as $objId => $objValueRaw) {
309
// pick those without dash - they indicate a new value        
310
            if (preg_match('/^S[0123456789]*$/', $objId)) {
311
                $objValue = $this->validator->optionName(preg_replace('/#.*$/', '', $objValueRaw));
312
                $optioninfo = $this->optioninfoObject->optionType($objValue);
313
                $lang = NULL;
314
                if ($optioninfo["flag"] == "ML") {
315
                    if (isset($listOfEntries["$objId-lang"])) {
316
                        if (!isset($multilangAttrsWithC[$objValue])) { // on first sight, initialise the attribute as "no C language set"
317
                            $multilangAttrsWithC[$objValue] = FALSE;
318
                        }
319
                        $lang = $listOfEntries["$objId-lang"];
320
                        if ($lang == "") { // user forgot to select a language
321
                            $lang = "C";
322
                        }
323
                    } else {
324
                        $bad[] = $objValue;
325
                        continue;
326
                    }
327
                    // did we get a C language? set corresponding value to TRUE
328
                    if ($lang == "C") {
329
                        $multilangAttrsWithC[$objValue] = TRUE;
330
                    }
331
                }
332
333
                // many of the cases below condense due to identical treatment
334
                // except validator function to call and where in POST the
335
                // content is
336
                $validators = [
337
                    \core\Options::TYPECODE_TEXT => ["function" => "string", "field" => \core\Options::TYPECODE_TEXT, "extraarg" => [TRUE]],
338
                    \core\Options::TYPECODE_COORDINATES => ["function" => "coordJsonEncoded", "field" => \core\Options::TYPECODE_TEXT, "extraarg" => []],
339
                    \core\Options::TYPECODE_BOOLEAN => ["function" => "boolean", "field" => \core\Options::TYPECODE_BOOLEAN, "extraarg" => []],
340
                    \core\Options::TYPECODE_INTEGER => ["function" => "integer", "field" => \core\Options::TYPECODE_INTEGER, "extraarg" => []],
341
                ];
342
343
                switch ($optioninfo["type"]) {
344
                    case \core\Options::TYPECODE_TEXT:
345
                    case \core\Options::TYPECODE_COORDINATES:
346
                    case \core\Options::TYPECODE_INTEGER:
347
                        $varName = "$objId-" . $validators[$optioninfo['type']]['field'];
348
                        if (!empty($listOfEntries[$varName])) {
349
                            $content = call_user_func_array([$this->validator, $validators[$optioninfo['type']]['function']], array_merge([$listOfEntries[$varName]], $validators[$optioninfo['type']]['extraarg']));
350
                            break;
351
                        }
352
                        continue 2;
353
                    case \core\Options::TYPECODE_BOOLEAN:
354
                        $varName = "$objId-" . \core\Options::TYPECODE_BOOLEAN;
355
                        if (!empty($listOfEntries[$varName])) {
356
                            $contentValid = $this->validator->boolean($listOfEntries[$varName]);
357
                            if ($contentValid) {
358
                                $content = "on";
359
                            } else {
360
                                $bad[] = $objValue;
361
                                continue 2;
362
                            }
363
                            break;
364
                        }
365
                        continue 2;
366
                    case \core\Options::TYPECODE_STRING:
367
                        if (!empty($listOfEntries["$objId-" . \core\Options::TYPECODE_STRING])) {
368
                            switch ($objValue) {
369
                                case "media:consortium_OI":
370
                                    $content = $this->validator->consortiumOI($listOfEntries["$objId-" . \core\Options::TYPECODE_STRING]);
371
                                    if ($content === FALSE) {
372
                                        $bad[] = $objValue;
373
                                        continue 3;
374
                                    }
375
                                    break;
376
                                case "media:remove_SSID":
377
                                    $content = $this->validator->string($listOfEntries["$objId-" . \core\Options::TYPECODE_STRING]);
378
                                    if ($content == "eduroam") {
379
                                        $bad[] = $objValue;
380
                                        continue 3;
381
                                    }
382
                                    break;
383
                                case "media:force_proxy":
384
                                    $content = $this->validator->string($listOfEntries["$objId-" . \core\Options::TYPECODE_STRING]);
385
                                    $serverAndPort = explode(':', strrev($content), 2);
386
                                    if (count($serverAndPort) != 2) {
387
                                        $bad[] = $objValue;
388
                                        continue 3;
389
                                    }
390
                                    $port = strrev($serverAndPort[0]);
391
                                    if (!is_numeric($port)) {
392
                                        $bad[] = $objValue;
393
                                        continue 3;
394
                                    }
395
                                    break;
396
                                case "support:url":
397
                                    $content = $this->validator->string($listOfEntries["$objId-" . \core\Options::TYPECODE_STRING]);
398
                                    if (preg_match("/^http/",$content) != 1) {
399
                                        $bad[] = $objValue;
400
                                        continue 3;
401
                                    }
402
                                    break;
403
                                case "support:email":
404
                                    $content = $this->validator->email($listOfEntries["$objId-" . \core\Options::TYPECODE_STRING]);
405
                                    if ($content === FALSE) {
406
                                        $bad[] = $objValue;
407
                                        continue 3;
408
                                    }
409
                                    break;
410
                                default:
411
                                    $content = $this->validator->string($listOfEntries["$objId-" . \core\Options::TYPECODE_STRING]);
412
                                    break;
413
                            }
414
                            break;
415
                        }
416
                        continue 2;
417
                    case \core\Options::TYPECODE_FILE:
418
                        if (!empty($listOfEntries["$objId-" . \core\Options::TYPECODE_STRING])) { // was already in, by ROWID reference, extract
419
                            // ROWID means it's a multi-line string (simple strings are inline in the form; so allow whitespace)
420
                            $content = $this->validator->string(urldecode($listOfEntries["$objId-" . \core\Options::TYPECODE_STRING]), TRUE);
421
                            break;
422
                        } else if (isset($listOfEntries["$objId-" . \core\Options::TYPECODE_FILE]) && ($listOfEntries["$objId-" . \core\Options::TYPECODE_FILE] != "")) { // let's do the download
423
                            $rawContent = \core\common\OutsideComm::downloadFile("file:///" . $listOfEntries["$objId-" . \core\Options::TYPECODE_FILE]);
424
425
                            if ($rawContent === FALSE || !$this->checkUploadSanity($objValue, $rawContent)) {
426
                                $bad[] = $objValue;
427
                                continue 2;
428
                            }
429
                            $content = base64_encode($rawContent);
430
                            break;
431
                        }
432
                        continue 2;
433
                    default:
434
                        throw new Exception("Internal Error: Unknown option type " . $objValue . "!");
435
                }
436
                // lang can be NULL here, if it's not a multilang attribute, or a ROWID reference. Never mind that.
437
                $retval[] = ["$objValue" => ["lang" => $lang, "content" => $content]];
438
            }
439
        }
440
        return $retval;
441
    }
442
443
    /**
444
     * The main function: takes all HTML field inputs, makes sense of them and stores valid data in the database
445
     * 
446
     * @param mixed  $object     The object for which attributes were submitted
447
     * @param array  $postArray  incoming attribute names and values as submitted with $_POST
448
     * @param array  $filesArray incoming attribute names and values as submitted with $_FILES
449
     * @param int    $eaptype    for eap-specific attributes (only used where $object is a ProfileRADIUS instance)
450
     * @param string $device     for device-specific attributes (only used where $object is a ProfileRADIUS instance)
451
     * @return string text to be displayed in UI with the summary of attributes added
452
     * @throws Exception
453
     */
454
    public function processSubmittedFields($object, array $postArray, array $filesArray, int $eaptype = NULL, string $device = NULL) {
455
456
        // construct new array with all non-empty options for later feeding into DB
457
        // $multilangAttrsWithC is a helper array to keep track of multilang 
458
        // options that were set in a specific language but are not 
459
        // accompanied by a "default" language setting
460
        // if there are some without C by the end of processing, we need to warn
461
        // the admin that this attribute is "invisible" in certain languages
462
        // attrib_name -> boolean
463
464
        $multilangAttrsWithC = [];
465
466
        // these two variables store which attributes were processed 
467
        // successfully vs. which were discarded because in some way malformed
468
469
        $good = [];
470
        $bad = [];
471
472
        // Step 1: collate option names, option values and uploaded files (by 
473
        // filename reference) into one array for later handling
474
475
        $iterator = $this->collateOptionArrays($postArray, $filesArray);
476
        
477
        // Step 2: sieve out malformed input
478
        $cleanData = $this->sanitiseInputs($iterator, $multilangAttrsWithC, $bad);
479
        // Step 3: now we have clean input data. Some attributes need special care:
480
        // URL-based attributes need to be downloaded to get their actual content
481
        // CA files may need to be split (PEM can contain multiple CAs 
482
483
        $optionsStep2 = $this->postProcessValidAttributes($cleanData, $good, $bad);
484
485
        // Step 4: coordinates do not follow the usual POST array as they are 
486
        // two values forming one attribute; extract those two as an extra step
487
488
        $options = array_merge($optionsStep2, $this->postProcessCoordinates($postArray, $good));
489
        
490
        // Step 5: push all the received options to the database. Keep mind of 
491
        // the list of existing database entries that are to be deleted.
492
        // 5a: first deletion step: purge all old content except file-based attributes;
493
        //     then take note of which file-based attributes are now stale
494
        if ($device === NULL && $eaptype === NULL) {
495
            $remaining = $object->beginflushAttributes();
496
            $killlist = $this->sendOptionsToDatabase($object, $options, $remaining);
497
        } elseif ($device !== NULL) {
498
            $remaining = $object->beginFlushMethodLevelAttributes(0, $device);
499
            $killlist = $this->sendOptionsToDatabase($object, $options, $remaining, $device);
500
        } else {
501
            $remaining = $object->beginFlushMethodLevelAttributes($eaptype, "");
502
            $killlist = $this->sendOptionsToDatabase($object, $options, $remaining, NULL, $eaptype);
503
        }
504
        // 5b: finally, kill the stale file-based attributes which are not wanted any more.
505
        $object->commitFlushAttributes($killlist);
506
507
        // finally: return HTML code that gives feedback about what we did. 
508
        // In some cases, callers won't actually want to display it; so simply
509
        // do not echo the return value. Reasons not to do this is if we working
510
        // e.g. from inside an overlay
511
512
        return $this->displaySummaryInUI($good, $bad, $multilangAttrsWithC);
513
    }
514
515
}
516