From 1e44fd2e276bcbf7a9104adc6e098fe65357cdc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sun, 27 Sep 2026 00:50:02 +0200 Subject: [PATCH] Redact URL query secrets and complete authorization tokens --- src/Transport/Sanitizer.php | 30 +++++++-- tests/Unit/Transport/ErrorMapperTest.php | 31 ++++++++++ tests/Unit/Transport/SanitizerTest.php | 77 ++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/Transport/Sanitizer.php b/src/Transport/Sanitizer.php index 14322d1..90035b8 100644 --- a/src/Transport/Sanitizer.php +++ b/src/Transport/Sanitizer.php @@ -118,7 +118,11 @@ private static function sanitizeObject(object $object): object */ private static function sanitizeString(string $string): string { + $string = self::sanitizeQueryParameters($string); + $patterns = [ + '/\b(Basic|Bearer) +[A-Za-z0-9._~+\/-]+=*/i' => '$1 [REDACTED]', + '/\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/' => '[JWT_TOKEN]', '/\b[a-z0-9]{32,}\b/i' => '[API_KEY]', @@ -126,10 +130,6 @@ private static function sanitizeString(string $string): string '/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i' => '[UUID]', '/:([^:@]+)@/' => ':[REDACTED]@', - - '/Basic [A-Za-z0-9+\/]+=*/' => 'Basic [REDACTED]', - - '/Bearer [A-Za-z0-9_\-\.]+/' => 'Bearer [REDACTED]', ]; foreach ($patterns as $pattern => $replacement) { @@ -143,6 +143,27 @@ private static function sanitizeString(string $string): string return $string; } + private static function sanitizeQueryParameters(string $string): string + { + $result = preg_replace_callback( + '/([?&])([^=&#\s"\'<>]+)=([^&#\s"\'<>]*)/', + static function (array $matches): string { + if (!self::isSensitiveKey(urldecode($matches[2]))) { + return $matches[0]; + } + + return $matches[1].$matches[2].'='.self::REPLACEMENT; + }, + $string + ); + + if (null === $result) { + throw new RuntimeException('preg_replace_callback failed'); + } + + return $result; + } + /** * Check if a key name suggests it contains sensitive data. */ @@ -164,6 +185,7 @@ private static function isSensitiveKey(string $key): bool */ public static function sanitizeUrl(string $url): string { + $url = self::sanitizeQueryParameters($url); $parsed = parse_url($url); if (false === $parsed) { diff --git a/tests/Unit/Transport/ErrorMapperTest.php b/tests/Unit/Transport/ErrorMapperTest.php index 7b9d8f3..89dc168 100644 --- a/tests/Unit/Transport/ErrorMapperTest.php +++ b/tests/Unit/Transport/ErrorMapperTest.php @@ -15,6 +15,7 @@ namespace Playwright\Tests\Unit\Transport; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Playwright\Exception\DisconnectedException; @@ -166,6 +167,36 @@ public function itSanitizesSensitiveParams(): void $this->assertEquals('data', $sanitizedParams['normal']); } + #[Test] + #[DataProvider('provideErrorNames')] + public function itRedactsSecretsInsideDiagnosticParameterStrings(string $errorName): void + { + $params = [ + 'url' => 'https://example.test/?access_token=short-secret&lang=en', + 'nested' => ['text' => 'Bearer demo+secret/token=='], + 'headers' => ['Authorization' => 'Bearer demo+secret/token=='], + ]; + + $exception = ErrorMapper::toException(['name' => $errorName, 'message' => 'Failed'], 'api.fetch', $params, null); + + $this->assertSame([ + 'url' => 'https://example.test/?access_token=[REDACTED]&lang=en', + 'nested' => ['text' => 'Bearer [REDACTED]'], + 'headers' => ['Authorization' => '[REDACTED]'], + ], $exception->getContext()['params']); + $this->assertSame('https://example.test/?access_token=short-secret&lang=en', $params['url']); + } + + /** + * @return iterable + */ + public static function provideErrorNames(): iterable + { + yield 'protocol' => ['Error']; + yield 'timeout' => ['TimeoutError']; + yield 'disconnection' => ['TargetClosedError']; + } + #[Test] public function itHandlesNumericCodes(): void { diff --git a/tests/Unit/Transport/SanitizerTest.php b/tests/Unit/Transport/SanitizerTest.php index 992086f..90d4212 100644 --- a/tests/Unit/Transport/SanitizerTest.php +++ b/tests/Unit/Transport/SanitizerTest.php @@ -15,6 +15,7 @@ namespace Playwright\Tests\Unit\Transport; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Playwright\Transport\Sanitizer; @@ -165,6 +166,82 @@ public function itSanitizesCommonPatterns(): void $this->assertStringContainsString('Basic [REDACTED]', $result); } + #[Test] + #[DataProvider('provideAuthorizationStrings')] + public function itMasksCompleteAuthorizationCredentials(string $input, string $expected): void + { + $this->assertSame($expected, Sanitizer::sanitizeParams($input)); + } + + /** + * @return iterable + */ + public static function provideAuthorizationStrings(): iterable + { + yield 'base64 characters' => ['Bearer demo+secret/token==', 'Bearer [REDACTED]']; + yield 'leading special character' => ['Bearer +secret/token', 'Bearer [REDACTED]']; + yield 'tilde' => ['Bearer demo~secret', 'Bearer [REDACTED]']; + yield 'JWT shape' => ['Bearer first.second.third', 'Bearer [REDACTED]']; + yield 'long token' => ['Bearer '.str_repeat('a', 40).'+secret', 'Bearer [REDACTED]']; + yield 'lowercase scheme' => ['bearer demo+secret/token==', 'bearer [REDACTED]']; + yield 'mixed case scheme' => ['bEaReR demo~secret', 'bEaReR [REDACTED]']; + yield 'multiple spaces' => ['Bearer demo+secret/token==', 'Bearer [REDACTED]']; + yield 'surrounding text' => ['Header: Bearer demo+secret/token==, request failed.', 'Header: Bearer [REDACTED], request failed.']; + yield 'long basic credential' => ['Basic '.str_repeat('a', 40).'==', 'Basic [REDACTED]']; + } + + #[Test] + #[DataProvider('provideUrlsWithQueryParameters')] + public function itMasksSensitiveQueryParameters(string $input, string $expected): void + { + $this->assertSame($expected, Sanitizer::sanitizeParams($input)); + $this->assertSame($expected, Sanitizer::sanitizeUrl($input)); + } + + /** + * @return iterable + */ + public static function provideUrlsWithQueryParameters(): iterable + { + yield 'short token' => ['https://example.test/?access_token=short-secret', 'https://example.test/?access_token=[REDACTED]']; + yield 'repeated keys and public values' => ['https://example.test/?q=hello%20world&token=one&token=two&sort=a+b', 'https://example.test/?q=hello%20world&token=[REDACTED]&token=[REDACTED]&sort=a+b']; + yield 'encoded key and value' => ['https://example.test/?%61ccess%5Ftoken=a%26b%3Dc', 'https://example.test/?%61ccess%5Ftoken=[REDACTED]']; + yield 'case insensitive key' => ['https://example.test/?API_KEY=short', 'https://example.test/?API_KEY=[REDACTED]']; + yield 'nested parameter' => ['https://example.test/?credentials%5Bpassword%5D=short', 'https://example.test/?credentials%5Bpassword%5D=[REDACTED]']; + yield 'fragment preserved' => ['https://example.test/?token=short#section', 'https://example.test/?token=[REDACTED]#section']; + yield 'empty secret' => ['https://example.test/?secret=&page=2', 'https://example.test/?secret=[REDACTED]&page=2']; + yield 'padding' => ['https://example.test/?token=demo+secret/token==', 'https://example.test/?token=[REDACTED]']; + yield 'relative URL' => ['/path?session_id=short&lang=en', '/path?session_id=[REDACTED]&lang=en']; + yield 'public query unchanged' => ['https://example.test/?q=a%2Fb&page=1&page=2&empty=#section', 'https://example.test/?q=a%2Fb&page=1&page=2&empty=#section']; + } + + #[Test] + public function itMasksQueryParametersInDiagnosticText(): void + { + $this->assertSame( + 'Failed "https://example.test/?token=[REDACTED]"; retry /path?api_key=[REDACTED] later.', + Sanitizer::sanitizeParams('Failed "https://example.test/?token=short"; retry /path?api_key=other later.') + ); + } + + #[Test] + public function itMasksUrlCredentialsAndQueryParametersTogether(): void + { + $this->assertSame( + 'https://[REDACTED]@example.test:8080/path?password=[REDACTED]&page=2#section', + Sanitizer::sanitizeUrl('https://user:pass@example.test:8080/path?password=short&page=2#section') + ); + } + + #[Test] + public function itMasksQueryParametersEvenWhenTheUrlCannotBeParsed(): void + { + $this->assertSame( + 'https://example.test:invalid/?token=[REDACTED]', + Sanitizer::sanitizeUrl('https://example.test:invalid/?token=short') + ); + } + #[Test] public function itPreservesNormalData(): void {