Skip to content

Commit

Permalink
Sanitize by converting input values back and forth
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
otsch committed Jan 30, 2023
1 parent e2078c5 commit af52c17
Show file tree
Hide file tree
Showing 4 changed files with 460 additions and 29 deletions.
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.1] - 2023-01-29
## [1.0.1] - 2023-01-30
### Fixed
- 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`.
- 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:
- From string ` foo=bar `:
- Before: toString(): `+++foo=bar++`, toArray(): `['foo' => 'bar ']`.
- Now: toString(): `foo=bar++`, toArray(): `['foo' => 'bar ']`
- From string `foo[bar] [baz]=bar`
- Before: toString(): `foo%5Bbar%5D+%5Bbaz%5D=bar`, toArray(): `['foo' => ['bar' => 'bar']]`.
- Now: toString(): `foo%5Bbar%5D=bar`, toArray(): `'[foo' => ['bar' => 'bar']]`.
- From string `foo[bar][baz][]=bar&foo[bar][baz][]=foo`
- Before: toString(): `foo%5Bbar%5D%5Bbaz%5D%5B%5D=bar&foo%5Bbar%5D%5Bbaz%5D%5B%5D=foo`, toArray(): `['foo' => ['bar' => ['baz' => ['bar', 'foo']]]]`.
- Now: toString(): `foo%5Bbar%5D%5Bbaz%5D%5B0%5D=bar&foo%5Bbar%5D%5Bbaz%5D%5B1%5D=foo`, toArray(): `['foo' => ['bar' => ['baz' => ['bar', 'foo']]]]`.
- From string `option`
- Before: toString(): `option`, toArray(): `['option' => '']`
- Now: toString(): `option=`, toArray(): `['option' => '']`
- From string `foo=bar=bar==`
- Before: toString(): `foo=bar=bar==`, toArray(): `[['foo' => 'bar=bar==']`
- Now: toString(): `foo=bar%3Dbar%3D%3D`, toArray(): `[['foo' => 'bar=bar==']`
- From string `sum=10%5c2%3d5`
- Before: toString(): `sum=10%5c2%3d5`, toArray(): `[['sum' => '10\\2=5']`
- Now: toString(): `sum=10%5C2%3D5`, toArray(): `[['sum' => '10\\2=5']`
- From string `foo=%20+bar`
- Before: toString(): `foo=%20+bar`, toArray(): `['foo' => ' bar']`
- Now: toString(): `foo=++bar`, toArray(): `['foo' => ' bar']`
- Maintain the correct order of key value pairs when converting query string to array.
237 changes: 209 additions & 28 deletions src/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,22 @@ final class Query implements ArrayAccess, Iterator

/**
* @param string|mixed[] $query
* @throws Exception
*/
public function __construct(string|array $query)
{
if (is_string($query)) {
$this->string = $this->encode($query);
$this->string = $this->sanitizeAndEncode($query);
}

if (is_array($query)) {
$this->array = $query;
$this->array = $this->sanitizeArray($query);
}
}

/**
* @throws Exception
*/
public static function fromString(string $queryString): self
{
return new Query($queryString);
Expand All @@ -69,6 +73,7 @@ public static function fromString(string $queryString): self
/**
* @param mixed[] $queryArray
* @return static
* @throws Exception
*/
public static function fromArray(array $queryArray): self
{
Expand All @@ -83,11 +88,7 @@ public function toString(): string
if ($this->string === null || $this->isDirty) {
$array = $this->toArray();

if (!$this->boolToInt) {
$array = $this->boolsToString($array);
}

$this->string = http_build_query($array, '', $this->separator, $this->spaceCharacterEncoding);
$this->string = $this->arrayToString($array);
}

return $this->string;
Expand All @@ -96,9 +97,9 @@ public function toString(): string
/**
* @throws Exception
*/
public function toStringWithUnencodedBrackets(): string
public function toStringWithUnencodedBrackets(?string $query = null): string
{
return str_replace(['%5B', '%5D'], ['[', ']'], $this->toString());
return str_replace(['%5B', '%5D'], ['[', ']'], $query ?? $this->toString());
}

/**
Expand Down Expand Up @@ -530,6 +531,89 @@ public function rewind(): void
}
}

/**
* @throws Exception
*/
private function sanitizeAndEncode(string $query): string
{
$query = $this->sanitize($query);

return $this->encode($query);
}

/**
* @throws Exception
*/
private function sanitize(string $query): string
{
while (str_starts_with($query, '&')) {
$query = substr($query, 1);
}

while (str_ends_with($query, '&')) {
$query = substr($query, 0, strlen($query) - 1);
}

$query = preg_replace('/&+/', '&', $query) ?? $query;

return $this->arrayToString($this->stringToArray($query));
}

/**
* @param mixed[] $query
* @return mixed[]
* @throws Exception
*/
private function sanitizeArray(array $query): array
{
$originalInputQuery = $query;

$query = $this->boolValuesToStringInSanitizeArray($query);

$query = $this->stringToArray($this->arrayToString($query));

return $this->revertBoolValuesInSanitizeArray($query, $originalInputQuery);
}

/**
* @param mixed[] $query
* @return mixed[]
*/
private function boolValuesToStringInSanitizeArray(array $query): array
{
foreach ($query as $key => $value) {
if (is_array($value)) {
$query[$key] = $this->boolValuesToStringInSanitizeArray($value);
}

if (is_bool($value)) {
$query[$key] = $value ? 'true' : 'false';
}
}

return $query;
}

/**
* @param mixed[] $query
* @param mixed[] $originalQuery
* @return mixed[]
*/
private function revertBoolValuesInSanitizeArray(array $query, array $originalQuery): array
{
foreach ($query as $key => $value) {
if (is_array($value)) {
$query[$key] = $this->revertBoolValuesInSanitizeArray($value, $originalQuery[$key]);
}

if (in_array($value, ['true', 'false'], true) && is_bool($originalQuery[$key])) {
$query[$key] = $value === 'true';
}
}

return $query;
}

/**
* Correctly encode a query string
*
Expand Down Expand Up @@ -570,22 +654,77 @@ private function encodePercentCharacter(string $string): string
* @return mixed[]
* @throws Exception
*/
private function fixKeysContainingDotsOrSpaces(): array
private function fixKeysContainingDotsOrSpaces(string $query): array
{
$queryWithDotAndSpaceReplacements = $this->replaceDotsAndSpacesInKeys($this->toStringWithUnencodedBrackets());
$queryWithDotAndSpaceReplacements = $this->replaceDotsAndSpacesInKeys(
$this->toStringWithUnencodedBrackets($query)
);

parse_str($queryWithDotAndSpaceReplacements, $array);

return $this->revertDotAndSpaceReplacementsInKeys($array);
}

/**
* @param array<mixed>|Query $query
* @return array<mixed>|Query
* @throws Exception
*/
private function containsDotOrSpaceInKey(): bool
private function replaceDotsAndSpacesInArrayKeys(array|Query $query): array|Query
{
return preg_match('/(?:^|&)([^\[=&]*\.)/', $this->toStringWithUnencodedBrackets()) ||
preg_match('/(?:^|&)([^\[=&]* )/', $this->toStringWithUnencodedBrackets());
$newQuery = [];

if ($query instanceof Query) {
$newQuery = new Query($query->toArray());
}

foreach ($query as $key => $value) {
if (is_array($value) || $value instanceof Query) {
$value = $this->replaceDotsAndSpacesInArrayKeys($value);
}

$key = str_replace(
['.', ' '],
[self::TEMP_DOT_REPLACEMENT, self::TEMP_SPACE_REPLACEMENT],
$key,
);

if (is_array($newQuery)) {
$newQuery[$key] = $value;
} else {
$newQuery->set($key, $value);
}
}

return $newQuery;
}

/**
* @throws Exception
*/
private function containsDotOrSpaceInKey(string $query): bool
{
return preg_match('/(?:^|&)([^\[=&]*\.)/', $this->toStringWithUnencodedBrackets($query)) ||
preg_match('/(?:^|&)([^\[=&]* )/', $this->toStringWithUnencodedBrackets($query));
}

/**
* @param array<mixed>|Query $query
* @return bool
*/
private function arrayContainsDotOrSpacesInKey(array|Query $query): bool
{
foreach ($query as $key => $value) {
if (is_array($value) && $this->arrayContainsDotOrSpacesInKey($value)) {
return true;
}

if (str_contains($key, ' ') || str_contains($key, '.')) {
return true;
}
}

return false;
}

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

$newQueryStringArray[$fixedKey] = $value;
$newQueryStringArray[trim($fixedKey)] = $value;
} else {
$newQueryStringArray[$key] = $value;
}
Expand All @@ -624,6 +763,14 @@ private function revertDotAndSpaceReplacementsInKeys(array $queryStringArray): a
return $newQueryStringArray;
}

private function revertDotAndSpaceReplacementsInString(string $query): string
{
return str_replace([
urlencode(self::TEMP_DOT_REPLACEMENT),
urlencode(self::TEMP_SPACE_REPLACEMENT),
], ['.', $this->spaceCharacter()], $query);
}

/**
* @throws Exception
*/
Expand All @@ -641,26 +788,59 @@ private function initArray(): void
private function array(): array
{
if ($this->array === null) {
if ($this->separator !== '&') {
throw new Exception(
'Converting a query string to array with custom separator isn\'t implemented, because PHP\'s ' .
'parse_str() function doesn\'t have that functionality. If you\'d need this reach out to crwlr ' .
'on github or twitter.'
);
}
if (empty($this->string)) {
return [];
} else {
if ($this->separator !== '&') {
throw new Exception(
'Converting a query string to array with custom separator isn\'t implemented, because PHP\'s ' .
'parse_str() function doesn\'t have that functionality. If you\'d need this, reach out to crwlr ' .
'on github or twitter.'
);
}

if ($this->containsDotOrSpaceInKey()) {
return $this->fixKeysContainingDotsOrSpaces();
$this->array = $this->stringToArray($this->string);
}
}

parse_str($this->string ?? '', $array);
return $this->array;
}

$this->array = $array;
/**
* @return mixed[]
* @throws Exception
*/
private function stringToArray(string $query): array
{
$query = str_replace($this->spaceCharacter(), ' ', $query);

return $array;
if ($this->containsDotOrSpaceInKey($query)) {
return $this->fixKeysContainingDotsOrSpaces($query);
}

return $this->array;
parse_str($query, $array);

return $array;
}

/**
* @param mixed[] $query
* @return string
* @throws Exception
*/
private function arrayToString(array $query): string
{
if (!$this->boolToInt) {
$query = $this->boolsToString($query);
}

if ($this->arrayContainsDotOrSpacesInKey($query)) {
$query = $this->replaceDotsAndSpacesInArrayKeys($query);
}

$string = http_build_query($query, '', $this->separator, $this->spaceCharacterEncoding);

return $this->revertDotAndSpaceReplacementsInString($string);
}

/**
Expand Down Expand Up @@ -709,6 +889,7 @@ private function firstOrLast(?string $key = null, bool $first = true): mixed
/**
* @param string|mixed[] $query
* @return $this
* @throws Exception
*/
private function newWithSameSettings(string|array $query): self
{
Expand Down
Loading

0 comments on commit af52c17

Please sign in to comment.