Passed
Push — master ( 86af57...0acbd9 )
by Rustam
02:47
created

TrustedHostsNetworkResolver::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 0
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 2
ccs 1
cts 1
cp 1
crap 1
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Middleware;
6
7
use InvalidArgumentException;
8
use Psr\Http\Message\RequestInterface;
9
use Psr\Http\Message\ResponseInterface;
10
use Psr\Http\Message\ServerRequestInterface;
11
use Psr\Http\Server\MiddlewareInterface;
12
use Psr\Http\Server\RequestHandlerInterface;
13
use RuntimeException;
14
use Yiisoft\Http\HeaderValueHelper;
15
use Yiisoft\NetworkUtilities\IpHelper;
16
use Yiisoft\Validator\Rule\Ip;
17
use Yiisoft\Validator\ValidatorInterface;
18
19
use function array_diff;
20
use function array_pad;
21
use function array_reverse;
22
use function array_shift;
23
use function array_unshift;
24
use function count;
25
use function explode;
26
use function in_array;
27
use function is_array;
28
use function is_callable;
29
use function filter_var;
30
use function is_string;
31
use function preg_match;
32
use function str_replace;
33
use function strpos;
34
use function strtolower;
35
use function trim;
36
37
/**
38
 * Trusted hosts network resolver.
39
 *
40
 * ```php
41
 * (new TrustedHostsNetworkResolver($responseFactory))
42
 * ->withAddedTrustedHosts(
43
 *   // List of secure hosts including $_SERVER['REMOTE_ADDR'], can specify IPv4, IPv6, domains and aliases {@see Ip}.
44
 *   ['1.1.1.1', '2.2.2.1/3', '2001::/32', 'localhost'].
45
 *   // IP list headers. For advanced handling headers {@see TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239}.
46
 *   // Headers containing multiple sub-elements (e.g. RFC 7239) must also be listed for other relevant types
47
 *   // (e.g. host headers), otherwise they will only be used as an IP list.
48
 *   ['x-forwarded-for', [TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, 'forwarded']]
49
 *   // Protocol headers with accepted protocols and values. Matching of values is case-insensitive.
50
 *   ['front-end-https' => ['https' => 'on']],
51
 *   // Host headers
52
 *   ['forwarded', 'x-forwarded-for']
53
 *   // URL headers
54
 *   ['x-rewrite-url'],
55
 *   // Port headers
56
 *   ['x-rewrite-port'],
57
 *   // Trusted headers. It is a good idea to list all relevant headers.
58
 *   ['x-forwarded-for', 'forwarded', ...],
59
 * );
60
 * ```
61
 */
