filterRequestParametersBySelector()   A
last analyzed

Complexity

Conditions 6
Paths 6

Size

Total Lines 25
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 6

Importance

Changes 0
Metric Value
eloc 12
dl 0
loc 25
ccs 13
cts 13
cp 1
rs 9.2222
c 0
b 0
f 0
cc 6
nc 6
nop 1
crap 6
1
<?php
2
3
declare(strict_types=1);
4
5
namespace DanBettles\Defence\Filter;
6
7
use DanBettles\Defence\Envelope;
8
use InvalidArgumentException;
9
use Symfony\Component\HttpFoundation\Request;
10
11
use function array_flip;
12
use function array_intersect_key;
13
use function array_map;
14
use function array_replace;
15
use function gettype;
16
use function implode;
17
use function in_array;
18
use function is_array;
19
use function is_string;
20
use function preg_match;
21
22
use const false;
23
use const null;
24
use const true;
25
26
/**
27
 * This filter can be used to reject requests containing a parameter with a suspicious value.  Its principal purpose is
28
 * to prevent SQL injection.
29
 *
30
 * There are a number of different types of parameter that can be easily vetted without in-depth knowledge of how
31
 * they're used.  For example, most -- if not all -- database ID (primary/foreign key) parameters, which are frequently
32
 * targeted in SQL injection attacks, should contain only digits.  Similarly, if you use the ISO 8601 format to express
33
 * dates then you're expecting only digits and dashes.  Consider the conventions you employ in your own application.
34
 *
35
 * There are two ways to select the parameters to examine: pass an array of names; or specify a regex that will match
36
 * the names of parameters of interest.  The second approach is especially useful if you follow strict naming
37
 * conventions.  For example, if you follow the old Ruby on Rails convention of using the suffixes "_on" and "_at" to
38
 * indicate date/times then you could easily select date parameters.
39
 *
40
 * The validity of the value is determined using the 'validator', a regular expression that matches a _valid_ value.
41
 *
42
 * @phpstan-import-type IncomingFilterOptions from AbstractFilter
43
 *
44
 * @phpstan-type AugmentedFilterOptions array{log_level:string,type:string|null}
45
 * @phpstan-type Selector string|string[]
46
 * @phpstan-type RequestParameters array<string,string>
47
 * @phpstan-type GroupedParameters array{query?:RequestParameters,request?:RequestParameters}
48
 *
49
 * @method AugmentedFilterOptions getOptions()
50
 */
