fix(ImipService): Make sure non-html fields are escaped and html fields are not

Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>
This commit is contained in:
David Dreschner 2026-04-22 11:32:53 +02:00
parent c2e72d3adc
commit 241eeed4e3
No known key found for this signature in database
2 changed files with 426 additions and 62 deletions

View file

@ -78,38 +78,53 @@ class IMipService {
return $default;
}
private function generateDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
$strikethrough = "<span style='text-decoration: line-through'>%s</span><br />%s";
if (!isset($vevent->$property)) {
return $default;
private function getStrikethroughString(?string $oldString, ?string $newValue = null): ?string {
if ($oldString === null || $oldString === '') {
return null;
}
$value = $vevent->$property->getValue();
$newstring = $value === null ? null : htmlspecialchars($value);
if (isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newstring) {
$oldstring = $oldVEvent->$property->getValue();
return sprintf($strikethrough, htmlspecialchars($oldstring), $newstring);
$strikethrough = '<span style="text-decoration: line-through">%s</span><br />%s';
return sprintf($strikethrough, $oldString, $newValue ?? '');
}
private function generateDiffString(VEvent $vEvent, VEvent $oldVEvent, string $property): ?string {
if (!isset($vEvent->$property)) {
return null;
}
return $newstring;
$newValue = $vEvent->$property->getValue();
$newString = $newValue === null ? null : htmlspecialchars($newValue);
$propertyChanged = isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newString;
if ($propertyChanged) {
$oldValue = $oldVEvent->$property->getValue();
$oldString = htmlspecialchars($oldValue);
return $this->getStrikethroughString($oldString, $newString);
}
return $newString;
}
/**
* Like generateDiffString() but linkifies the property values if they are urls.
*/
private function generateLinkifiedDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
if (!isset($vevent->$property)) {
return $default;
private function generateLinkifiedDiffString(VEvent $vEvent, VEvent $oldVEvent, string $property): ?string {
if (!isset($vEvent->$property)) {
return null;
}
/** @var string|null $newString */
$newString = htmlspecialchars($vevent->$property->getValue());
$oldString = isset($oldVEvent->$property) ? htmlspecialchars($oldVEvent->$property->getValue()) : null;
if ($oldString !== $newString) {
return sprintf(
"<span style='text-decoration: line-through'>%s</span><br />%s",
$this->linkify($oldString) ?? $oldString ?? '',
$this->linkify($newString) ?? $newString ?? ''
);
$newValue = $vEvent->$property->getValue();
$newString = $this->linkify($newValue) ?? htmlspecialchars($newValue);
$propertyChanged = isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newValue;
if ($propertyChanged) {
$oldValue = $oldVEvent->$property->getValue();
$oldString = $this->linkify($oldValue) ?? htmlspecialchars($oldValue);
return $this->getStrikethroughString($oldString, $newString);
}
return $this->linkify($newString) ?? $newString;
return $this->getStrikethroughString($newString);
}
/**
@ -119,7 +134,15 @@ class IMipService {
if ($url === null) {
return null;
}
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
$isValidLinkUrl
= filter_var($url, FILTER_VALIDATE_URL) !== false
&& (
str_starts_with($url, 'http://')
|| str_starts_with($url, 'https://')
);
if (!$isValidLinkUrl) {
return null;
}
@ -132,7 +155,6 @@ class IMipService {
* @return array
*/
public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array {
// construct event reader
$eventReaderCurrent = new EventReader($vEvent);
$eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null;
@ -144,22 +166,26 @@ class IMipService {
$data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
}
$data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal);
if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
$data['meeting_location_html'] = $locationHtml;
}
$data['meeting_location_html'] = $this->linkify($data['meeting_location']);
$data['meeting_url_html'] = $this->linkify($data['meeting_url']);
if (!empty($oldVEvent)) {
$data['meeting_title_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY');
$data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION');
$data['meeting_location_html'] = $this->generateLinkifiedDiffString($vEvent, $oldVEvent, 'LOCATION');
$oldMeetingUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal);
$oldMeetingUrlAsLink = $this->linkify($oldMeetingUrl);
$meetingUrlAsLinkChanged = !empty($oldMeetingUrlAsLink) && $oldMeetingUrlAsLink !== $data['meeting_url_html'];
if ($meetingUrlAsLinkChanged) {
$data['meeting_url_html'] = $this->getStrikethroughString(htmlspecialchars($oldMeetingUrl), $data['meeting_url_html']);
}
$oldMeetingWhen = $this->generateWhenString($eventReaderPrevious);
$data['meeting_title_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY', $data['meeting_title']);
$data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION', $data['meeting_description']);
$data['meeting_location_html'] = $this->generateLinkifiedDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']);
$oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal);
$data['meeting_url_html'] = !empty($oldUrl) && $oldUrl !== $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url'];
$data['meeting_when_html'] = $oldMeetingWhen !== $data['meeting_when'] ? sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", $oldMeetingWhen, $data['meeting_when']) : $data['meeting_when'];
$meetingWhenChanged = $oldMeetingWhen !== $data['meeting_when'];
$data['meeting_when_html'] = $meetingWhenChanged
? $this->getStrikethroughString($oldMeetingWhen, $data['meeting_when'])
: null;
}
// generate occurring next string
if ($eventReaderCurrent->recurs()) {
@ -183,11 +209,8 @@ class IMipService {
$data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
}
if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
$data['meeting_location_html'] = $locationHtml;
}
$data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : '';
$data['meeting_location_html'] = $this->linkify($data['meeting_location']);
$data['meeting_url_html'] = $this->linkify($data['meeting_url']);
// generate occurring next string
if ($eventReader->recurs()) {
@ -470,7 +493,7 @@ class IMipService {
// days of month
if ($er->recurringPattern() === 'R') {
$days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
. implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
. implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
} else {
$days = implode(', ', $er->recurringDaysOfMonth());
}
@ -537,7 +560,7 @@ class IMipService {
// days of month
if ($er->recurringPattern() === 'R') {
$days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
. implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
. implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
} else {
$days = $er->startDateTime()->format('jS');
}
@ -627,7 +650,6 @@ class IMipService {
* @return string
*/
public function generateOccurringString(EventReader $er): string {
// initialize
$occurrence = null;
$occurrence2 = null;
@ -798,26 +820,26 @@ class IMipService {
// construct event reader
$eventReaderCurrent = new EventReader($vEvent);
$defaultVal = '';
$strikethrough = "<span style='text-decoration: line-through'>%s</span>";
$newMeetingWhen = $this->generateWhenString($eventReaderCurrent);
$newSummary = htmlspecialchars(isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event'));
$newDescription = htmlspecialchars(isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal);
$newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? sprintf('<a href="%1$s">%1$s</a>', $vEvent->URL) : $defaultVal;
$newLocation = htmlspecialchars(isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal);
$newLocationHtml = $this->linkify($newLocation) ?? $newLocation;
$newSummary = isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event');
$newDescription = isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal;
$newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? $this->linkify((string)$vEvent->URL) : $defaultVal;
$newLocation = isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal;
$newLocationHtml = $this->linkify($newLocation);
$data = [];
$data['meeting_when_html'] = $newMeetingWhen === '' ?: sprintf($strikethrough, $newMeetingWhen);
$data['meeting_when_html'] = $this->getStrikethroughString(htmlspecialchars($newMeetingWhen));
$data['meeting_when'] = $newMeetingWhen;
$data['meeting_title_html'] = sprintf($strikethrough, $newSummary);
$data['meeting_title_html'] = $this->getStrikethroughString(htmlspecialchars($newSummary));
$data['meeting_title'] = $newSummary !== '' ? $newSummary: $this->l10n->t('Untitled event');
$data['meeting_description_html'] = $newDescription !== '' ? sprintf($strikethrough, $newDescription) : '';
$data['meeting_description_html'] = $this->getStrikethroughString(htmlspecialchars($newDescription));
$data['meeting_description'] = $newDescription;
$data['meeting_url_html'] = $newUrl !== '' ? sprintf($strikethrough, $newUrl) : '';
$data['meeting_url_html'] = $this->getStrikethroughString($newUrl);
$data['meeting_url'] = isset($vEvent->URL) ? (string)$vEvent->URL : '';
$data['meeting_location_html'] = $newLocationHtml !== '' ? sprintf($strikethrough, $newLocationHtml) : '';
$data['meeting_location_html'] = $this->getStrikethroughString($newLocationHtml ?? htmlspecialchars($newLocation));
$data['meeting_location'] = $newLocation;
return $data;
}

View file

@ -20,6 +20,7 @@ use OCP\IL10N;
use OCP\IUser;
use OCP\IUserManager;
use OCP\L10N\IFactory;
use OCP\Mail\IEMailTemplate;
use OCP\Security\ISecureRandom;
use PHPUnit\Framework\MockObject\MockObject;
use Sabre\VObject\Component\VCalendar;
@ -226,6 +227,7 @@ class IMipServiceTest extends TestCase {
'meeting_description' => '',
'meeting_title' => 'Testing Event',
'meeting_location' => '',
'meeting_location_html' => '',
'meeting_url' => '',
'meeting_url_html' => '',
];
@ -235,6 +237,64 @@ class IMipServiceTest extends TestCase {
$this->assertEquals($expected, $actual);
}
public function testBuildBodyDataCreatedEscapesStrings(): void {
$this->l10n->method('l')
->willReturnCallback(static function (string $type, \DateTime $date, $_):string {
if ($type === 'time') {
return $date->format('H:i');
}
return $date->format('m-d');
});
$this->l10n->method('n')
->willReturnCallback(function (string $singular, string $plural, int $count) {
if ($count === 1) {
return $singular;
}
return $plural;
});
$this->timeFactory->method('getDateTime')->willReturnCallback(
function ($v1, $v2) {
return match (true) {
$v1 == 'now' && $v2 == null => (new \DateTime('20240630T000000'))
};
}
);
$testTitle = '<h1>Test event</h1>';
$testLocation = '<h1>Stuttgart Office</h1>';
$testDescription = '<h1>Description for event</h1>';
$testUrl = 'https://example.test/event?id=123"><script>alert(1)</script><a href="';
$vCalendar = new VCalendar();
$vEvent = $vCalendar->add('VEVENT', []);
$vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
$vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'Europe/Berlin']);
$vEvent->add('DTEND', '20240701T090000', ['TZID' => 'Europe/Berlin']);
$vEvent->add('SUMMARY', $testTitle);
$vEvent->add('LOCATION', $testLocation);
$vEvent->add('DESCRIPTION', $testDescription);
$vEvent->add('URL', $testUrl);
$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
$expected = [
'meeting_when' => $this->service->generateWhenString($eventReader),
'meeting_description' => $testDescription,
'meeting_title' => $testTitle,
'meeting_location' => $testLocation,
'meeting_location_html' => null,
'meeting_url' => $testUrl,
'meeting_url_html' => null,
];
$actual = $this->service->buildBodyData($vCalendar->VEVENT[0], null);
$this->assertEquals($expected, $actual);
}
public function testBuildBodyDataUpdate(): void {
// construct l10n return(s)
@ -278,11 +338,11 @@ class IMipServiceTest extends TestCase {
'meeting_title' => 'Testing Event',
'meeting_location' => '',
'meeting_url' => '',
'meeting_url_html' => '',
'meeting_when_html' => $this->service->generateWhenString($eventReaderNew),
'meeting_title_html' => sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", 'Testing Singleton Event', 'Testing Event'),
'meeting_description_html' => '',
'meeting_location_html' => ''
'meeting_url_html' => null,
'meeting_when_html' => null,
'meeting_title_html' => sprintf('<span style="text-decoration: line-through">%s</span><br />%s', 'Testing Singleton Event', 'Testing Event'),
'meeting_description_html' => null,
'meeting_location_html' => null
];
// generate actual output
$actual = $this->service->buildBodyData($vCalendarNew->VEVENT[0], $vCalendarOld->VEVENT[0]);
@ -290,6 +350,205 @@ class IMipServiceTest extends TestCase {
$this->assertEquals($expected, $actual);
}
public function testBuildBodyDataUpdatedEscapesStrings(): void {
$this->l10n->method('l')
->willReturnCallback(static function (string $type, \DateTime $date):string {
if ($type === 'time') {
return $date->format('H:i');
}
return $date->format('m-d');
});
$this->l10n->method('n')
->willReturnCallback(function (string $singular, string $plural, int $count) {
if ($count === 1) {
return $singular;
}
return $plural;
});
$this->timeFactory->method('getDateTime')->willReturnCallback(
function ($v1, $v2) {
return match (true) {
$v1 == 'now' && $v2 == null => (new \DateTime('20240601T000000'))
};
}
);
$oldTitle = '<h1>Old event</h1>';
$oldLocation = '<h1>Stuttgart Office</h1>';
$oldDescription = '<h1>Description for old event</h1>';
$oldUrl = 'https://example.test/event?id=123"><script>alert(1)</script><a href="';
$newTitle = '<h1>New event</h1>';
$newLocation = '<h1>Berlin Office</h1>';
$newDescription = '<h1>Description for new event</h1>';
$newUrl = 'https://example.test/event?id=456"><script>alert(1)</script><a href="';
$vCalendarNew = new VCalendar();
$vEventNew = $vCalendarNew->add('VEVENT', []);
$vEventNew->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
$vEventNew->add('DTSTART', '20240701T080000', ['TZID' => 'Europe/Berlin']);
$vEventNew->add('DTEND', '20240701T090000', ['TZID' => 'Europe/Berlin']);
$vEventNew->add('SUMMARY', $newTitle);
$vEventNew->add('LOCATION', $newLocation);
$vEventNew->add('DESCRIPTION', $newDescription);
$vEventNew->add('URL', $newUrl);
$vCalendarOld = new VCalendar();
$vEventOld = $vCalendarOld->add('VEVENT', []);
$vEventOld->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
$vEventOld->add('DTSTART', '20240702T080000', ['TZID' => 'Europe/Berlin']);
$vEventOld->add('DTEND', '20240702T090000', ['TZID' => 'Europe/Berlin']);
$vEventOld->add('SUMMARY', $oldTitle);
$vEventOld->add('LOCATION', $oldLocation);
$vEventOld->add('DESCRIPTION', $oldDescription);
$vEventOld->add('URL', $oldUrl);
$eventReaderNew = new EventReader($vCalendarNew, $vCalendarNew->VEVENT[0]->UID->getValue());
$expected = [
'meeting_when' => $this->service->generateWhenString($eventReaderNew),
'meeting_when_html' => null,
'meeting_description' => $newDescription,
'meeting_title' => $newTitle,
'meeting_location' => $newLocation,
'meeting_url' => $newUrl,
'meeting_url_html' => null,
'meeting_title_html' => sprintf('<span style="text-decoration: line-through">%s</span><br />%s', htmlspecialchars($oldTitle), htmlspecialchars($newTitle)),
'meeting_description_html' => sprintf('<span style="text-decoration: line-through">%s</span><br />%s', htmlspecialchars($oldDescription), htmlspecialchars($newDescription)),
'meeting_location_html' => sprintf('<span style="text-decoration: line-through">%s</span><br />%s', htmlspecialchars($oldLocation), htmlspecialchars($newLocation)),
];
$actual = $this->service->buildBodyData($vCalendarNew->VEVENT[0], $vCalendarOld->VEVENT[0]);
$this->assertEquals($expected, $actual);
}
public function testBuildReplyBodyDataEscapesStrings(): void {
$this->l10n->method('l')
->willReturnCallback(static function (string $type, \DateTime $date, $_):string {
if ($type === 'time') {
return $date->format('H:i');
}
return $date->format('m-d');
});
$this->l10n->method('n')
->willReturnCallback(function (string $singular, string $plural, int $count) {
if ($count === 1) {
return $singular;
}
return $plural;
});
$this->timeFactory->method('getDateTime')->willReturnCallback(
function ($v1, $v2) {
return match (true) {
$v1 == 'now' && $v2 == null => (new \DateTime('20240601T000000'))
};
}
);
$testTitle = '<h1>Test event</h1>';
$testLocation = '<h1>Stuttgart Office</h1>';
$testDescription = '<h1>Description for event</h1>';
$testUrl = 'https://example.test/event?id=123"><script>alert(1)</script><a href="';
$vCalendar = new VCalendar();
$vEvent = $vCalendar->add('VEVENT', []);
$vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
$vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'Europe/Berlin']);
$vEvent->add('DTEND', '20240701T090000', ['TZID' => 'Europe/Berlin']);
$vEvent->add('SUMMARY', $testTitle);
$vEvent->add('LOCATION', $testLocation);
$vEvent->add('DESCRIPTION', $testDescription);
$vEvent->add('URL', $testUrl);
$eventReaderNew = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
$expected = [
'meeting_when' => $this->service->generateWhenString($eventReaderNew),
'meeting_description' => $testDescription,
'meeting_title' => $testTitle,
'meeting_location' => $testLocation,
'meeting_location_html' => null,
'meeting_url' => $testUrl,
'meeting_url_html' => null,
];
$actual = $this->service->buildReplyBodyData($vCalendar->VEVENT[0]);
$this->assertEquals($expected, $actual);
}
public function testBuildCancelledBodyDataEscapesStrings(): void {
$this->l10n->method('l')
->willReturnCallback(static function (string $type, \DateTime $date, $_):string {
if ($type === 'time') {
return $date->format('H:i');
}
return $date->format('m-d');
});
$this->l10n->method('n')
->willReturnCallback(function (string $singular, string $plural, int $count) {
if ($count === 1) {
return $singular;
}
return $plural;
});
$this->timeFactory->method('getDateTime')->willReturnCallback(
function ($v1, $v2) {
return match (true) {
$v1 == 'now' && $v2 == null => (new \DateTime('20240601T000000'))
};
}
);
$testTitle = '<h1>Test event</h1>';
$testLocation = '<h1>Stuttgart Office</h1>';
$testDescription = '<h1>Description for event</h1>';
$testUrl = 'https://example.test/event?id=123"><script>alert(1)</script><a href="';
$vCalendar = new VCalendar();
$vEvent = $vCalendar->add('VEVENT', []);
$vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
$vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'Europe/Berlin']);
$vEvent->add('DTEND', '20240701T090000', ['TZID' => 'Europe/Berlin']);
$vEvent->add('SUMMARY', $testTitle);
$vEvent->add('LOCATION', $testLocation);
$vEvent->add('DESCRIPTION', $testDescription);
$vEvent->add('URL', $testUrl);
$eventReaderNew = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
$expectedWhenString = $this->service->generateWhenString($eventReaderNew);
$expected = [
'meeting_when' => $expectedWhenString,
'meeting_when_html' => sprintf('<span style="text-decoration: line-through">%s</span><br />', htmlspecialchars($expectedWhenString)),
'meeting_description' => $testDescription,
'meeting_title' => $testTitle,
'meeting_location' => $testLocation,
'meeting_url' => $testUrl,
'meeting_url_html' => null,
'meeting_title_html' => sprintf('<span style="text-decoration: line-through">%s</span><br />', htmlspecialchars($testTitle)),
'meeting_description_html' => sprintf('<span style="text-decoration: line-through">%s</span><br />', htmlspecialchars($testDescription)),
'meeting_location_html' => sprintf('<span style="text-decoration: line-through">%s</span><br />', htmlspecialchars($testLocation)),
];
$actual = $this->service->buildCancelledBodyData($vCalendar->VEVENT[0]);
$this->assertEquals($expected, $actual);
}
public function testGetLastOccurrenceRRULE(): void {
$vCalendar = new VCalendar();
$vCalendar->add('VEVENT', [
@ -2231,4 +2490,87 @@ class IMipServiceTest extends TestCase {
);
}
/**
* Test that HTML properties are preferred over non-HTML ones
* and that these will be passed through as is.
*/
public function testAddBulletListMaintainsHtmlProperties(): void {
$template = $this->createMock(IEMailTemplate::class);
$vCalendar = new VCalendar();
$vEvent = $vCalendar->add('VEVENT', []);
$vEvent->add('SUMMARY', 'Test Event');
$vEvent->add('UID', 'test-uid');
$data = [
'meeting_title' => 'Title',
'meeting_title_html' => '<strong>Title</strong>',
'meeting_when' => 'Monday & Tuesday',
'meeting_when_html' => '<em>Monday & Tuesday</em>',
'meeting_location' => 'Room A',
'meeting_location_html' => '<span>Room <b>A</b></span>',
'meeting_url' => 'https://example.com',
'meeting_url_html' => '<a href="https://example.com">https://example.com</a>',
'meeting_description' => 'Description',
'meeting_description_html' => '<p>Description</p>',
'meeting_occurring' => 'Every Monday',
'meeting_occurring_html' => '<em>Every Monday</em>',
];
$actualHtmlValues = [];
$template
->method('addBodyListItem')
->willReturnCallback(function (string $html) use (&$actualHtmlValues) {
$actualHtmlValues[] = $html;
});
$this->appConfig->method('getValueBool')->willReturn(false);
$this->service->addBulletList($template, $vEvent, $data);
$this->assertCount(6, $actualHtmlValues);
$this->assertSame($data['meeting_title_html'], $actualHtmlValues[0]);
$this->assertSame($data['meeting_when_html'], $actualHtmlValues[1]);
$this->assertSame($data['meeting_location_html'], $actualHtmlValues[2]);
$this->assertSame($data['meeting_url_html'], $actualHtmlValues[3]);
$this->assertSame($data['meeting_occurring_html'], $actualHtmlValues[4]);
$this->assertSame($data['meeting_description_html'], $actualHtmlValues[5]);
}
/**
* Test that non-HTML properties are used when no HTML properties
* being provided and that those are escaped through `htmlspecialchars()`.
*/
public function testAddBulletListEscapesNonHtmlProperties(): void {
$template = $this->createMock(IEMailTemplate::class);
$vCalendar = new VCalendar();
$vEvent = $vCalendar->add('VEVENT', []);
$vEvent->add('SUMMARY', 'Test Event');
$vEvent->add('UID', 'test-uid');
$data = [
'meeting_title' => '<script>alert("xss")</script>',
'meeting_when' => '<em>Monday & Tuesday</em>',
'meeting_location' => '<div onclick="hack()">Room "A" & B</div>',
'meeting_url' => 'https://example.com/?a=1&b=<script>',
'meeting_description' => '<p>Description with "quotes" & ampersands</p>',
'meeting_occurring' => '<b>Every Monday & Wednesday</b>',
];
$actualHtmlValues = [];
$template
->method('addBodyListItem')
->willReturnCallback(function (string $html) use (&$actualHtmlValues) {
$actualHtmlValues[] = $html;
});
$this->appConfig->method('getValueBool')->willReturn(false);
$this->service->addBulletList($template, $vEvent, $data);
$this->assertCount(6, $actualHtmlValues);
$this->assertSame(htmlspecialchars($data['meeting_title']), $actualHtmlValues[0]);
$this->assertSame(htmlspecialchars($data['meeting_when']), $actualHtmlValues[1]);
$this->assertSame(htmlspecialchars($data['meeting_location']), $actualHtmlValues[2]);
$this->assertSame(htmlspecialchars($data['meeting_url']), $actualHtmlValues[3]);
$this->assertSame(htmlspecialchars($data['meeting_occurring']), $actualHtmlValues[4]);
$this->assertSame(htmlspecialchars($data['meeting_description']), $actualHtmlValues[5]);
}
}