diff --git a/config.default.ini b/config.default.ini index d45eb5f0..1825584c 100644 --- a/config.default.ini +++ b/config.default.ini @@ -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 diff --git a/src/Application.php b/src/Application.php index 33213c54..7011e1d7 100644 --- a/src/Application.php +++ b/src/Application.php @@ -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; @@ -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 */ + 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 $postBody + * @param array $ignoredFieldLookup + * @return array + */ + 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; } diff --git a/test/phpunit/ApplicationTest.php b/test/phpunit/ApplicationTest.php index c0ed1124..5798a6a5 100644 --- a/test/phpunit/ApplicationTest.php +++ b/test/phpunit/ApplicationTest.php @@ -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 { @@ -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(), @@ -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 { @@ -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"]); }