51
class InvalidParameterFilter extends AbstractFilter
52
{
53
    public const TYPE_ANY = null;
54
    /** @var string */
55
    public const TYPE_STRING = 'string';
56
    /** @var string */
57
    public const TYPE_ARRAY = 'array';
58
59
    /**
60
     * @var array<string|null>
61
     */
62
    private const VALID_TYPES = [
63
        self::TYPE_ANY,
64
        self::TYPE_STRING,
65
        self::TYPE_ARRAY,
66
    ];
67
68
    /**
69
     * @phpstan-var Selector
70
     */
71
    private $selector;
72
73
    /**
74
     * @var string
75
     */
76
    private $validator;
77
78
    /**
79
     * @phpstan-param Selector $selector
80
     * @phpstan-param IncomingFilterOptions $options
81
     * @throws InvalidArgumentException If the value of the `type` option is invalid
82
     */
83 140
    public function __construct(
84
        $selector,
85
        string $validator,
86
        array $options = []
87
    ) {
88
        /** @phpstan-var AugmentedFilterOptions */
89 140
        $completeOptions = array_replace([
90 140
            'type' => self::TYPE_ANY,
91 140
        ], $options);
92
93 140
        parent::__construct($completeOptions);
94
95 140
        $type = $this->getOptions()['type'];
96
97 140
        if (!in_array($type, self::VALID_TYPES, true)) {
98 22
            $listOfValidTypeNames = implode(', ', array_map(
99 22
                fn ($typeName): string => (null === $typeName ? '`NULL`' : "`\"{$typeName}\"`"),
100 22
                self::VALID_TYPES
101 22
            ));
102
103 22
            throw new InvalidArgumentException("Option `type` is not one of: {$listOfValidTypeNames}");
104
        }
105
106 118
        $this
107 118
            ->setSelector($selector)
108 118
            ->setValidator($validator)
109 118
        ;
110
    }
111
112
    /**
113
     * @phpstan-param Selector $selector
114
     * @throws InvalidArgumentException If the selector is invalid.
115
     */
116 118
    private function setSelector($selector): self
117
    {
118
        /** @phpstan-ignore-next-line */
119 118
        if (!is_array($selector) && !is_string($selector)) {
120 4
            throw new InvalidArgumentException('The selector is invalid.');
121
        }
122
123 114
        $this->selector = $selector;
124
125 114
        return $this;
126
    }
127
128
    /**
129
     * @phpstan-return Selector
130
     */
131 110
    public function getSelector()
132
    {
133 110
        return $this->selector;
134
    }
135
136 114
    private function setValidator(string $validator): self
137
    {
138 114
        $this->validator = $validator;
139
140 114
        return $this;
141
    }
142
143 90
    public function getValidator(): string
144
    {
145 90
        return $this->validator;
146
    }
147
148
    /**
149
     * Returns an array containing all request parameters, grouped by parameter-bag name.
150
     *
151
     * @phpstan-return GroupedParameters
152
     */
153 100
    private function requestGetParametersGrouped(Request $request): array
154
    {
155 100
        $grouped = [];
156
157 100
        foreach (['query', 'request'] as $paramBagName) {
158 100
            $grouped[$paramBagName] = $request->{$paramBagName}->all();
159
        }
160
161 100
        return $grouped;
162
    }
163
164
    /**
165
     * Filters the request parameters using the selector; returns an array containing the relevant request parameters
166
     * grouped by parameter-bag name
167
     *
168
     * @phpstan-return GroupedParameters
169
     */
170 100
    private function filterRequestParametersBySelector(Request $request): array
171
    {
172 100
        if (is_array($this->getSelector())) {
173 84
            $selectorParamNamesAsKeys = array_flip($this->getSelector());
174
175 84
            $relevantParameters = [];
176
177 84
            foreach ($this->requestGetParametersGrouped($request) as $paramBagName => $parameters) {
178 84
                $relevantParameters[$paramBagName] = array_intersect_key($parameters, $selectorParamNamesAsKeys);
179
            }
180
181 84
            return $relevantParameters;
182
        }
183
184 16
        $relevantParameters = [];
185
186 16
        foreach ($this->requestGetParametersGrouped($request) as $paramBagName => $parameters) {
187 16
            foreach ($parameters as $paramName => $paramValue) {
188 14
                if (preg_match($this->getSelector(), (string) $paramName)) {
189 12
                    $relevantParameters[$paramBagName][$paramName] = $paramValue;
190
                }
191
            }
192
        }
193
194 16
        return $relevantParameters;
195
    }
196
197
    /**
198
     * Returns `true` if the parameter is valid, or `false` otherwise.  Additionally, a message will be added to the
199
     * log, accessed via the envelope, if the parameter is invalid.
200
     */
201 80
    private function validateParameterValue(
202
        Envelope $envelope,
203
        string $paramBagName,
204
        string $paramName,
205
        string $paramValue
206
    ): bool {
207 80
        if (preg_match($this->getValidator(), $paramValue)) {
208 49
            return true;
209
        }
210
211 35
        $this->envelopeAddLogEntry(
212 35
            $envelope,
213 35
            "The value of `{$paramBagName}.{$paramName}` failed validation using the regex `{$this->getValidator()}`."
214 35
        );
215
216 35
        return false;
217
    }
218
219 100
    public function __invoke(Envelope $envelope): bool
220
    {
221 100
        $requiredType = $this->getOptions()['type'];
222
223 100
        $filteredParamsByBagName = $this->filterRequestParametersBySelector($envelope->getRequest());
224
225 100
        foreach ($filteredParamsByBagName as $paramBagName => $parameters) {
226 96
            foreach ($parameters as $paramName => $oneOrMoreValues) {
227 90
                if (self::TYPE_ANY !== $requiredType && $requiredType !== gettype($oneOrMoreValues)) {
228 10
                    $this->envelopeAddLogEntry(
229 10
                        $envelope,
230 10
                        "The value of `{$paramBagName}.{$paramName}` is not of type `{$requiredType}`"
231 10
                    );
232
233 10
                    return true;
234
                }
235
236 80
                foreach ((array) $oneOrMoreValues as $paramValue) {
237 80
                    if (!$this->validateParameterValue($envelope, $paramBagName, $paramName, $paramValue)) {
238 35
                        return true;
239
                    }
240
                }
241
            }
242
        }
243
244 55
        return false;
245
    }
246
}
247