diff --git a/src/BusProNet/Transport/XmlChannel.php b/src/BusProNet/Transport/XmlChannel.php index b33fecbf..418b0a9c 100644 --- a/src/BusProNet/Transport/XmlChannel.php +++ b/src/BusProNet/Transport/XmlChannel.php @@ -6,6 +6,7 @@ use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ImmediateConnectionCloseException; use App\BusProNet\Exception\ResponseParserException; use App\BusProNet\MessageType; +use App\BusProNet\Utility\XmlCharacterFilter; use App\BusProNet\XmlParser\ApiResponseParser; use App\Service\XmlDumpWriter; use Psr\Log\LoggerInterface; @@ -208,8 +209,18 @@ class XmlChannel */ private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed { + // The dump keeps the raw response; only the parser sees the filtered one. + $filtered = XmlCharacterFilter::strip($xml, $removed); + if ($removed > 0) { + $this->logger->warning('Removed characters not allowed in XML from API response', [ + 'request_id' => $requestId, + 'type' => $type->value, + 'removed' => $removed, + ]); + } + try { - return $this->responseParser->parseXmlString($type, $xml, $parserArgs); + return $this->responseParser->parseXmlString($type, $filtered, $parserArgs); } catch (ResponseParserException $e) { // Keep the evidence even outside debug mode: without the raw response a parse // failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it. diff --git a/src/BusProNet/Utility/XmlCharacterFilter.php b/src/BusProNet/Utility/XmlCharacterFilter.php new file mode 100644 index 00000000..98c9d228 --- /dev/null +++ b/src/BusProNet/Utility/XmlCharacterFilter.php @@ -0,0 +1,60 @@ += 0x20 && $codePoint <= 0xD7FF) + || ($codePoint >= 0xE000 && $codePoint <= 0xFFFD) + || ($codePoint >= 0x10000 && $codePoint <= 0x10FFFF); + } +} diff --git a/tests/BusProNet/Utility/XmlCharacterFilterTest.php b/tests/BusProNet/Utility/XmlCharacterFilterTest.php new file mode 100644 index 00000000..b5eb637f --- /dev/null +++ b/tests/BusProNet/Utility/XmlCharacterFilterTest.php @@ -0,0 +1,75 @@ +' + .'11297Muster' + .''.str_repeat('�', 40).''; + + $this->assertCount(0, XmlCrawlerFactory::create($xml)); + + $filtered = XmlCharacterFilter::strip($xml, $count); + + $this->assertSame(40, $count); + $crawler = XmlCrawlerFactory::create($filtered); + $this->assertSame('Muster', $crawler->filterXPath('//adressdaten/name')->text()); + $this->assertSame('', $crawler->filterXPath('//adressdaten/geburtsort')->text()); + } + + /** @return iterable */ + public static function invalidProvider(): iterable + { + yield 'hex NUL' => ['�']; + yield 'hex NUL, padded and upper case' => ['�']; + yield 'decimal NUL' => ['�']; + yield 'vertical tab' => [' ']; + yield 'decimal 31' => ['']; + yield 'surrogate' => ['�']; + yield 'noncharacter FFFE' => ['￾']; + yield 'beyond Unicode' => ['�']; + yield 'raw NUL byte' => ["\x00"]; + yield 'raw escape byte' => ["\x1B"]; + } + + #[DataProvider('invalidProvider')] + public function testRemovesInvalidCharacters(string $invalid): void + { + $this->assertSame('xy', XmlCharacterFilter::strip('x'.$invalid.'y', $count)); + $this->assertSame(1, $count); + } + + /** @return iterable */ + public static function validProvider(): iterable + { + yield 'tab' => [' ']; + yield 'line feed' => [' ']; + yield 'carriage return' => [' ']; + yield 'space' => [' ']; + yield 'umlaut' => ['ü']; + yield 'emoji' => ['😀']; + yield 'named entity' => ['&']; + yield 'raw tab and newline' => ["\t\r\n"]; + yield 'raw UTF-8' => ['Grüße 😀']; + } + + #[DataProvider('validProvider')] + public function testKeepsAllowedCharacters(string $valid): void + { + $xml = 'x'.$valid.'y'; + + $this->assertSame($xml, XmlCharacterFilter::strip($xml, $count)); + $this->assertSame(0, $count); + } +}