62
class TrustedHostsNetworkResolver implements MiddlewareInterface
63
{
64
    public const IP_HEADER_TYPE_RFC7239 = 'rfc7239';
65
66
    public const DEFAULT_TRUSTED_HEADERS = [
67
        // common:
68
        'x-forwarded-for',
69
        'x-forwarded-host',
70
        'x-forwarded-proto',
71
        'x-forwarded-port',
72
73
        // RFC:
74
        'forward',
75
76
        // Microsoft:
77
        'front-end-https',
78
        'x-rewrite-url',
79
    ];
80
81
    private const DATA_KEY_HOSTS = 'hosts';
82
    private const DATA_KEY_IP_HEADERS = 'ipHeaders';
83
    private const DATA_KEY_HOST_HEADERS = 'hostHeaders';
84
    private const DATA_KEY_URL_HEADERS = 'urlHeaders';
85
    private const DATA_KEY_PROTOCOL_HEADERS = 'protocolHeaders';
86
    private const DATA_KEY_TRUSTED_HEADERS = 'trustedHeaders';
87
    private const DATA_KEY_PORT_HEADERS = 'portHeaders';
88
89
    private array $trustedHosts = [];
90
    private ?string $attributeIps = null;
91
92 35
    public function __construct(private ValidatorInterface $validator)
93
    {
94
    }
95
96
    /**
97
     * Returns a new instance with the added trusted hosts and related headers.
98
     *
99
     * The header lists are evaluated in the order they were specified.
100
     * If you specify multiple headers by type (e.g. IP headers), you must ensure that the irrelevant header is removed
101
     * e.g. web server application, otherwise spoof clients can be use this vulnerability.
102
     *
103
     * @param string[] $hosts List of trusted hosts IP addresses. If {@see isValidHost()} method is extended,
104
     * then can use domain names with reverse DNS resolving e.g. yiiframework.com, * .yiiframework.com.
105
     * @param array $ipHeaders List of headers containing IP lists.
106
     * @param array $protocolHeaders List of headers containing protocol. e.g.
107
     * ['x-forwarded-for' => ['http' => 'http', 'https' => ['on', 'https']]].
108
     * @param string[] $hostHeaders List of headers containing HTTP host.
109
     * @param string[] $urlHeaders List of headers containing HTTP URL.
110
     * @param string[] $portHeaders List of headers containing port number.
111
     * @param string[]|null $trustedHeaders List of trusted headers. Removed from the request, if in checking process
112
     * are classified as untrusted by hosts.
113
     *
114
     * @return self
115
     */
116 32
    public function withAddedTrustedHosts(
117
        array $hosts,
118
        // Defining default headers is not secure!
119
        array $ipHeaders = [],
120
        array $protocolHeaders = [],
121
        array $hostHeaders = [],
122
        array $urlHeaders = [],
123
        array $portHeaders = [],
124
        ?array $trustedHeaders = null
125
    ): self {
126 32
        $new = clone $this;
127
128 32
        foreach ($ipHeaders as $ipHeader) {
129 16
            if (is_string($ipHeader)) {
130 5
                continue;
131
            }
132
133 11
            if (!is_array($ipHeader)) {
134 1
                throw new InvalidArgumentException('Type of IP header is not a string and not array.');
135
            }
136
137 10
            if (count($ipHeader) !== 2) {
138 1
                throw new InvalidArgumentException('The IP header array must have exactly 2 elements.');
139
            }
140
141 9
            [$type, $header] = $ipHeader;
142
143 9
            if (!is_string($type)) {
144 1
                throw new InvalidArgumentException('The IP header type is not a string.');
145
            }
146
147 8
            if (!is_string($header)) {
148 1
                throw new InvalidArgumentException('The IP header value is not a string.');
149
            }
150
151 7
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
152 6
                continue;
153
            }
154
155 1
            throw new InvalidArgumentException("Not supported IP header type: $type.");
156
        }
157
158 27
        if ($hosts === []) {
159 8
            throw new InvalidArgumentException('Empty hosts not allowed.');
160
        }
161
162 19
        $trustedHeaders = $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS;
163 19
        $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders);
164
165 15
        $this->checkTypeStringOrArray($hosts, 'hosts');
166 12
        $this->checkTypeStringOrArray($trustedHeaders, 'trustedHeaders');
167 12
        $this->checkTypeStringOrArray($hostHeaders, 'hostHeaders');
168 12
        $this->checkTypeStringOrArray($urlHeaders, 'urlHeaders');
169 12
        $this->checkTypeStringOrArray($portHeaders, 'portHeaders');
170
171 12
        foreach ($hosts as $host) {
172 12
            $host = str_replace('*', 'wildcard', $host); // wildcard is allowed in host
173
174 12
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) {
175 1
                throw new InvalidArgumentException("\"$host\" host is not a domain and not an IP address.");
176
            }
177
        }
178
179 11
        $new->trustedHosts[] = [
180
            self::DATA_KEY_HOSTS => $hosts,
181
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
182
            self::DATA_KEY_PROTOCOL_HEADERS => $protocolHeaders,
183
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders,
184
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
185
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
186
            self::DATA_KEY_PORT_HEADERS => $portHeaders,
187
        ];
188
189 11
        return $new;
190
    }
191
192
    /**
193
     * Returns a new instance without the trusted hosts and related headers.
194
     *
195
     * @return self
196
     */
197 1
    public function withoutTrustedHosts(): self
198
    {
199 1
        $new = clone $this;
200 1
        $new->trustedHosts = [];
201 1
        return $new;
202
    }
203
204
    /**
205
     * Returns a new instance with the specified request's attribute name to which trusted path data is added.
206
     *
207
     * @param string|null $attribute The request attribute name.
208
     *
209
     * @see getElementsByRfc7239()
210
     *
211
     * @return self
212
     */
213 3
    public function withAttributeIps(?string $attribute): self
214
    {
215 3
        if ($attribute === '') {
216 1
            throw new RuntimeException('Attribute should not be empty string.');
217
        }
218
219 2
        $new = clone $this;
220 2
        $new->attributeIps = $attribute;
221 2
        return $new;
222
    }
