fix: strip invalid XML characters from responses
This commit is contained in:
1 parent
b047465339
commit
e9766dbdce
3 files changed
+147
-1
No files matched your search
@@ -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 `�`. 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('�', 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' => ['�'];
|
||||
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('<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' => ['	'];
|
||||
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 = '<a>x'.$valid.'y</a>';
|
||||
|
||||
$this->assertSame($xml, XmlCharacterFilter::strip($xml, $count));
|
||||
$this->assertSame(0, $count);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user