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);
+ }
+}