fix: strip invalid XML characters from responses

This commit is contained in:
fromme committed 2026-11-03 15:07:47 +01:00
1 parent b047465339
commit e9766dbdce
3 files changed
+147 -1

No files matched your search

+12 -1
View File
@@ -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.
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Utility;
/**
* Removes characters XML 1.0 does not allow from a BusPro response before it is parsed.
*
* BusPro escapes whatever a field holds into a character reference without checking it,
* so a NUL-padded value comes out as `&#x0;`. libxml rejects that as fatal and the whole
* response fails to parse, not just the field. The same applies to the raw control bytes.
*/
final class XmlCharacterFilter
{
/**
* @param-out int $count the number of characters removed
*/
public static function strip(string $xml, ?int &$count = null): string
{
$count = 0;
$xml = preg_replace_callback(
'/&#(x[0-9a-f]+|[0-9]+);/i',
static function (array $match) use (&$count): string {
$reference = $match[1];
$codePoint = 'x' === strtolower($reference[0]) ? hexdec(substr($reference, 1)) : (int) $reference;
if (self::isAllowed($codePoint)) {
return $match[0];
}
++$count;
return '';
},
$xml,
);
// Control bytes never occur inside a UTF-8 multibyte sequence, so a byte-wise match is safe.
$xml = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $xml, -1, $rawCount);
$count += $rawCount;
return $xml;
}
/**
* The Char production of XML 1.0.
*/
private static function isAllowed(int|float $codePoint): bool
{
return 0x9 === $codePoint
|| 0xA === $codePoint
|| 0xD === $codePoint
|| ($codePoint >= 0x20 && $codePoint <= 0xD7FF)
|| ($codePoint >= 0xE000 && $codePoint <= 0xFFFD)
|| ($codePoint >= 0x10000 && $codePoint <= 0x10FFFF);
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\Utility;
use App\BusProNet\Utility\XmlCharacterFilter;
use App\BusProNet\XmlCrawlerFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class XmlCharacterFilterTest extends TestCase
{
public function testNulPaddedFieldNoLongerBreaksTheResponse(): void
{
// Shape of a real KUNDENKONTO/Adressdaten response with a NUL-padded Geburtsort.
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz>'
.'<idperson>11297</idperson><adressdaten><name>Muster</name>'
.'<geburtsort>'.str_repeat('&#x0;', 40).'</geburtsort><beruf></beruf></adressdaten></ergebnis>';
$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<string, array{string}> */
public static function invalidProvider(): iterable
{
yield 'hex NUL' => ['&#x0;'];
yield 'hex NUL, padded and upper case' => ['&#X0000;'];
yield 'decimal NUL' => ['&#0;'];
yield 'vertical tab' => ['&#x0B;'];
yield 'decimal 31' => ['&#31;'];
yield 'surrogate' => ['&#xD800;'];
yield 'noncharacter FFFE' => ['&#xFFFE;'];
yield 'beyond Unicode' => ['&#x110000;'];
yield 'raw NUL byte' => ["\x00"];
yield 'raw escape byte' => ["\x1B"];
}
#[DataProvider('invalidProvider')]
public function testRemovesInvalidCharacters(string $invalid): void
{
$this->assertSame('<a>xy</a>', XmlCharacterFilter::strip('<a>x'.$invalid.'y</a>', $count));
$this->assertSame(1, $count);
}
/** @return iterable<string, array{string}> */
public static function validProvider(): iterable
{
yield 'tab' => ['&#x9;'];
yield 'line feed' => ['&#10;'];
yield 'carriage return' => ['&#xD;'];
yield 'space' => ['&#32;'];
yield 'umlaut' => ['&#xFC;'];
yield 'emoji' => ['&#x1F600;'];
yield 'named entity' => ['&amp;'];
yield 'raw tab and newline' => ["\t\r\n"];
yield 'raw UTF-8' => ['Grüße 😀'];
}
#[DataProvider('validProvider')]
public function testKeepsAllowedCharacters(string $valid): void
{
$xml = '<a>x'.$valid.'y</a>';
$this->assertSame($xml, XmlCharacterFilter::strip($xml, $count));
$this->assertSame(0, $count);
}
}