223
224 12
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
225
    {
226 12
        $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null;
227
228 12
        if ($actualHost === null) {
229
            // Validation is not possible.
230 1
            return $this->handleNotTrusted($request, $handler);
231
        }
232
233 11
        $trustedHostData = null;
234 11
        $trustedHeaders = [];
235
236 11
        foreach ($this->trustedHosts as $data) {
237
            // collect all trusted headers
238 10
            $trustedHeaders = array_merge($trustedHeaders, $data[self::DATA_KEY_TRUSTED_HEADERS]);
239
240 10
            if ($trustedHostData !== null) {
241
                // trusted hosts already found
242
                continue;
243
            }
244
245 10
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS])) {
246 8
                $trustedHostData = $data;
247
            }
248
        }
249
250
        /** @psalm-suppress PossiblyNullArgument, PossiblyNullArrayAccess */
251 11
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
252 11
        $request = $this->removeHeaders($request, $untrustedHeaders);
253
254 11
        if ($trustedHostData === null) {
255
            // No trusted host at all.
256 3
            return $this->handleNotTrusted($request, $handler);
257
        }
258
259 8
        [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
260 8
        $hostList = array_reverse($hostList); // the first item should be the closest to the server
261
262 8
        if ($ipListType === null) {
263 2
            $hostList = $this->getFormattedIpList($hostList);
264 6
        } elseif ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
265 6
            $hostList = $this->getElementsByRfc7239($hostList);
266
        }
267
268 8
        array_unshift($hostList, ['ip' => $actualHost]); // server's ip to first position
269 8
        $hostDataList = [];
270
271
        do {
272 8
            $hostData = array_shift($hostList);
273 8
            if (!isset($hostData['ip'])) {
274
                $hostData = $this->reverseObfuscate($hostData, $hostDataList, $hostList, $request);
275
276
                if ($hostData === null) {
277
                    continue;
278
                }
279
280
                if (!isset($hostData['ip'])) {
281
                    break;
282
                }
283
            }
284
285 8
            $ip = $hostData['ip'];
286
287 8
            if (!$this->isValidHost($ip, ['any'])) {
288
                // invalid IP
289
                break;
290
            }
291
292 8
            $hostDataList[] = $hostData;
293
294 8
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS])) {
295
                // not trusted host
296 8
                break;
297
            }
298 8
        } while (count($hostList) > 0);
299
300 8
        if ($this->attributeIps !== null) {
301
            $request = $request->withAttribute($this->attributeIps, $hostDataList);
302
        }
303
304 8
        $uri = $request->getUri();
305
        // find HTTP host
306 8
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
307 4
            if (!$request->hasHeader($hostHeader)) {
308
                continue;
309
            }
310
311
            if (
312 4
                $hostHeader === $ipHeader
313 4
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
314 4
                && isset($hostData['httpHost'])
315
            ) {
316 2
                $uri = $uri->withHost($hostData['httpHost']);
317 2
                break;
318
            }
319
320 2
            $host = $request->getHeaderLine($hostHeader);
321
322 2
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
323 2
                $uri = $uri->withHost($host);
324 2
                break;
325
            }
326
        }
327
328
        // find protocol
329 8
        foreach ($trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS] as $protocolHeader => $protocols) {
330 4
            if (!$request->hasHeader($protocolHeader)) {
331
                continue;
332
            }
333
334
            if (
335 4
                $protocolHeader === $ipHeader
336 4
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
337 4
                && isset($hostData['protocol'])
338
            ) {
339 4
                $uri = $uri->withScheme($hostData['protocol']);
340 4
                break;
341
            }
342
343 2
            $protocolHeaderValue = $request->getHeaderLine($protocolHeader);
344
345 2
            foreach ($protocols as $protocol => $acceptedValues) {
346 2
                if (in_array($protocolHeaderValue, $acceptedValues, true)) {
347
                    $uri = $uri->withScheme($protocol);
348
                    break 2;
349
                }
350
            }
351
        }
352
353 8
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
354
355 8
        if ($urlParts !== null) {
356 3
            [$path, $query] = $urlParts;
357 3
            $uri = $uri->withPath($path);
358
359 3
            if ($query !== null) {
360 3
                $uri = $uri->withQuery($query);
361
            }
362
        }
363
364
        // find port
