Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions src/Transport/Sanitizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,18 +118,18 @@ 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]',

'/\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) {
Expand All @@ -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.
*/
Expand All @@ -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) {
Expand Down
31 changes: 31 additions & 0 deletions tests/Unit/Transport/ErrorMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{string}>
*/
public static function provideErrorNames(): iterable
{
yield 'protocol' => ['Error'];
yield 'timeout' => ['TimeoutError'];
yield 'disconnection' => ['TargetClosedError'];
}

#[Test]
public function itHandlesNumericCodes(): void
{
Expand Down
77 changes: 77 additions & 0 deletions tests/Unit/Transport/SanitizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{string, string}>
*/
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<string, array{string, string}>
*/
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
{
Expand Down
Loading