Skip to content

Commit af52c17

Browse files
committed
Sanitize by converting input values back and forth
To assure that there can't be differences in the array and string versions returned by the `Query` class, no matter if the instance was created from string or array, the library now first converts incoming values back and forth. So, when an instance is created from string, it first converts it to an array and then again back to a string and vice versa when an instance is created from an array.
1 parent e2078c5 commit af52c17

File tree

4 files changed

+460
-29
lines changed

4 files changed

+460
-29
lines changed

CHANGELOG.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
## [1.0.1] - 2023-01-29
10+
## [1.0.1] - 2023-01-30
1111
### Fixed
12+
- When creating a `Query` from string, and it contains empty key value parts, like `&foo=bar`, `foo=bar&` or `foo=bar&&baz=quz`, the unnecessary `&` characters are removed in the string version now. For example, `&foo=bar` previously lead to `$instance->toString()` returning `&foo=bar` and now it returns `foo=bar`.
13+
- To assure that there can't be differences in the array and string versions returned by the `Query` class, no matter if the instance was created from string or array, the library now first converts incoming values back and forth. So, when an instance is created from string, it first converts it to an array and then again back to a string and vice versa when an instance is created from an array. Some Examples being fixed by this:
14+
- From string ` foo=bar `:
15+
- Before: toString(): `+++foo=bar++`, toArray(): `['foo' => 'bar ']`.
16+
- Now: toString(): `foo=bar++`, toArray(): `['foo' => 'bar ']`
17+
- From string `foo[bar] [baz]=bar`
18+
- Before: toString(): `foo%5Bbar%5D+%5Bbaz%5D=bar`, toArray(): `['foo' => ['bar' => 'bar']]`.
19+
- Now: toString(): `foo%5Bbar%5D=bar`, toArray(): `'[foo' => ['bar' => 'bar']]`.
20+
- From string `foo[bar][baz][]=bar&foo[bar][baz][]=foo`
21+
- Before: toString(): `foo%5Bbar%5D%5Bbaz%5D%5B%5D=bar&foo%5Bbar%5D%5Bbaz%5D%5B%5D=foo`, toArray(): `['foo' => ['bar' => ['baz' => ['bar', 'foo']]]]`.
22+
- Now: toString(): `foo%5Bbar%5D%5Bbaz%5D%5B0%5D=bar&foo%5Bbar%5D%5Bbaz%5D%5B1%5D=foo`, toArray(): `['foo' => ['bar' => ['baz' => ['bar', 'foo']]]]`.
23+
- From string `option`
24+
- Before: toString(): `option`, toArray(): `['option' => '']`
25+
- Now: toString(): `option=`, toArray(): `['option' => '']`
26+
- From string `foo=bar=bar==`
27+
- Before: toString(): `foo=bar=bar==`, toArray(): `[['foo' => 'bar=bar==']`
28+
- Now: toString(): `foo=bar%3Dbar%3D%3D`, toArray(): `[['foo' => 'bar=bar==']`
29+
- From string `sum=10%5c2%3d5`
30+
- Before: toString(): `sum=10%5c2%3d5`, toArray(): `[['sum' => '10\\2=5']`
31+
- Now: toString(): `sum=10%5C2%3D5`, toArray(): `[['sum' => '10\\2=5']`
32+
- From string `foo=%20+bar`
33+
- Before: toString(): `foo=%20+bar`, toArray(): `['foo' => ' bar']`
34+
- Now: toString(): `foo=++bar`, toArray(): `['foo' => ' bar']`
1235
- Maintain the correct order of key value pairs when converting query string to array.

src/Query.php

Lines changed: 209 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,22 @@ final class Query implements ArrayAccess, Iterator
4949

5050
/**
5151
* @param string|mixed[] $query
52+
* @throws Exception
5253
*/
5354
public function __construct(string|array $query)
5455
{
5556
if (is_string($query)) {
56-
$this->string = $this->encode($query);
57+
$this->string = $this->sanitizeAndEncode($query);
5758
}
5859

5960
if (is_array($query)) {
60-
$this->array = $query;
61+
$this->array = $this->sanitizeArray($query);
6162
}
6263
}
6364

