Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions config/nightwatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
73 changes: 73 additions & 0 deletions src/Concerns/RedactsHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Laravel\Nightwatch\Concerns;

use Symfony\Component\HttpFoundation\HeaderBag;
use Throwable;

use function array_map;
use function explode;
use function implode;
use function in_array;
use function strlen;
use function strpos;
use function strtolower;
use function trim;

trait RedactsHeaders
{
/**
* @param list<string> $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', 'digest', 'hoba', 'mutual', 'negotiate', 'ntlm', 'vapid', 'scram', 'aws4-hmac-sha256'], 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);
}
}
}
2 changes: 2 additions & 0 deletions src/NightwatchServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/Records/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Laravel\Nightwatch\Records;

use Symfony\Component\HttpFoundation\HeaderBag;

final class Request
{
/**
Expand All @@ -20,6 +22,7 @@ public function __construct(
public readonly int $statusCode,
public readonly int $requestSize,
public readonly int $responseSize,
public HeaderBag $headers,
) {
//
}
Expand Down
5 changes: 5 additions & 0 deletions src/SensorManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,15 @@ final class SensorManager
*/
public $commandSensor;

/**
* @param list<string> $redactHeaders
*/
public function __construct(
private RequestState|CommandState $executionState,
private Clock $clock,
public Location $location,
private bool $captureExceptionSourceCode,
private array $redactHeaders,
private Repository $config,
) {
//
Expand All @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions src/Sensors/RequestSensor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,18 +20,25 @@
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
*/
final class RequestSensor
{
use RecordsContext;
use RedactsHeaders;

/**
* @param list<string> $redactHeaders
*/
public function __construct(
private RequestState $requestState,
private array $redactHeaders,
) {
//
}
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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)),
];
},
];
Expand Down
117 changes: 117 additions & 0 deletions tests/Feature/Sensors/RequestSensorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"]}',
],
]);
}
Expand Down Expand Up @@ -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');
Expand Down