Skip to content
Merged
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
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
91 changes: 91 additions & 0 deletions src/Concerns/RedactsHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?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;

/**
* @internal
*/
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',
'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);
}
}
}
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