365 8
        foreach ($trustedHostData[self::DATA_KEY_PORT_HEADERS] as $portHeader) {
366 1
            if (!$request->hasHeader($portHeader)) {
367
                continue;
368
            }
369
370
            if (
371 1
                $portHeader === $ipHeader
372 1
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
373 1
                && isset($hostData['port'])
374 1
                && $this->checkPort((string) $hostData['port'])
375
            ) {
376 1
                $uri = $uri->withPort($hostData['port']);
377 1
                break;
378
            }
379
380
            $port = $request->getHeaderLine($portHeader);
381
382
            if ($this->checkPort($port)) {
383
                $uri = $uri->withPort((int) $port);
384
                break;
385
            }
386
        }
387
388 8
        return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $hostData['ip'] ?? null));
389
    }
390
391
    /**
392
     * Validate host by range.
393
     *
394
     * This method can be extendable by overwriting e.g. with reverse DNS verification.
395
     */
396 10
    protected function isValidHost(string $host, array $ranges): bool
397
    {
398 10
        $validationResult = $this->validator->validate(
399 10
            ['host' => $host],
400 10
            ['host' => [new Ip(ranges: $ranges, allowNegation: false, allowSubnet: false)]]
401
        );
402 10
        return $validationResult->isValid();
403
    }
404
405
    /**
406
     * Reverse obfuscating host data
407
     *
408
     * RFC 7239 allows to use obfuscated host data. In this case, either specifying the
409
     * IP address or dropping the proxy endpoint is required to determine validated route.
410
     *
411
     * By default it does not perform any transformation on the data. You can override this method.
412
     *
413
     * @param array $hostData
414
     * @param array $hostDataListValidated
415
     * @param array $hostDataListRemaining
416
     * @param RequestInterface $request
417
     *
418
     * @return array|null reverse obfuscated host data or null.
419
     * In case of null data is discarded and the process continues with the next portion of host data.
420
     * If the return value is an array, it must contain at least the `ip` key.
421
     *
422
     * @see getElementsByRfc7239()
423
     * @link https://tools.ietf.org/html/rfc7239#section-6.2
424
     * @link https://tools.ietf.org/html/rfc7239#section-6.3
425
     */
426
    protected function reverseObfuscate(
427
        array $hostData,
428
        array $hostDataListValidated,
0 ignored issues
show
Unused Code introduced by
The parameter $hostDataListValidated 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

428
        /** @scrutinizer ignore-unused */ array $hostDataListValidated,

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...
429
        array $hostDataListRemaining,
0 ignored issues
show
Unused Code introduced by
The parameter $hostDataListRemaining 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

429
        /** @scrutinizer ignore-unused */ array $hostDataListRemaining,

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...
430
        RequestInterface $request
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

430
        /** @scrutinizer ignore-unused */ RequestInterface $request

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...
431
    ): ?array {
432
        return $hostData;
433
    }
434
435 4
    private function handleNotTrusted(
436
        ServerRequestInterface $request,
437
        RequestHandlerInterface $handler
438
    ): ResponseInterface {
439 4
        if ($this->attributeIps !== null) {
440 1
            $request = $request->withAttribute($this->attributeIps, null);
441
        }
442
443 4
        return $handler->handle($request->withAttribute('requestClientIp', null));
444
    }
445
446 19
    private function prepareProtocolHeaders(array $protocolHeaders): array
447
    {
448 19
        $output = [];
449
450 19
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
451 8
            $header = strtolower($header);
452
453 8
            if (is_callable($protocolAndAcceptedValues)) {
454
                $output[$header] = $protocolAndAcceptedValues;
455
                continue;
456
            }
457
458 8
            if (!is_array($protocolAndAcceptedValues)) {
459 1
                throw new RuntimeException('Accepted values is not an array nor callable.');
460
            }
461
462 7
            if ($protocolAndAcceptedValues === []) {
463 1
                throw new RuntimeException('Accepted values cannot be an empty array.');
464
            }
465
466 6
            $output[$header] = [];
467
468 6
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
469 6
                if (!is_string($protocol)) {
470 1
                    throw new RuntimeException('The protocol must be a string.');
471
                }
472
473 5
                if ($protocol === '') {
474 1
                    throw new RuntimeException('The protocol cannot be empty.');
475
                }
476
477 4
                $output[$header][$protocol] = array_map('\strtolower', (array)$acceptedValues);
478
            }
479
        }
480
481 15
        return $output;
482
    }
483
484 11
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
485
    {
486 11
        foreach ($headers as $header) {
487
            $request = $request->withoutAttribute($header);
488
        }
489
490 11
        return $request;
491
    }
492
493 8
    private function getIpList(RequestInterface $request, array $ipHeaders): array
