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
1 change: 1 addition & 0 deletions config.default.ini
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ log_static_requests=false
log_404_to_error_log=false
log_not_modified=false
log_redirects=false
ignore_post_fields=password,pass,passwd,password_confirm,password_confirmation,current_password,new_password,old_password,secret,client_secret,token,access_token,refresh_token,id_token,api_key,apikey,authorization,auth,bearer,otp,totp,mfa_code,verification_code,recovery_code,card_number,cc_number,credit_card,cvv,cvc,pin
debug_to_javascript=true
stderr_level=ERROR
type=stdout
Expand Down
55 changes: 55 additions & 0 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
* @SuppressWarnings("PHPMD.ExcessiveClassComplexity")
*/
class Application {
private const string REDACTED_LOG_VALUE = "[redacted]";

private Redirect $redirect;
private Timer $timer;
private OutputBuffer $outputBuffer;
Expand Down Expand Up @@ -575,6 +577,59 @@ private function shouldLogRequest(Response $response):bool {
*/
private function filterLoggedPostBody(array $postBody):array {
unset($postBody[HTMLDocumentProtector::TOKEN_NAME]);
return $this->redactIgnoredPostFields(
$postBody,
$this->getIgnoredPostFieldLookup(),
);
}

/** @return array<string, true> */
private function getIgnoredPostFieldLookup():array {
$configuredFields = explode(
",",
$this->config->getString("logger.ignore_post_fields") ?? "",
);

$lookup = [];
foreach($configuredFields as $field) {
$field = strtolower(trim($field));
if($field === "") {
continue;
}

$lookup[$field] = true;
}

return $lookup;
}

/**
* @param array<array-key, mixed> $postBody
* @param array<string, true> $ignoredFieldLookup
* @return array<array-key, mixed>
*/
private function redactIgnoredPostFields(
array $postBody,
array $ignoredFieldLookup,
):array {
if(!$ignoredFieldLookup) {
return $postBody;
}

foreach($postBody as $key => $value) {
if(is_string($key) && isset($ignoredFieldLookup[strtolower($key)])) {
$postBody[$key] = self::REDACTED_LOG_VALUE;
continue;
}

if(is_array($value)) {
$postBody[$key] = $this->redactIgnoredPostFields(
$value,
$ignoredFieldLookup,
);
}
}

return $postBody;
}

Expand Down
74 changes: 69 additions & 5 deletions test/phpunit/ApplicationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public function testDefaultConfig_usesHtmlRoutingAndDoesNotLogNotModifiedRespons

self::assertSame("text/html", $config["router"]["default_content_type"]);
self::assertSame("false", $config["logger"]["log_not_modified"]);
self::assertStringContainsString("password", $config["logger"]["ignore_post_fields"]);
self::assertStringContainsString("pass", $config["logger"]["ignore_post_fields"]);
}

public function testStart_callsRedirectExecute():void {
Expand Down Expand Up @@ -860,7 +862,7 @@ public function testBuildLogContext_omitsObjectParsedBodyAndKeepsQueryContext():
self::assertArrayNotHasKey("post", $context);
}

public function testBuildLogContext_filtersConfiguredCsrfTokenNameOnly():void {
public function testBuildLogContext_filtersCsrfTokenBeforeRedactingIgnoredFields():void {
$sut = new Application(
config: $this->createTestConfig([]),
requestFactory: $this->createRequestFactory(),
Expand All @@ -872,14 +874,76 @@ public function testBuildLogContext_filtersConfiguredCsrfTokenNameOnly():void {
$sut,
"buildLogContext",
"/send",
[],
[
HTMLDocumentProtector::TOKEN_NAME => "CSRF_secret",
"token" => "business-token",
],
);

self::assertSame(["token" => "[redacted]"], $context["post"]);
}

public function testBuildLogContext_redactsIgnoredPostFields():void {
$sut = new Application(
config: $this->createTestConfig([]),
requestFactory: $this->createRequestFactory(),
dispatcherFactory: self::createStub(DispatcherFactory::class),
globalProtection: self::createStub(Protection::class),
);

$context = $this->invokePrivateMethod(
$sut,
"buildLogContext",
"/login",
[],
[
HTMLDocumentProtector::TOKEN_NAME => "CSRF_secret",
"token" => "business-token",
"username" => "ada",
"password" => "correct horse battery staple",
"pass" => "secret",
],
);

self::assertSame(["token" => "business-token"], $context["post"]);
self::assertSame([
"username" => "ada",
"password" => "[redacted]",
"pass" => "[redacted]",
], $context["post"]);
}

public function testBuildLogContext_redactsConfiguredPostFieldsRecursively():void {
$sut = new Application(
config: $this->createTestConfig([
"logger.ignore_post_fields" => " password , secret , ",
]),
requestFactory: $this->createRequestFactory(),
dispatcherFactory: self::createStub(DispatcherFactory::class),
globalProtection: self::createStub(Protection::class),
);

$context = $this->invokePrivateMethod(
$sut,
"buildLogContext",
"/account",
[],
[
"user" => [
"name" => "Grace",
"password" => "hidden",
],
"SECRET" => "hidden",
"message" => "hello",
],
);

self::assertSame([
"user" => [
"name" => "Grace",
"password" => "[redacted]",
],
"SECRET" => "[redacted]",
"message" => "hello",
], $context["post"]);
}

public function testStart_logsAllRequestsWithRequestContext():void {
Expand Down Expand Up @@ -924,7 +988,7 @@ public function testStart_logsAllRequestsWithRequestContext():void {
self::assertSame("HTTP 204", TestLogHandler::$records[0]["message"]);
self::assertSame("/search", TestLogHandler::$records[0]["context"]["uri"]);
self::assertSame(["q" => "php"], TestLogHandler::$records[0]["context"]["query"]);
self::assertSame(["token" => "abc"], TestLogHandler::$records[0]["context"]["post"]);
self::assertSame(["token" => "[redacted]"], TestLogHandler::$records[0]["context"]["post"]);
self::assertSame("127.0.0.1:", TestLogHandler::$records[0]["context"]["id"]);
}

Expand Down
Loading