From dcaad3669aff770c922be7c186566f7300456012 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Tue, 7 Jul 2026 22:49:13 +0100 Subject: [PATCH] fix: prevent XSS via event title in inline script contexts --- backend/app/Helper/StringHelper.php | 8 +++++ .../Handlers/Event/UpdateEventHandler.php | 3 +- .../Domain/Event/CreateEventService.php | 3 +- .../Domain/Event/DuplicateEventService.php | 3 +- .../tests/Unit/Helper/StringHelperTest.php | 31 +++++++++++++++++++ frontend/server.js | 17 +++++----- .../common/EventDocumentHead/index.tsx | 3 +- frontend/src/utilites/safeScriptJson.d.ts | 1 + frontend/src/utilites/safeScriptJson.js | 10 ++++++ 9 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 backend/tests/Unit/Helper/StringHelperTest.php create mode 100644 frontend/src/utilites/safeScriptJson.d.ts create mode 100644 frontend/src/utilites/safeScriptJson.js diff --git a/backend/app/Helper/StringHelper.php b/backend/app/Helper/StringHelper.php index ffa612c144..c4f14aeab8 100644 --- a/backend/app/Helper/StringHelper.php +++ b/backend/app/Helper/StringHelper.php @@ -4,6 +4,14 @@ class StringHelper { + /** + * Remove control characters and unicode line/paragraph separators from a plain-text value. + */ + public static function stripControlCharacters(string $text): string + { + return preg_replace('/[\x{0000}-\x{001F}\x{007F}-\x{009F}\x{2028}\x{2029}]/u', '', $text) ?? $text; + } + public static function previewFromHtml(string $text, int $length = 100): string { $textWithSpaces = preg_replace('/<[^>]+>/', ' ', $text); diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php index 8f284ff631..b7b5a32137 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php @@ -8,6 +8,7 @@ use HiEvents\Events\EventUpdateEvent; use HiEvents\Exceptions\CannotChangeCurrencyException; use HiEvents\Helper\DateHelper; +use HiEvents\Helper\StringHelper; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventDTO; @@ -71,7 +72,7 @@ private function updateEventAttributes(UpdateEventDTO $eventData): void $this->eventRepository->updateWhere( attributes: [ - 'title' => $eventData->title, + 'title' => StringHelper::stripControlCharacters($eventData->title), 'category' => $eventData->category?->value ?? $existingEvent->getCategory(), 'start_date' => DateHelper::convertToUTC($eventData->start_date, $eventData->timezone), 'end_date' => $eventData->end_date diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index a963784ee3..f44549e376 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -12,6 +12,7 @@ use HiEvents\Exceptions\OrganizerNotFoundException; use HiEvents\Helper\DateHelper; use HiEvents\Helper\IdHelper; +use HiEvents\Helper\StringHelper; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; @@ -94,7 +95,7 @@ private function getOrganizer(int $organizerId, int $accountId): OrganizerDomain private function handleEventCreate(EventDomainObject $eventData): EventDomainObject { return $this->eventRepository->create([ - 'title' => $eventData->getTitle(), + 'title' => StringHelper::stripControlCharacters($eventData->getTitle()), 'organizer_id' => $eventData->getOrganizerId(), 'start_date' => DateHelper::convertToUTC($eventData->getStartDate(), $eventData->getTimezone()), 'end_date' => $eventData->getEndDate() diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php index 22326cb041..54b7b53e0e 100644 --- a/backend/app/Services/Domain/Event/DuplicateEventService.php +++ b/backend/app/Services/Domain/Event/DuplicateEventService.php @@ -18,6 +18,7 @@ use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\DomainObjects\WebhookDomainObject; +use HiEvents\Helper\StringHelper; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; @@ -81,7 +82,7 @@ public function duplicateEvent( $event = $this->getEventWithRelations($eventId, $accountId); $event - ->setTitle($title) + ->setTitle(StringHelper::stripControlCharacters($title)) ->setStartDate($startDate) ->setEndDate($endDate) ->setDescription($this->purifier->purify($description)) diff --git a/backend/tests/Unit/Helper/StringHelperTest.php b/backend/tests/Unit/Helper/StringHelperTest.php new file mode 100644 index 0000000000..158678af1d --- /dev/null +++ b/backend/tests/Unit/Helper/StringHelperTest.php @@ -0,0 +1,31 @@ +assertSame('HelloWorld', StringHelper::stripControlCharacters($input)); + } + + public function testStripControlCharactersPreservesLegitimatePrintableCharacters(): void + { + $input = 'Rock & Roll: a < b > c "Live" 🎸'; + + $this->assertSame($input, StringHelper::stripControlCharacters($input)); + } + + public function testStripControlCharactersPreservesScriptLikeText(): void + { + // The title is stored faithfully; escaping is the renderer's responsibility. + $input = ''; + + $this->assertSame($input, StringHelper::stripControlCharacters($input)); + } +} diff --git a/frontend/server.js b/frontend/server.js index 14649760cb..5502ae254b 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -12,6 +12,7 @@ import * as nodePath from "node:path"; import * as nodeUrl from "node:url"; import "dotenv/config"; import {sitemapIndexHandler, sitemapEventsHandler, sitemapOrganizersHandler} from "./src/sitemap/proxy.js"; +import {htmlSafeJsonStringify} from "./src/utilites/safeScriptJson.js"; installGlobals(); @@ -59,7 +60,7 @@ async function main() { envVars[key] = process.env[key]; } } - return JSON.stringify(envVars); + return htmlSafeJsonStringify(envVars); }; app.get('/robots.txt', (req, res) => { @@ -98,7 +99,7 @@ Sitemap: ${frontendUrl}/sitemap.xml { req, res }, ssrManifest ); - const stringifiedState = JSON.stringify(dehydratedState); + const stringifiedState = htmlSafeJsonStringify(dehydratedState); const helmetHtml = Object.values(helmetContext.helmet || {}) .map((value) => value.toString() || "") @@ -114,11 +115,11 @@ Sitemap: ${frontendUrl}/sitemap.xml } const html = template - .replace("", headSnippets.join("\n")) - .replace("", appHtml) - .replace("", ``) - .replace("", envVariablesHtml) - .replace(/.*?/s, helmetHtml); + .replace("", () => headSnippets.join("\n")) + .replace("", () => appHtml) + .replace("", () => ``) + .replace("", () => envVariablesHtml) + .replace(/.*?/s, () => helmetHtml); res.setHeader("Content-Type", "text/html"); return res.status(200).end(html); @@ -147,4 +148,4 @@ Sitemap: ${frontendUrl}/sitemap.xml } } -main(); \ No newline at end of file +main(); diff --git a/frontend/src/components/common/EventDocumentHead/index.tsx b/frontend/src/components/common/EventDocumentHead/index.tsx index 7bb2bacd07..ff386098cb 100644 --- a/frontend/src/components/common/EventDocumentHead/index.tsx +++ b/frontend/src/components/common/EventDocumentHead/index.tsx @@ -3,6 +3,7 @@ import {Helmet} from "react-helmet-async"; import {Event} from "../../../types"; import {eventCoverImageUrl, eventHomepageUrl} from "../../../utilites/urlHelper.ts"; import {utcToTz} from "../../../utilites/dates.ts"; +import {htmlSafeJsonStringify} from "../../../utilites/safeScriptJson.js"; interface EventDocumentHeadProps { event: Event; @@ -90,7 +91,7 @@ export const EventDocumentHead = ({event}: EventDocumentHeadProps) => { ); diff --git a/frontend/src/utilites/safeScriptJson.d.ts b/frontend/src/utilites/safeScriptJson.d.ts new file mode 100644 index 0000000000..5b3ada0c5e --- /dev/null +++ b/frontend/src/utilites/safeScriptJson.d.ts @@ -0,0 +1 @@ +export declare const htmlSafeJsonStringify: (value: unknown) => string; diff --git a/frontend/src/utilites/safeScriptJson.js b/frontend/src/utilites/safeScriptJson.js new file mode 100644 index 0000000000..e6e276b23c --- /dev/null +++ b/frontend/src/utilites/safeScriptJson.js @@ -0,0 +1,10 @@ +/** + * Serialise a value to JSON that is safe to embed inside an inline