diff --git a/src/DataCollection/KeyValueDataFilter.php b/src/DataCollection/KeyValueDataFilter.php new file mode 100644 index 000000000..c90c6d5ab --- /dev/null +++ b/src/DataCollection/KeyValueDataFilter.php @@ -0,0 +1,171 @@ + $headers + * + * @phpstan-param KeyValueCollectionBehavior $behavior + * + * @return array|null Returns null when collection is off + */ + public static function filterHeaders(array $headers, array $behavior): ?array + { + if ($behavior['mode'] === 'off') { + return null; + } + + $filtered = []; + + foreach ($headers as $name => $values) { + $name = (string) $name; + + if (\in_array(strtolower($name), self::SENSITIVE_HEADERS, true) || self::shouldFilterValue($name, $behavior)) { + foreach ($values as $headerLine => $headerValue) { + $values[$headerLine] = '[Filtered]'; + } + } + + $filtered[$name] = $values; + } + + return $filtered; + } + + /** + * @param array $data + * + * @phpstan-param KeyValueCollectionBehavior $behavior + * + * @return array|null Returns null when collection is off + */ + public static function filterKeyValueData(array $data, array $behavior): ?array + { + if ($behavior['mode'] === 'off') { + return null; + } + + $filtered = []; + + /** @mago-ignore analysis:mixed-assignment */ + foreach ($data as $key => $value) { + $key = (string) $key; + $filtered[$key] = self::shouldFilterValue($key, $behavior) ? '[Filtered]' : $value; + } + + return $filtered; + } + + /** + * @phpstan-param KeyValueCollectionBehavior $behavior + */ + public static function filterQueryString(string $queryString, array $behavior): ?string + { + if ($behavior['mode'] === 'off') { + return null; + } + + $parts = explode('&', $queryString); + + foreach ($parts as $index => $part) { + $separatorPosition = strpos($part, '='); + $encodedKey = $separatorPosition === false ? $part : substr($part, 0, $separatorPosition); + $key = urldecode($encodedKey); + + if (self::shouldFilterValue($key, $behavior)) { + $parts[$index] = $encodedKey . '=[Filtered]'; + } + } + + return implode('&', $parts); + } + + /** + * @phpstan-param KeyValueCollectionBehavior $behavior + */ + private static function shouldFilterValue(string $key, array $behavior): bool + { + if (self::matchesMandatoryDenyList($key)) { + return true; + } + + if ($behavior['mode'] === 'allowList') { + return !self::matchesAnyTerm($key, $behavior['terms']); + } + + return self::matchesAnyTerm($key, $behavior['terms']); + } + + private static function matchesMandatoryDenyList(string $key): bool + { + if (self::$sensitiveDataDenyListRegex === null) { + self::$sensitiveDataDenyListRegex = '/' . implode('|', array_map(static function (string $term): string { + return preg_quote($term, '/'); + }, self::SENSITIVE_DATA_DENYLIST)) . '/i'; + } + + return preg_match(self::$sensitiveDataDenyListRegex, $key) === 1; + } + + /** + * @param string[] $terms + */ + private static function matchesAnyTerm(string $key, array $terms): bool + { + $key = strtolower($key); + + foreach ($terms as $term) { + if (strpos($key, strtolower($term)) !== false) { + return true; + } + } + + return false; + } +} diff --git a/tests/DataCollection/KeyValueDataFilterTest.php b/tests/DataCollection/KeyValueDataFilterTest.php new file mode 100644 index 000000000..2662102cb --- /dev/null +++ b/tests/DataCollection/KeyValueDataFilterTest.php @@ -0,0 +1,186 @@ + 'off', 'terms' => ['public']]; + + $this->assertNull(KeyValueDataFilter::filterKeyValueData([ + 'authorization' => 'secret', + 'public' => 'visible', + ], $behavior)); + } + + public function testFilterKeyValueDataAppliesMandatoryDenyList(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $filtered = KeyValueDataFilter::filterKeyValueData([ + 'AUTHORIZATION' => 'secret', + 'public' => 'visible', + ], $behavior); + + $this->assertSame([ + 'AUTHORIZATION' => '[Filtered]', + 'public' => 'visible', + ], $filtered); + } + + public function testFilterKeyValueDataCombinesMandatoryAndCustomDenyListTerms(): void + { + $behavior = ['mode' => 'denyList', 'terms' => ['custom']]; + + $filtered = KeyValueDataFilter::filterKeyValueData([ + 'authorization' => 'secret', + 'custom-field' => 'private', + 'public' => 'visible', + ], $behavior); + + $this->assertSame([ + 'authorization' => '[Filtered]', + 'custom-field' => '[Filtered]', + 'public' => 'visible', + ], $filtered); + } + + public function testFilterKeyValueDataAppliesAllowList(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['theme']]; + + $filtered = KeyValueDataFilter::filterKeyValueData([ + 'preferred-theme' => 'dark', + 'tracking_id' => '12345', + ], $behavior); + + $this->assertSame([ + 'preferred-theme' => 'dark', + 'tracking_id' => '[Filtered]', + ], $filtered); + } + + public function testFilterKeyValueDataAllowListCannotOverrideMandatoryDenyList(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['authorization']]; + + $filtered = KeyValueDataFilter::filterKeyValueData([ + 'authorization' => 'secret', + ], $behavior); + + $this->assertSame(['authorization' => '[Filtered]'], $filtered); + } + + public function testFilterHeadersReturnsNullWhenCollectionIsOff(): void + { + $behavior = ['mode' => 'off', 'terms' => ['x-request-id']]; + + $this->assertNull(KeyValueDataFilter::filterHeaders([ + 'Authorization' => ['secret'], + 'X-Request-Id' => ['request-id'], + ], $behavior)); + } + + public function testFilterHeadersAppliesDenyListToEveryHeaderLine(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $filtered = KeyValueDataFilter::filterHeaders([ + 'X-Api-Key' => ['first', 'second'], + 'X-Request-Id' => ['request-id'], + ], $behavior); + + $this->assertSame([ + 'X-Api-Key' => ['[Filtered]', '[Filtered]'], + 'X-Request-Id' => ['request-id'], + ], $filtered); + } + + public function testFilterHeadersAlwaysFiltersCookieHeaders(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['cookie', 'set-cookie', 'x-request-id']]; + + $filtered = KeyValueDataFilter::filterHeaders([ + 'Cookie' => ['session_id=secret; theme=dark'], + 'Set-Cookie' => ['session_id=secret'], + 'X-Request-Id' => ['request-id'], + ], $behavior); + + $this->assertSame([ + 'Cookie' => ['[Filtered]'], + 'Set-Cookie' => ['[Filtered]'], + 'X-Request-Id' => ['request-id'], + ], $filtered); + } + + public function testFilterHeadersAppliesExtendedDenyTerms(): void + { + $defaultBehavior = ['mode' => 'denyList', 'terms' => []]; + $extendedBehavior = ['mode' => 'denyList', 'terms' => ['forwarded', '-ip', 'remote-', 'via', '-user']]; + $headers = [ + 'X-Forwarded-For' => ['203.0.113.7'], + 'X-Real-IP' => ['203.0.113.7'], + ]; + + $this->assertSame($headers, KeyValueDataFilter::filterHeaders($headers, $defaultBehavior)); + $this->assertSame([ + 'X-Forwarded-For' => ['[Filtered]'], + 'X-Real-IP' => ['[Filtered]'], + ], KeyValueDataFilter::filterHeaders($headers, $extendedBehavior)); + } + + public function testFilterHeadersAppliesAllowList(): void + { + $behavior = ['mode' => 'allowList', 'terms' => ['request-id']]; + + $filtered = KeyValueDataFilter::filterHeaders([ + 'X-Request-Id' => ['request-id'], + 'Host' => ['example.com'], + ], $behavior); + + $this->assertSame([ + 'X-Request-Id' => ['request-id'], + 'Host' => ['[Filtered]'], + ], $filtered); + } + + public function testFilterQueryStringReturnsNullWhenCollectionIsOff(): void + { + $behavior = ['mode' => 'off', 'terms' => ['page']]; + + $this->assertNull(KeyValueDataFilter::filterQueryString('token=secret&page=1', $behavior)); + } + + public function testFilterQueryStringAppliesMandatoryAndCustomDenyListTerms(): void + { + $behavior = ['mode' => 'denyList', 'terms' => ['page']]; + + $filtered = KeyValueDataFilter::filterQueryString('token=secret&page=1&flag', $behavior); + + $this->assertSame('token=[Filtered]&page=[Filtered]&flag', $filtered); + } + + public function testFilterQueryStringDecodesKeysBeforeMatching(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $filtered = KeyValueDataFilter::filterQueryString('api%5Ftoken=secret&page=1', $behavior); + + $this->assertSame('api%5Ftoken=[Filtered]&page=1', $filtered); + } + + public function testFilterQueryStringDoesNotTreatCookieNamesAsCookieHeaders(): void + { + $behavior = ['mode' => 'denyList', 'terms' => []]; + + $filtered = KeyValueDataFilter::filterQueryString('cookie=foo&set-cookie=bar', $behavior); + + $this->assertSame('cookie=foo&set-cookie=bar', $filtered); + } +}