diff --git a/config/nightwatch.php b/config/nightwatch.php index 0a48a8358..60d10a8be 100644 --- a/config/nightwatch.php +++ b/config/nightwatch.php @@ -6,6 +6,7 @@ 'deployment' => env('NIGHTWATCH_DEPLOY'), 'server' => env('NIGHTWATCH_SERVER', (string) gethostname()), 'capture_exception_source_code' => env('NIGHTWATCH_CAPTURE_EXCEPTION_SOURCE_CODE', true), + 'redact_headers' => explode(',', env('NIGHTWATCH_REDACT_HEADERS', 'Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN')), 'sampling' => [ 'requests' => env('NIGHTWATCH_REQUEST_SAMPLE_RATE', 1.0), diff --git a/src/Concerns/RedactsHeaders.php b/src/Concerns/RedactsHeaders.php new file mode 100644 index 000000000..048adc508 --- /dev/null +++ b/src/Concerns/RedactsHeaders.php @@ -0,0 +1,91 @@ + $keys + */ + private function redactHeaders(HeaderBag $headers, array $keys = []): HeaderBag + { + foreach ($keys as $key) { + if (! $headers->has($key)) { + continue; + } + + $headers->set($key, array_map(fn ($value) => match (strtolower($key)) { + 'authorization', 'proxy-authorization' => $this->redactAuthorizationHeaderValue((string) $value), // @phpstan-ignore cast.string + 'cookie' => $this->redactCookieHeaderValue((string) $value), // @phpstan-ignore cast.string + default => $this->redactHeaderValue((string) $value), // @phpstan-ignore cast.string + }, $headers->all($key))); + } + + return $headers; + } + + private function redactHeaderValue(string $value): string + { + return '['.strlen($value).' bytes redacted]'; + } + + private function redactAuthorizationHeaderValue(string $value): string + { + if (strpos($value, ' ') === false) { + return $this->redactHeaderValue($value); + } + + [$type, $remainder] = explode(' ', $value, 2); + + if (in_array(strtolower($type), [ + 'basic', + 'bearer', + 'concealed', + 'digest', + 'dpop', + 'gnap', + 'hoba', + 'mutual', + 'negotiate', + 'oauth', + 'privatetoken', + 'scram-sha-1', + 'scram-sha-256', + 'vapid', + ], true)) { + return $type.' '.$this->redactHeaderValue($remainder); + } + + return $this->redactHeaderValue($value); + } + + private function redactCookieHeaderValue(string $value): string + { + $cookies = explode(';', $value); + + try { + return implode('; ', array_map(function ($cookie) { + [$name, $value] = explode('=', $cookie, 2); + + return trim($name).'='.$this->redactHeaderValue($value); + }, $cookies)); + } catch (Throwable) { + return $this->redactHeaderValue($value); + } + } +} diff --git a/src/NightwatchServiceProvider.php b/src/NightwatchServiceProvider.php index 98cea8c3e..c40c38ec4 100644 --- a/src/NightwatchServiceProvider.php +++ b/src/NightwatchServiceProvider.php @@ -121,6 +121,7 @@ final class NightwatchServiceProvider extends ServiceProvider * server?: string, * ingest?: array{ uri?: string, timeout?: float|int, connection_timeout?: float|int, event_buffer?: int }, * capture_exception_source_code?: bool, + * redact_headers?: string[], * } */ private array $nightwatchConfig; @@ -251,6 +252,7 @@ private function buildAndRegisterCore(): void publicPath: $this->app->publicPath(), ), captureExceptionSourceCode: (bool) ($this->nightwatchConfig['capture_exception_source_code'] ?? true), + redactHeaders: $this->nightwatchConfig['redact_headers'] ?? ['Authorization', 'Cookie', 'Proxy-Authorization', 'X-XSRF-TOKEN'], config: $this->config, ), executionState: $executionState, diff --git a/src/Records/Request.php b/src/Records/Request.php index 1e945d15b..99f5b9a07 100644 --- a/src/Records/Request.php +++ b/src/Records/Request.php @@ -2,6 +2,8 @@ namespace Laravel\Nightwatch\Records; +use Symfony\Component\HttpFoundation\HeaderBag; + final class Request { /** @@ -20,6 +22,7 @@ public function __construct( public readonly int $statusCode, public readonly int $requestSize, public readonly int $responseSize, + public HeaderBag $headers, ) { // } diff --git a/src/SensorManager.php b/src/SensorManager.php index 5789070cf..54f045348 100644 --- a/src/SensorManager.php +++ b/src/SensorManager.php @@ -127,11 +127,15 @@ final class SensorManager */ public $commandSensor; + /** + * @param list $redactHeaders + */ public function __construct( private RequestState|CommandState $executionState, private Clock $clock, public Location $location, private bool $captureExceptionSourceCode, + private array $redactHeaders, private Repository $config, ) { // @@ -154,6 +158,7 @@ public function request(Request $request, Response $response): array { $sensor = $this->requestSensor ??= new RequestSensor( requestState: $this->executionState, // @phpstan-ignore argument.type + redactHeaders: $this->redactHeaders, ); return $sensor($request, $response); diff --git a/src/Sensors/RequestSensor.php b/src/Sensors/RequestSensor.php index 444f55500..78698c80a 100644 --- a/src/Sensors/RequestSensor.php +++ b/src/Sensors/RequestSensor.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Route; use Laravel\Nightwatch\Concerns\RecordsContext; +use Laravel\Nightwatch\Concerns\RedactsHeaders; use Laravel\Nightwatch\ExecutionStage; use Laravel\Nightwatch\Records\Request as RequestRecord; use Laravel\Nightwatch\State\RequestState; @@ -19,8 +20,10 @@ use function is_int; use function is_numeric; use function is_string; +use function json_encode; use function sort; use function strlen; +use function tap; /** * @internal @@ -28,9 +31,14 @@ final class RequestSensor { use RecordsContext; + use RedactsHeaders; + /** + * @param list $redactHeaders + */ public function __construct( private RequestState $requestState, + private array $redactHeaders, ) { // } @@ -78,6 +86,11 @@ public function __invoke(Request $request, Response $response): array statusCode: $response->getStatusCode(), requestSize: strlen($request->getContent()), responseSize: $this->parseResponseSize($response), + headers: tap(clone $request->headers, static function ($headers) { + $headers->remove('php-auth-user'); + $headers->remove('php-auth-pw'); + $headers->remove('php-auth-digest'); + }), ), function () use ($record) { return [ @@ -125,6 +138,7 @@ function () use ($record) { 'peak_memory_usage' => $this->requestState->peakMemory(), 'exception_preview' => Str::tinyText($this->requestState->exceptionPreview), 'context' => $this->serializedContext(), + 'headers' => Str::text(json_encode((object) $this->redactHeaders($record->headers, $this->redactHeaders)->all(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION)), ]; }, ]; diff --git a/tests/Feature/Sensors/RequestSensorTest.php b/tests/Feature/Sensors/RequestSensorTest.php index 288d05d5e..0250de75b 100644 --- a/tests/Feature/Sensors/RequestSensorTest.php +++ b/tests/Feature/Sensors/RequestSensorTest.php @@ -23,6 +23,7 @@ use Laravel\Nightwatch\ExecutionStage; use Laravel\Nightwatch\SensorManager; use Livewire\Livewire; +use Orchestra\Testbench\Attributes\WithEnv; use Tests\TestCase; use function fseek; @@ -110,6 +111,7 @@ public function test_it_can_ingest_requests(): void 'peak_memory_usage' => 1234, 'exception_preview' => '', 'context' => Compatibility::$contextExists ? '{}' : '', + 'headers' => '{"host":["localhost"],"user-agent":["Symfony"],"accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"],"accept-language":["en-us,en;q=0.5"],"accept-charset":["ISO-8859-1,utf-8;q=0.7,*;q=0.7"]}', ], ]); } @@ -815,6 +817,121 @@ public function test_it_captures_context(): void }); } + public function test_it_captures_request_headers(): void + { + $ingest = $this->fakeIngest(); + Route::get('/test', function () {}); + + $response = $this + ->withHeader('Test-Header', 'test header value') + ->get('/test'); + + $response->assertOk(); + $ingest->assertWrittenTimes(1); + $ingest->assertLatestWrite('request:0.headers', function ($headers) { + $headers = json_decode($headers, true); + $this->assertSame([ + 'host' => [ + 'localhost', + ], + 'user-agent' => [ + 'Symfony', + ], + 'accept' => [ + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + ], + 'accept-language' => [ + 'en-us,en;q=0.5', + ], + 'accept-charset' => [ + 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', + ], + 'test-header' => [ + 'test header value', + ], + ], $headers); + + return true; + }); + } + + #[WithEnv('NIGHTWATCH_REDACT_HEADERS', 'Authorization,Cookie,Proxy-Authorization,custom')] + public function test_it_redacts_sensitive_headers(): void + { + $ingest = $this->fakeIngest(); + Route::get('/test', function () {}); + + $response = $this + ->withBasicAuth('taylor', '$f4c4d3') + ->withHeader('Proxy-Authorization', 'Bearer secret-token') + ->withHeader('Cookie', 'laravel_session=abc123; XSRF-TOKEN=1234') + ->withHeader('Custom', 'secret') + ->get('/test'); + + $response->assertOk(); + $ingest->assertWrittenTimes(1); + $ingest->assertLatestWrite('request:0.headers', function ($headers) { + $headers = json_decode($headers, true); + $this->assertSame(['Basic [20 bytes redacted]'], $headers['authorization']); + $this->assertSame(['Bearer [12 bytes redacted]'], $headers['proxy-authorization']); + $this->assertSame(['laravel_session=[6 bytes redacted]; XSRF-TOKEN=[4 bytes redacted]'], $headers['cookie']); + $this->assertSame(['[6 bytes redacted]'], $headers['custom']); + $this->assertArrayNotHasKey('php-auth-user', $headers); + $this->assertArrayNotHasKey('php-auth-pw', $headers); + + return true; + }); + } + + #[WithEnv('NIGHTWATCH_REDACT_HEADERS', '')] + public function test_header_redaction_can_be_disabled(): void + { + $ingest = $this->fakeIngest(); + Route::get('/test', function () {}); + + $response = $this + ->withBasicAuth('taylor', '$f4c4d3') + ->withHeader('Proxy-Authorization', 'Bearer secret-token') + ->withHeader('Cookie', 'laravel_session=abc123; XSRF-TOKEN=1234') + ->get('/test'); + + $response->assertOk(); + $ingest->assertWrittenTimes(1); + $ingest->assertLatestWrite('request:0.headers', function ($headers) { + $headers = json_decode($headers, true); + $this->assertSame(['Basic dGF5bG9yOiRmNGM0ZDM='], $headers['authorization']); + $this->assertSame(['Bearer secret-token'], $headers['proxy-authorization']); + $this->assertSame(['laravel_session=abc123; XSRF-TOKEN=1234'], $headers['cookie']); + $this->assertArrayNotHasKey('php-auth-user', $headers); + $this->assertArrayNotHasKey('php-auth-pw', $headers); + + return true; + }); + } + + public function test_it_handles_unconventional_headers(): void + { + $ingest = $this->fakeIngest(); + Route::get('/test', function () {}); + + $response = $this + ->withHeader('Authorization', 'secret-token') + ->withHeader('Proxy-Authorization', 'secret-key secret-token') + ->withHeader('Cookie', 'secret') + ->get('/test'); + + $response->assertOk(); + $ingest->assertWrittenTimes(1); + $ingest->assertLatestWrite('request:0.headers', function ($headers) { + $headers = json_decode($headers, true); + $this->assertSame(['[12 bytes redacted]'], $headers['authorization']); + $this->assertSame(['[23 bytes redacted]'], $headers['proxy-authorization']); + $this->assertSame(['[6 bytes redacted]'], $headers['cookie']); + + return true; + }); + } + public function test_livewire_2(): void { $this->markTestSkippedWhen(version_compare(InstalledVersions::getVersion('livewire/livewire'), '3.0.0', '>='), 'Requires Livewire 2');