Passed
Push — master ( 28eddd...1e0b05 )
by Stefan
08:22
created

OptionParser::sanitiseInputs()   D

Complexity

Conditions 30
Paths 210

Size

Total Lines 121
Code Lines 90

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 121
rs 4.0556
c 0
b 0
f 0
cc 30
eloc 90
nc 210
nop 3

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