65+
/**
66+
* @throws Exception
67+
*/
6468
public static function fromString(string $queryString): self
6569
{
6670
return new Query($queryString);
@@ -69,6 +73,7 @@ public static function fromString(string $queryString): self
6973
/**
7074
* @param mixed[] $queryArray
7175
* @return static
76+
* @throws Exception
7277
*/
7378
public static function fromArray(array $queryArray): self
7479
{
@@ -83,11 +88,7 @@ public function toString(): string
8388
if ($this->string === null || $this->isDirty) {
8489
$array = $this->toArray();
8590

86-
if (!$this->boolToInt) {
87-
$array = $this->boolsToString($array);
88-
}
89-
90-
$this->string = http_build_query($array, '', $this->separator, $this->spaceCharacterEncoding);
91+
$this->string = $this->arrayToString($array);
9192
}
9293

9394
return $this->string;
@@ -96,9 +97,9 @@ public function toString(): string
9697
/**
9798
* @throws Exception
9899
*/
99-
public function toStringWithUnencodedBrackets(): string
100+
public function toStringWithUnencodedBrackets(?string $query = null): string
100101
{
101-
return str_replace(['%5B', '%5D'], ['[', ']'], $this->toString());
102+
return str_replace(['%5B', '%5D'], ['[', ']'], $query ?? $this->toString());
102103
}
103104

104105
/**
@@ -530,6 +531,89 @@ public function rewind(): void
530531
}
531532
}
532533

534+
/**
535+
* @throws Exception
536+
*/
537+
private function sanitizeAndEncode(string $query): string
538+
{
539+
$query = $this->sanitize($query);
540+
541+
return $this->encode($query);
542+
}
543+
544+
/**
545+
* @throws Exception
546+
*/
547+
private function sanitize(string $query): string
548+
{
549+
while (str_starts_with($query, '&')) {
550+
$query = substr($query, 1);
551+
}
552+
553+
while (str_ends_with($query, '&')) {
554+
$query = substr($query, 0, strlen($query) - 1);
555+
}
556+
557+
$query = preg_replace('/&+/', '&', $query) ?? $query;
558+
559+
return $this->arrayToString($this->stringToArray($query));
560+
}
561+
562+
/**
563+
* @param mixed[] $query
564+
* @return mixed[]
565+
* @throws Exception
566+
*/
567+
private function sanitizeArray(array $query): array
568+
{
569+
$originalInputQuery = $query;
570+
571+
$query = $this->boolValuesToStringInSanitizeArray($query);
572+
573+
$query = $this->stringToArray($this->arrayToString($query));
574+
575+
return $this->revertBoolValuesInSanitizeArray($query, $originalInputQuery);
576+
}
577+
578+
/**
579+
* @param mixed[] $query
580+
* @return mixed[]
581+
*/
582+
private function boolValuesToStringInSanitizeArray(array $query): array
583+
{
584+
foreach ($query as $key => $value) {
585+
if (is_array($value)) {
586+
$query[$key] = $this->boolValuesToStringInSanitizeArray($value);
587+
}
588+
589+
if (is_bool($value)) {
590+
$query[$key] = $value ? 'true' : 'false';
591+
}
592+
}
593+
594+
return $query;
595+
}
596+
597+
/**
598+
* @param mixed[] $query
599+
* @param mixed[] $originalQuery
600+
* @return mixed[]
601+
*/
602+
private function revertBoolValuesInSanitizeArray(array $query, array $originalQuery): array
603+
{
604+
foreach ($query as $key => $value) {
605+
if (is_array($value)) {
606+
$query[$key] = $this->revertBoolValuesInSanitizeArray($value, $originalQuery[$key]);
607+
}
608+
609+
if (in_array($value, ['true', 'false'], true) && is_bool($originalQuery[$key])) {
610+
$query[$key] = $value === 'true';
611+
}
612+
}
613+
614+
return $query;
615+
}
616+
533617
/**
534618
* Correctly encode a query string
535619
*
@@ -570,22 +654,77 @@ private function encodePercentCharacter(string $string): string
570654
* @return mixed[]
571655
* @throws Exception
572656
*/
573-
private function fixKeysContainingDotsOrSpaces(): array
657+
private function fixKeysContainingDotsOrSpaces(string $query): array
574658
{
575-
$queryWithDotAndSpaceReplacements = $this->replaceDotsAndSpacesInKeys($this->toStringWithUnencodedBrackets());
659+
$queryWithDotAndSpaceReplacements = $this->replaceDotsAndSpacesInKeys(
660+
$this->toStringWithUnencodedBrackets($query)
661+
);
576662

577663
parse_str($queryWithDotAndSpaceReplacements, $array);
578664

579665
return $this->revertDotAndSpaceReplacementsInKeys($array);
580666
}
581667

582668
/**
669+
* @param array<mixed>|Query $query
670+
* @return array<mixed>|Query
583671
* @throws Exception
584672
*/
585-
private function containsDotOrSpaceInKey(): bool
673+
private function replaceDotsAndSpacesInArrayKeys(array|Query $query): array|Query
586674
{
587-
return preg_match('/(?:^|&)([^\[=&]*\.)/', $this->toStringWithUnencodedBrackets()) ||
588-
preg_match('/(?:^|&)([^\[=&]* )/', $this->toStringWithUnencodedBrackets());
675+
$newQuery = [];
676+
677+
if ($query instanceof Query) {
678+
$newQuery = new Query($query->toArray());
679+
}
680+
681+
foreach ($query as $key => $value) {
682+
if (is_array($value) || $value instanceof Query) {
683+
$value = $this->replaceDotsAndSpacesInArrayKeys($value);
684+
}
685+
686+
$key = str_replace(
687+
['.', ' '],
688+
[self::TEMP_DOT_REPLACEMENT, self::TEMP_SPACE_REPLACEMENT],
689+
$key,
690+
);
691+
692+
if (is_array($newQuery)) {
693+
$newQuery[$key] = $value;
694+
} else {
695+
$newQuery->set($key, $value);
696+
}
697+
}
698+
699+
return $newQuery;
700+
}
701+
702+
/**
703+
* @throws Exception
704+
*/
705+
private function containsDotOrSpaceInKey(string $query): bool
706+
{
707+
return preg_match('/(?:^|&)([^\[=&]*\.)/', $this->toStringWithUnencodedBrackets($query)) ||
708+
preg_match('/(?:^|&)([^\[=&]* )/', $this->toStringWithUnencodedBrackets($query));
709+
}
710+
711+
/**
712+
* @param array<mixed>|Query $query
713+
* @return bool
714+
*/
715+
private function arrayContainsDotOrSpacesInKey(array|Query $query): bool
716+
{
717+
foreach ($query as $key => $value) {
718+
if (is_array($value) && $this->arrayContainsDotOrSpacesInKey($value)) {
719+
return true;
720+
}
721+
722+
if (str_contains($key, ' ') || str_contains($key, '.')) {
723+
return true;
724+
}
725+
}
726+
727+
return false;
589728
}
590729

591730
private function replaceDotsAndSpacesInKeys(string $queryString): string
@@ -615,7 +754,7 @@ private function revertDotAndSpaceReplacementsInKeys(array $queryStringArray): a
615754
if (str_contains($key, self::TEMP_DOT_REPLACEMENT) || str_contains($key, self::TEMP_SPACE_REPLACEMENT)) {
616755
$fixedKey = str_replace([self::TEMP_DOT_REPLACEMENT, self::TEMP_SPACE_REPLACEMENT], ['.', ' '], $key);
617756

618-
$newQueryStringArray[$fixedKey] = $value;
757+
$newQueryStringArray[trim($fixedKey)] = $value;
619758
} else {
620759
$newQueryStringArray[$key] = $value;
621760
}
@@ -624,6 +763,14 @@ private function revertDotAndSpaceReplacementsInKeys(array $queryStringArray): a
624763
return $newQueryStringArray;
625764
}
626765

766+
private function revertDotAndSpaceReplacementsInString(string $query): string
767+
{
768+
return str_replace([
769+
urlencode(self::TEMP_DOT_REPLACEMENT),
770+
urlencode(self::TEMP_SPACE_REPLACEMENT),
771+
], ['.', $this->spaceCharacter()], $query);
772+
}
773+
627774
/**
628775
* @throws Exception
629776
*/
@@ -641,26 +788,59 @@ private function initArray(): void
641788
private function array(): array
642789
{
643790
if ($this->array === null) {
644-
if ($this->separator !== '&') {
645-
throw new Exception(
646-
'Converting a query string to array with custom separator isn\'t implemented, because PHP\'s ' .
647-
'parse_str() function doesn\'t have that functionality. If you\'d need this reach out to crwlr ' .
648-
'on github or twitter.'
649-
);
650-
}
791+
if (empty($this->string)) {
792+
return [];
793+
} else {
794+
if ($this->separator !== '&') {
795+
throw new Exception(
796+
'Converting a query string to array with custom separator isn\'t implemented, because PHP\'s ' .
797+
'parse_str() function doesn\'t have that functionality. If you\'d need this, reach out to crwlr ' .
798+
'on github or twitter.'
799+
);
800+
}
651801

652-
if ($this->containsDotOrSpaceInKey()) {
653-
return $this->fixKeysContainingDotsOrSpaces();
802+
$this->array = $this->stringToArray($this->string);
654803
}
804+
}
655805

656-
parse_str($this->string ?? '', $array);
806+
return $this->array;
807+
}
657808

658-
$this->array = $array;
809+
/**
810+
* @return mixed[]
811+
* @throws Exception
812+
*/
813+
private function stringToArray(string $query): array
814+
{
815+
$query = str_replace($this->spaceCharacter(), ' ', $query);
659816

660-
return $array;
817+
if ($this->containsDotOrSpaceInKey($query)) {
818+
return $this->fixKeysContainingDotsOrSpaces($query);
661819
}
662820

663-
return $this->array;
821+
parse_str($query, $array);
822+
823+
return $array;
824+
}
825+
826+
/**
827+
* @param mixed[] $query
828+
* @return string
829+
* @throws Exception
830+
*/
831+
private function arrayToString(array $query): string
832+
{
833+
if (!$this->boolToInt) {
834+
$query = $this->boolsToString($query);
835+
}
836+
837+
if ($this->arrayContainsDotOrSpacesInKey($query)) {
838+
$query = $this->replaceDotsAndSpacesInArrayKeys($query);
839+
}
840+
841+
$string = http_build_query($query, '', $this->separator, $this->spaceCharacterEncoding);
842+
843+
return $this->revertDotAndSpaceReplacementsInString($string);
664844
}
665845

666846
/**
@@ -709,6 +889,7 @@ private function firstOrLast(?string $key = null, bool $first = true): mixed
709889
/**
710890
* @param string|mixed[] $query
711891
* @return $this
892+
* @throws Exception
712893
*/
713894
private function newWithSameSettings(string|array $query): self
714895
{

0 commit comments

Comments
 (0)