From cb521f4b3e397b5507a72bb9fd4a901cf17816ed Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Sun, 25 Oct 2026 09:45:28 +0200 Subject: [PATCH 1/3] refactor: Make XML sanitization consistent --- src/card.php | 19 +++++++++++++++---- tests/RenderTest.php | 10 +++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/card.php b/src/card.php index 7e769859..a5a8480c 100644 --- a/src/card.php +++ b/src/card.php @@ -2,6 +2,17 @@ declare(strict_types=1); +/** + * Escape text for XML output. + * + * @param string $text The text to escape + * @return string The XML-escaped text + */ +function escapeXml(string $text): string +{ + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_XML1, "UTF-8"); +} + /** * Convert date from Y-M-D to more human-readable format * @@ -50,7 +61,7 @@ function formatDate(string $dateString, string|null $format, string $locale): st } } // sanitize and return formatted date - return htmlspecialchars($formatted); + return escapeXml($formatted); } /** @@ -246,7 +257,7 @@ function splitLines(string $text, int $maxChars, int $line1Offset): string $text = utf8WordWrap($text, $maxChars, "\n", true); } } - $text = htmlspecialchars($text); + $text = escapeXml($text); return preg_replace( "/^(.*)\n(.*)/", "$1$2", @@ -484,7 +495,7 @@ function generateCard(array $stats, ?array $params = null): string if (!empty($stats["excludedDays"])) { $offset = $direction === "rtl" ? $cardWidth - 5 : 5; $excludingDaysText = getExcludingDaysText($stats["excludedDays"], $localeTranslations, $localeCode); - $excludingDaysText = htmlspecialchars($excludingDaysText, ENT_QUOTES | ENT_SUBSTITUTE | ENT_XML1, "UTF-8"); + $excludingDaysText = escapeXml($excludingDaysText); $excludedDays = " @@ -619,7 +630,7 @@ function generateCard(array $stats, ?array $params = null): string function generateErrorCard(string $message, ?array $params = null): string { $params = $params ?? $_REQUEST; - $message = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE | ENT_XML1, "UTF-8"); + $message = escapeXml($message); // get requested theme, use $_REQUEST if no params array specified $theme = getRequestedTheme($params); diff --git a/tests/RenderTest.php b/tests/RenderTest.php index 4e190f27..b94f25a5 100644 --- a/tests/RenderTest.php +++ b/tests/RenderTest.php @@ -40,6 +40,14 @@ final class RenderTest extends TestCase "excludedDays" => [], ]; + public function testEscapeXml(): void + { + $this->assertSame("<>&"'", escapeXml("<>&\"'")); + $this->assertSame("&lt;", escapeXml("<")); + $this->assertSame("\u{00E9}", escapeXml("\u{00E9}")); + $this->assertSame("\u{FFFD}", escapeXml("\xFF")); + } + /** * Test normal card render */ @@ -158,7 +166,7 @@ public function testExcludedDaysCannotInjectMarkup(): void $this->testStats["excludedDays"] = [$day]; $render = generateOutput($this->testStats, $this->testParams)["body"]; $this->assertSafeSvg($render); - $this->assertStringContainsString(htmlspecialchars($day), $render); + $this->assertStringContainsString(escapeXml($day), $render); } /** From 4991cdc5fe064adefbcae39605b713c2085add0e Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Sun, 25 Oct 2026 09:48:09 +0200 Subject: [PATCH 2/3] Update RenderTest.php --- tests/RenderTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/RenderTest.php b/tests/RenderTest.php index b94f25a5..25bbe4dc 100644 --- a/tests/RenderTest.php +++ b/tests/RenderTest.php @@ -40,6 +40,9 @@ final class RenderTest extends TestCase "excludedDays" => [], ]; + /** + * Test escapeXml function + */ public function testEscapeXml(): void { $this->assertSame("<>&"'", escapeXml("<>&\"'")); From 32f4cee02d855ac2e4f4dc68eba3d48b5ebaa4e3 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Sun, 25 Oct 2026 09:50:35 +0200 Subject: [PATCH 3/3] Update card.php --- src/card.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/card.php b/src/card.php index a5a8480c..02fa8add 100644 --- a/src/card.php +++ b/src/card.php @@ -10,7 +10,7 @@ */ function escapeXml(string $text): string { - return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_XML1, "UTF-8"); + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_DISALLOWED | ENT_XML1, "UTF-8"); } /** @@ -495,7 +495,7 @@ function generateCard(array $stats, ?array $params = null): string if (!empty($stats["excludedDays"])) { $offset = $direction === "rtl" ? $cardWidth - 5 : 5; $excludingDaysText = getExcludingDaysText($stats["excludedDays"], $localeTranslations, $localeCode); - $excludingDaysText = escapeXml($excludingDaysText); + $excludingDaysText = htmlspecialchars($excludingDaysText, ENT_QUOTES | ENT_SUBSTITUTE | ENT_XML1, "UTF-8"); $excludedDays = "