494
    {
495 8
        foreach ($ipHeaders as $ipHeader) {
496 8
            $type = null;
497
498 8
            if (is_array($ipHeader)) {
499 6
                $type = array_shift($ipHeader);
500 6
                $ipHeader = array_shift($ipHeader);
501
            }
502
503 8
            if ($request->hasHeader($ipHeader)) {
504 8
                return [$type, $ipHeader, $request->getHeader($ipHeader)];
505
            }
506
        }
507
508
        return [null, null, []];
509
    }
510
511
    /**
512
     * @see getElementsByRfc7239
513
     */
514 2
    private function getFormattedIpList(array $forwards): array
515
    {
516 2
        $list = [];
517
518 2
        foreach ($forwards as $ip) {
519 2
            $list[] = ['ip' => $ip];
520
        }
521
522 2
        return $list;
523
    }
524
525
    /**
526
     * Forwarded elements by RFC7239.
527
     *
528
     * The structure of the elements:
529
     * - `host`: IP or obfuscated hostname or "unknown"
530
     * - `ip`: IP address (only if presented)
531
     * - `by`: used user-agent by proxy (only if presented)
532
     * - `port`: port number received by proxy (only if presented)
533
     * - `protocol`: protocol received by proxy (only if presented)
534
     * - `httpHost`: HTTP host received by proxy (only if presented)
535
     *
536
     * The list starts with the server and the last item is the client itself.
537
     *
538
     * @link https://tools.ietf.org/html/rfc7239
539
     *
540
     * @return array Proxy data elements.
541
     */
542 6
    private function getElementsByRfc7239(array $forwards): array
543
    {
544 6
        $list = [];
545
546 6
        foreach ($forwards as $forward) {
547 6
            $data = HeaderValueHelper::getParameters($forward);
548
549 6
            if (!isset($data['for'])) {
550
                // Invalid item, the following items will be dropped
551
                break;
552
            }
553
554 6
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]'
555
                . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
556
557 6
            if (preg_match($pattern, $data['for'], $matches) === 0) {
558
                // Invalid item, the following items will be dropped
559
                break;
560
            }
561
562 6
            $ipData = [];
563 6
            $host = $matches['host'];
564 6
            $obfuscatedHost = $host === 'unknown' || str_starts_with($host, '_');
565
566 6
            if (!$obfuscatedHost) {
567
                // IPv4 & IPv6
568 6
                $ipData['ip'] = str_starts_with($host, '[') ? trim($host /* IPv6 */, '[]') : $host;
569
            }
570
571 6
            $ipData['host'] = $host;
572
573 6
            if (isset($matches['port'])) {
574 1
                $port = $matches['port'];
575
576 1
                if (!$obfuscatedHost && !$this->checkPort($port)) {
577
                    // Invalid port, the following items will be dropped
578
                    break;
579
                }
580
581 1
                $ipData['port'] = $obfuscatedHost ? $port : (int)$port;
582
            }
583
584
            // copy other properties
585 6
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
586 6
                if (isset($data[$source])) {
587 4
                    $ipData[$destination] = $data[$source];
588
                }
589
            }
590
591 6
            if (isset($ipData['httpHost']) && filter_var($ipData['httpHost'], FILTER_VALIDATE_DOMAIN) === false) {
592
                // remove not valid HTTP host
593
                unset($ipData['httpHost']);
594
            }
595
596 6
            $list[] = $ipData;
597
        }
598
599 6
        return $list;
600
    }
601
602 8
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
603
    {
604 8
        foreach ($urlHeaders as $header) {
605 3
            if (!$request->hasHeader($header)) {
606
                continue;
607
            }
608
609 3
            $url = $request->getHeaderLine($header);
610
611 3
            if (str_starts_with($url, '/')) {
612 3
                return array_pad(explode('?', $url, 2), 2, null);
613
            }
614
        }
615
616 5
        return null;
617
    }
618
619 1
    private function checkPort(string $port): bool
620
    {
621 1
        return preg_match('/^\d{1,5}$/', $port) === 1 && (int)$port <= 65535;
622
    }
623
624 15
    private function checkTypeStringOrArray(array $array, string $field): void
625
    {
626 15
        foreach ($array as $item) {
627 15
            if (!is_string($item)) {
628 1
                throw new InvalidArgumentException("$field must be string type");
629
            }
630
631 14
            if (trim($item) === '') {
632 2
                throw new InvalidArgumentException("$field cannot be empty strings");
633
            }
634
        }
635
    }
636
}
637