Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117775961
D5511.1775242578.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
63 KB
Referenced Files
None
Subscribers
None
D5511.1775242578.diff
View Options
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule.php b/src/app/Policy/Mailfilter/Modules/ItipModule.php
--- a/src/app/Policy/Mailfilter/Modules/ItipModule.php
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule.php
@@ -271,4 +271,104 @@
return $object;
}
+
+ /**
+ * Check iTip message origin. Spoofing detection
+ *
+ * @param Component $request iTip content
+ * @param ?Component $existing Existing object
+ */
+ protected function checkOrigin(Component $request, ?Component $existing): bool
+ {
+ $method = strtoupper(str_replace('Handler', '', class_basename(static::class)));
+
+ // First we check if the envelope sender matches the ORGANIZER/ATTENDEE in the iTip
+ switch ($method) {
+ case 'REQUEST':
+ case 'CANCEL':
+ $property = 'ORGANIZER';
+ $email = str_ireplace('mailto:', '', (string) $request->ORGANIZER);
+ break;
+ case 'REPLY':
+ $property = 'ATTENDEE';
+ // Per RFC 5546 there can be only one ATTENDEE in REPLY
+ if (count($request->ATTENDEE) == 1) {
+ $email = str_ireplace('mailto:', '', (string) $request->ATTENDEE);
+ }
+ break;
+ default:
+ throw new \Exception("Unexpected iTip method: {$method}");
+ }
+
+ if (empty($email)) {
+ $this->parser->debug("Missing {$property} in iTip {$method}. Ignored.");
+ return false;
+ }
+
+ $email = strtolower($email);
+ $sender = strtolower($this->parser->getSender());
+
+ $result = $email === $sender;
+
+ // TODO: Check if it is a local user using one of his aliases?
+
+ // Check if this ORGANIZER/ATTENDEE matches the one in the existing object
+ if ($result && $existing) {
+ $rid = (string) $request->{'RECURRENCE-ID'};
+ $master = $this->extractMainComponent($existing);
+ $occurence = $rid ? $this->extractRecurrenceInstanceComponent($existing, $rid) : null;
+
+ switch ($method) {
+ case 'REQUEST':
+ case 'CANCEL':
+ $source = $occurence && !empty($occurence->ORGANIZER) ? $occurence : $master;
+ $organizer = str_ireplace('mailto:', '', (string) $source->ORGANIZER);
+
+ $result = strtolower($organizer) === $email;
+ break;
+ case 'REPLY':
+ $source = $occurence && !empty($occurence->ATTENDEE) ? $occurence : $master;
+ foreach ($source->ATTENDEE ?? [] as $attendee) {
+ $attendee = str_ireplace('mailto:', '', (string) $attendee);
+ if (strtolower($attendee) === $email) {
+ break 2;
+ }
+ }
+
+ $result = false;
+ break;
+ }
+ }
+
+ if (!$result) {
+ \Log::warning("Itip {$method} origin mismatch for {$property}.");
+ }
+
+ return $result;
+ }
+
+ /**
+ * Check if the iTip message is eligible for an auto-update.
+ *
+ * @param Component $request iTip content
+ * @param ?Component $existing Existing object
+ */
+ protected function isEligibleForUpdate(Component $request, ?Component $existing = null): bool
+ {
+ if ($existing === null) {
+ return true;
+ }
+
+ // SEQUENCE does not match, we'll let the MUAs deal with this
+ if ((string) $existing->SEQUENCE > (string) $request->SEQUENCE) {
+ $this->parser->debug("Sequence mismatch. Ignored.");
+ return false;
+ }
+
+ // FIXME: When SEQUENCEs are equal we should compare DTSTAMP. Should we?
+ // https://datatracker.ietf.org/doc/html/rfc5546#section-2.1.4
+ // https://datatracker.ietf.org/doc/html/rfc5546#section-5.3
+
+ return true;
+ }
}
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php
--- a/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php
@@ -33,27 +33,19 @@
$existing = $this->findObject($user, $this->uid, $this->type);
if (!$existing) {
- // FIXME: Should we stop message delivery?
return null;
}
- // FIXME: what to do if CANCEL attendees do not match with the recipient email(s)?
- // FIXME: what to do if CANCEL does not come from the organizer's email?
- // stop processing here and pass the message to the inbox?
-
$existingMaster = $this->extractMainComponent($existing);
$cancelMaster = $this->extractMainComponent($this->itip);
if (!$existingMaster || !$cancelMaster) {
- // FIXME: Should we stop message delivery?
$parser->debug("Failed to get the main component. Ignored.");
return null;
}
- // SEQUENCE does not match, deliver the message, let the MUAs to deal with this
- // FIXME: Is this even a valid aproach regarding recurrence?
- if ((string) $existingMaster->SEQUENCE != (string) $cancelMaster->SEQUENCE) {
- $parser->debug("Sequence mismatch. Ignored.");
+ // Spoofing protection
+ if (!$this->checkOrigin($cancelMaster, $existing)) {
return null;
}
@@ -65,11 +57,16 @@
// First find and remove the exception object, if exists
if ($existingInstance = $this->extractRecurrenceInstanceComponent($existing, $recurrence_id)) {
+ // Outdated message, just deliver it, let the MUAs deal with this
+ if (!$this->isEligibleForUpdate($cancelMaster, $existingInstance)) {
+ return null;
+ }
+
$existing->remove($existingInstance);
}
// Add the EXDATE entry
- // FIXME: Do we need to handle RECURRENE-ID differently to get the exception date (timezone)?
+ // FIXME: Do we need to handle RECURRENCE-ID differently to get the exception date (timezone)?
// TODO: We should probably make sure the entry does not exist yet
$exdate = $cancelMaster->{'RECURRENCE-ID'}->getDateTime();
$existingMaster->add('EXDATE', $exdate, ['VALUE' => 'DATE'], 'DATE');
@@ -79,6 +76,11 @@
$dav = $this->getDAVClient($user);
$dav->update($this->toOpaqueObject($existing, $this->davLocation));
} else {
+ // Outdated message, just deliver it, let the MUAs deal with this
+ if (!$this->isEligibleForUpdate($cancelMaster, $existingMaster)) {
+ return null;
+ }
+
$existingInstance = $existingMaster;
$parser->debug("Deleting object at {$this->davLocation}");
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php
--- a/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php
@@ -40,14 +40,9 @@
$existing = $this->findObject($user, $this->uid, $this->type);
if (!$existing) {
- // FIXME: Should we stop message delivery?
return null;
}
- // FIXME: what to do if the REPLY comes from an address not mentioned in the event?
- // FIXME: Should we check if the recipient is an organizer?
- // stop processing here and pass the message to the inbox, or drop it?
-
$existingMaster = $this->extractMainComponent($existing);
$replyMaster = $this->extractMainComponent($this->itip);
@@ -56,10 +51,8 @@
return null;
}
- // SEQUENCE does not match, deliver the message, let the MUAs to deal with this
- // FIXME: Is this even a valid aproach regarding recurrence?
- if ((string) $existingMaster->SEQUENCE != (string) $replyMaster->SEQUENCE) {
- $parser->debug("Sequence mismatch. Ignored.");
+ // Spoofing protection
+ if (!$this->checkOrigin($replyMaster, $existing)) {
return null;
}
@@ -69,8 +62,6 @@
return null;
}
- // TODO: Delegation
-
$sender = $replyMaster->ATTENDEE;
$partstat = $sender['PARTSTAT'];
$email = strtolower(preg_replace('!^mailto:!i', '', (string) $sender));
@@ -82,7 +73,6 @@
}
// Invalid/useless reply, let the MUA deal with it
- // FIXME: Or should we stop delivery?
if (empty($partstat) || $partstat == 'NEEDS-ACTION') {
$parser->debug("Unexpected PARTSTAT in REPLY. Ignored.");
return null;
@@ -92,8 +82,8 @@
if ($recurrence_id) {
$existingInstance = $this->extractRecurrenceInstanceComponent($existing, $recurrence_id);
+
// No such recurrence exception, let the MUA deal with it
- // FIXME: Or should we stop delivery?
if (!$existingInstance) {
$parser->debug("No existing recurrence instance. Ignored.");
return null;
@@ -102,6 +92,11 @@
$existingInstance = $existingMaster;
}
+ // Outdated message, just deliver it, let the MUAs deal with this
+ if (!$this->isEligibleForUpdate($replyMaster, $existingInstance)) {
+ return null;
+ }
+
// Update organizer's event with attendee status
$updated = false;
if (isset($existingInstance->ATTENDEE)) {
@@ -119,6 +114,8 @@
if ($updated) {
$parser->debug("Updating object at {$this->davLocation}");
+ // TODO: We should probably bump LAST-MODIFIED and/or DTSTAMP property
+
$dav = $this->getDAVClient($user);
$dav->update($this->toOpaqueObject($existing, $this->davLocation));
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php
--- a/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php
@@ -4,9 +4,11 @@
use App\Policy\Mailfilter\MailParser;
use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Notifications\ItipNotification;
+use App\Policy\Mailfilter\Notifications\ItipNotificationParams;
use App\Policy\Mailfilter\Result;
+use App\User;
use Sabre\VObject\Component;
-use Sabre\VObject\Document;
class RequestHandler extends ItipModule
{
@@ -37,10 +39,6 @@
// - For an existing "VEVENT" calendar component, delegate the role of "Attendee" to another user.
// - For an existing "VEVENT" calendar component, change the role of "Organizer" to another user.
- // FIXME: This whole method could be async, if we wanted to be more responsive on mail delivery,
- // but CANCEL and REPLY could not, because we're potentially stopping mail delivery there,
- // so I suppose we'll do all of them synchronously for now. Still some parts of it can be async.
-
$this->parser = $parser;
// Check whether the object already exists in the recipient's calendar
@@ -52,12 +50,21 @@
return null;
}
- // FIXME: what to do if REQUEST attendees do not match with the recipient email(s)?
- // stop processing here and pass the message to the inbox?
-
$requestMaster = $this->extractMainComponent($this->itip);
$recurrence_id = (string) $requestMaster->{'RECURRENCE-ID'};
+ // Spoofing protection
+ if (!$this->checkOrigin($requestMaster, $existing)) {
+ return null;
+ }
+
+ // If REQUEST attendees do not match with the recipient email(s)
+ // stop processing here and pass the message to the Inbox.
+ if (!$this->checkRecipient($requestMaster)) {
+ $parser->debug("REQUEST attendees do not match recipient's addresses. Ignored.");
+ return null;
+ }
+
// The event does not exist yet in the recipient's calendar, create it
if (!$existing) {
if (!empty($recurrence_id)) {
@@ -76,45 +83,33 @@
return null;
}
- // TODO: Cover all cases mentioned above
-
- // FIXME: For updates that don't create a new exception should we replace the iTip with a notification?
- // Or maybe we should not even bother with auto-updating and leave it to MUAs?
-
if ($recurrence_id) {
// Recurrence instance
$existingInstance = $this->extractRecurrenceInstanceComponent($existing, $recurrence_id);
- // A new recurrence instance, just add it to the existing event
- if (!$existingInstance) {
- // TODO: Bump LAST-MODIFIED on the master object
- $existing->add($requestMaster);
- } else {
- // SEQUENCE does not match, deliver the message, let the MUAs deal with this
- // TODO: A higher SEQUENCE indicates a re-scheduled object, we should update the existing event.
- if ((int) ((string) $existingInstance->SEQUENCE) != (int) ((string) $requestMaster->SEQUENCE)) {
- return null;
- }
+ // Outdated message, just deliver it, let the MUAs deal with this
+ if (!$this->isEligibleForUpdate($requestMaster, $existingInstance)) {
+ return null;
+ }
- $this->mergeComponents($existingInstance, $requestMaster);
- // TODO: Bump LAST-MODIFIED on the master object
+ // Organizer is the event owner, always replace the whole exception with the new one
+ if ($existingInstance) {
+ $existing->remove($existingInstance);
}
+
+ $existing->add($requestMaster);
} else {
// Master event
$existingMaster = $this->extractMainComponent($existing);
- if (!$existingMaster) {
- return null;
- }
-
- // SEQUENCE does not match, deliver the message, let the MUAs deal with this
- // TODO: A higher SEQUENCE indicates a re-scheduled object, we should update the existing event.
- if ((int) ((string) $existingMaster->SEQUENCE) != (int) ((string) $requestMaster->SEQUENCE)) {
+ // Outdated message, just deliver it, let the MUAs deal with this
+ if (!$this->isEligibleForUpdate($requestMaster, $existingMaster)) {
return null;
}
- // FIXME: Merge all components included in the request?
- $this->mergeComponents($existingMaster, $requestMaster);
+ // Organizer is the event owner, always replace the whole event with the new one
+ $existing = $this->itip;
+ $existingInstance = $existingMaster;
}
$parser->debug("Updating object at {$this->davLocation}");
@@ -122,44 +117,83 @@
$dav = $this->getDAVClient($user);
$dav->update($this->toOpaqueObject($existing, $this->davLocation));
+ // If the recipient's action is not required replace the message with a notification
+ if (!$this->isActionRequired($requestMaster, $existingInstance)) {
+ $parser->debug("Sending notification to {$user->email}");
+ $user->notify($this->notification($requestMaster));
+
+ return new Result(Result::STATUS_DISCARD);
+ }
+
return null;
}
/**
- * Merge VOBJECT component properties into another component
+ * Check if the request recipient is one of the event attendees
*/
- protected function mergeComponents(Component $to, Component $from): void
+ private function checkRecipient(Component $request): bool
{
- // TODO: Every property? What other properties? EXDATE/RDATE? ORGANIZER? ATTACH?
- // TODO: Removal of RRULE from the master event
- $props = ['SEQUENCE', 'RRULE'];
- foreach ($props as $prop) {
- if (isset($from->{$prop})) {
- $to->{$prop} = $from->{$prop};
+ if (empty($request->ATTENDEE)) {
+ return false;
+ }
+
+ $user = $this->parser->getUser();
+ $attendees = [];
+
+ // Check the main user email address
+ foreach ($request->ATTENDEE as $attendee) {
+ $email = strtolower(preg_replace('!^mailto:!i', '', (string) $attendee));
+ if ($email === $user->email) {
+ return true;
}
+ if ($email) {
+ $attendees[] = $email;
+ }
+ }
+
+ // Check the user aliases
+ return $user->aliases->whereIn('alias', $attendees)->count() > 0;
+ }
+
+ /**
+ * Check if attendee action is required for the request
+ */
+ private function isActionRequired(Component $request, ?Component $existing = null): bool
+ {
+ if (empty($existing)) {
+ return true;
}
- // Replace the list of ATTENDEEs
- $to->remove('ATTENDEE');
- foreach ($from->ATTENDEE ?? [] as $attendee) {
- $class = $attendee::class;
- $to->add(new $class($to->parent, 'ATTENDEE', $attendee->getValue(), $attendee->parameters()));
+ if ((string) $existing->SEQUENCE < (string) $request->SEQUENCE) {
+ return true;
}
- // If RRULE contains UNTIL remove exceptions from the timestamp forward
- if (isset($to->RRULE) && ($parts = $to->RRULE->getParts()) && !empty($parts['UNTIL'])) {
- // TODO: Timezone? Also test that with UNTIL using a date format
- $until = new \DateTime($parts['UNTIL']);
- /** @var Document $doc */
- $doc = $to->parent;
-
- foreach ($doc->getComponents() as $component) {
- if ($component->name == $to->name && !empty($component->{'RECURRENCE-ID'})) {
- if ($component->{'RECURRENCE-ID'}->getDateTime() >= $until) {
- $doc->remove($component);
- }
+ $user = $this->parser->getUser();
+
+ foreach ($request->ATTENDEE as $attendee) {
+ $email = strtolower(preg_replace('!^mailto:!i', '', (string) $attendee));
+ if ($email === $user->email || $user->aliases->contains('alias', $email)) {
+ if (empty($attendee['PARTSTAT']) || (string) $attendee['PARTSTAT'] == 'NEEDS-ACTION') {
+ return true;
}
}
}
+
+ return false;
+ }
+
+ /**
+ * Create a notification
+ */
+ private function notification(Component $request): ItipNotification
+ {
+ $organizer = $request->ORGANIZER;
+
+ $params = new ItipNotificationParams('request', $request);
+ $params->comment = (string) $request->COMMENT;
+ $params->senderName = (string) $organizer['CN'];
+ $params->senderEmail = strtolower(preg_replace('!^mailto:!i', '', (string) $organizer));
+
+ return new ItipNotification($params);
}
}
diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php
--- a/src/resources/lang/en/mail.php
+++ b/src/resources/lang/en/mail.php
@@ -35,7 +35,6 @@
'itip-request-subject' => "\":summary\" has been updated",
'itip-request-body' => "The event \":summary\" at :start has been updated in your calendar.",
- 'itip-request-changes' => "Changes submitted by :sender have been automatically applied.",
'itip-attendee-accepted' => ":sender accepted the invitation.",
'itip-attendee-declined' => ":sender declined the invitation.",
diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php
--- a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php
@@ -34,29 +34,24 @@
$this->davEmptyFolder($account, 'Calendar', 'event');
// Jack cancelled the meeting, but there's no event in John's calendar
- $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
Notification::assertNothingSent();
// Jack cancelled the meeting, and now the event exists in John's calendar
$this->davAppend($account, 'Calendar', ['mailfilter/event2.ics'], 'event');
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
- $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
-
- $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(0, $list);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
Notification::assertCount(1);
Notification::assertSentTo(
@@ -72,6 +67,63 @@
&& empty($notification->params->recurrenceId);
}
);
+
+ Notification::fake();
+
+ // Jack cancelled the meeting, but the iTIP has outdated SEQUENCE
+ $replaces = [
+ '/SEQUENCE:0/' => 'SEQUENCE:1',
+ ];
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event2.ics'], 'event', $replaces);
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $this->davList($account, 'Calendar', 'event'));
+
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test spoofing protection on CANCEL
+ *
+ * @group @dav
+ */
+ public function testItipCancelSpoofing(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // Ned impersonates Jack cancelling the meeting, but there's no event in John's calendar
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org', 'ned@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
+
+ $this->davAppend($account, 'Calendar', ['mailfilter/event2.ics'], 'event');
+
+ // Ned impersonates Jack cancelling the meeting, but now the event exists in John's calendar
+ $replaces = [
+ 'mailto:jack@kolab.org' => 'mailto:ned@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org', 'ned@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $this->davList($account, 'Calendar', 'event'));
+
+ Notification::assertNothingSent();
}
/**
@@ -90,8 +142,8 @@
$this->davEmptyFolder($account, 'Calendar', 'event');
$this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
- // Jack cancelled the meeting, and the event exists in John's calendar
- $parser = MailParserTest::getParserForFile('mailfilter/itip2_cancel.eml', 'john@kolab.org');
+ // Jack cancelled the meeting occurence, and the event exists in John's calendar
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
@@ -119,5 +171,56 @@
&& $notification->params->recurrenceId == '20240717T123000';
}
);
+
+ Notification::fake();
+
+ // Jack cancelled the meeting occurence, but the iTip CANCEL contains outdated SEQUENCE
+ $replaces = [
+ '/SEQUENCE:0\nTRANSP/' => 'SEQUENCE:1\nTRANSP',
+ ];
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event', $replaces);
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertCount(1, $list[0]->exceptions);
+
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test spoofing protection on CANCEL with recurrence
+ *
+ * @group @dav
+ */
+ public function testItipCancelRecurrenceSpoofing(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
+
+ // Ned impersonates Jack cancelling the meeting occurrence, the event exists in John's calendar
+ $replaces = [
+ 'mailto:jack@kolab.org' => 'mailto:ned@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_cancel.eml', 'john@kolab.org', 'ned@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(0, $list[0]->exdate);
+
+ Notification::assertNothingSent();
}
}
diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php
--- a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php
@@ -34,7 +34,7 @@
$this->davEmptyFolder($account, 'Calendar', 'event');
// John's response to the invitation, but there's no event in Jack's (organizer's) calendar
- $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email);
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email, 'john@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
@@ -45,14 +45,12 @@
// John's response to the invitation, the Jack's event exists now
$this->davAppend($account, 'Calendar', ['mailfilter/event1.ics'], 'event');
- $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email);
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email, 'john@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
$this->assertCount(2, $attendees = $list[0]->attendees);
$this->assertSame('john@kolab.org', $attendees[0]['email']);
@@ -77,7 +75,71 @@
}
);
- // TODO: Test corner cases, spoofing case, etc.
+ Notification::fake();
+
+ // John's response to the invitation, but with outdated SEQUENCE
+ $replaces = [
+ '/SEQUENCE:0/' => 'SEQUENCE:1',
+ ];
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event1.ics'], 'event', $replaces);
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email, 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(2, $attendees = $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[0]['partstat']);
+
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test spoofing protection for REPLY
+ *
+ * @group @dav
+ */
+ public function testItipReplySpoofing(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('jack@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://jack%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // Unknown impersonates John's response to the invitation, but there's no event in Jack's (organizer's) calendar
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email, 'unknown@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
+
+ $this->davAppend($account, 'Calendar', ['mailfilter/event1.ics'], 'event');
+
+ // Unknown user impersonates John's response to the invitation, the Jack's event exists now
+ $replaces = [
+ 'mailto:john@kolab.org' => 'mailto:unknown@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email, 'unknown@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
+ $this->assertCount(2, $attendees = $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[0]['partstat']);
+ $this->assertSame('ned@kolab.org', $attendees[1]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[1]['partstat']);
+
+ Notification::assertNothingSent();
}
/**
@@ -97,16 +159,12 @@
$this->davAppend($account, 'Calendar', ['mailfilter/event3.ics'], 'event');
// John's response to the invitation, but there's no exception in Jack's event
- $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'john@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
-
- Notification::assertNothingSent();
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
$this->assertCount(2, $attendees = $list[0]->attendees);
$this->assertSame('john@kolab.org', $attendees[0]['email']);
@@ -116,18 +174,18 @@
$this->assertSame('jack@kolab.org', $list[0]->organizer['email']);
$this->assertCount(0, $list[0]->exceptions);
+ Notification::assertNothingSent();
+
$this->davEmptyFolder($account, 'Calendar', 'event');
$this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
// John's response to the invitation, the Jack's event exists now
- $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'john@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
$this->assertCount(2, $attendees = $list[0]->attendees);
$this->assertSame('john@kolab.org', $attendees[0]['email']);
@@ -158,6 +216,82 @@
}
);
- // TODO: Test corner cases, etc.
+ Notification::fake();
+
+ // John's response to the invitation, but with the outdated SEQUENCE
+ $replaces = [
+ '/SEQUENCE:0\nTRANSP/' => 'SEQUENCE:1\nTRANSP',
+ ];
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event', $replaces);
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(2, $attendees = $list[0]->exceptions[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[0]['partstat']);
+ }
+
+ /**
+ * Test spoofing protection on REPLY with recurrence
+ *
+ * @group @dav
+ */
+ public function testItipReplyRecurrenceSpoofing(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('jack@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://jack%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event3.ics'], 'event');
+
+ // Unknown user impersonates John's response to the invitation, but there's no exception in Jack's event
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'unknown@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
+
+ // Unknown impersonates John's response to the invitation, the Jack's event exists now with the exception
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'unknown@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(2, $attendees = $list[0]->exceptions[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[0]['partstat']);
+
+ // Unknown impersonates John's response to the invitation, the Jack's event exists now with the exception
+ $replaces = [
+ 'mailto:john@kolab.org' => 'mailto:unknown@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'unknown@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(2, $attendees = $list[0]->exceptions[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[0]['partstat']);
+
+ Notification::assertNothingSent();
}
}
diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php
--- a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php
@@ -4,6 +4,8 @@
use App\DataMigrator\Account;
use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Notifications\ItipNotification;
+use App\Policy\Mailfilter\Result;
use Illuminate\Support\Facades\Notification;
use Tests\BackendsTrait;
use Tests\TestCase;
@@ -31,7 +33,7 @@
$this->davEmptyFolder($account, 'Calendar', 'event');
// Jack invites John (and Ned) to a new meeting
- $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
@@ -49,7 +51,7 @@
Notification::assertNothingSent();
- // Test REQUEST to an existing event, and other corner cases
+ // Test REQUEST to an existing event
$replaces = [
'CN=Ned;PARTSTAT=NEEDS-ACTION' => 'CN=Ned;PARTSTAT=ACCEPTED',
];
@@ -62,6 +64,7 @@
$list = $this->davList($account, 'Calendar', 'event');
$this->assertCount(1, $list);
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
+ $this->assertSame('Test Meeting 1', $list[0]->summary);
$this->assertCount(2, $list[0]->attendees);
$this->assertSame('john@kolab.org', $list[0]->attendees[0]['email']);
$this->assertSame('NEEDS-ACTION', $list[0]->attendees[0]['partstat']);
@@ -71,7 +74,264 @@
Notification::assertNothingSent();
- // TODO: Test various supported message structures (ItipModule::getItip())
+ // Jack sends a REQUEST with old SEQUENCE (master event, not exception)
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event5.ics'], 'event');
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(2, $list[0]->exceptions);
+ $this->assertSame(1, (int) $list[0]->sequence);
+
+ // Jack sends a REQUEST with updated SEQUENCE (master event, not exception)
+ $replace = [
+ 'SEQUENCE:0' => 'SEQUENCE:2',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+ $this->assertSame(2, (int) $list[0]->sequence);
+
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test REQUEST method against checkRecipient()
+ *
+ * @group dav
+ */
+ public function testItipRequestCheckRecipient(): void
+ {
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // Test a REQUEST where none of the ATTENDEEs is the recipient
+ $replace = [
+ 'john@kolab.org' => 'johnathan@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(0, $list = $this->davList($account, 'Calendar', 'event'));
+
+ // Test a REQUEST where ATTENDEE is the recipient
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(2, $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[0]['email']);
+ $this->assertSame('ned@kolab.org', $list[0]->attendees[1]['email']);
+
+ // Test a REQUEST where ATTENDEE is the recipient's alias
+ $replace = [
+ 'john@kolab.org' => 'john.doe@kolab.org',
+ 'SEQUENCE:0' => 'SEQUENCE:1',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(2, $list[0]->attendees);
+ $this->assertSame('john.doe@kolab.org', $list[0]->attendees[0]['email']);
+ $this->assertSame('ned@kolab.org', $list[0]->attendees[1]['email']);
+
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test notifications on a REQUEST
+ *
+ * @group dav
+ */
+ public function testItipRequestNotifications(): void
+ {
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event5.ics'], 'event');
+
+ // Jack sends a REQUEST with same SEQUENCE, a notification is expected
+ $replace = [
+ 'SEQUENCE:0' => 'SEQUENCE:1',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user = $this->getTestUser('john@kolab.org'),
+ static function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'request'
+ && $notification->params->senderEmail == 'jack@kolab.org'
+ && $notification->params->senderName == 'Jack'
+ && $notification->params->comment == 'Test comment'
+ && $notification->params->start == '2024-07-10 10:30'
+ && $notification->params->summary == 'Test Meeting'
+ && empty($notification->params->recurrenceId);
+ }
+ );
+
+ Notification::fake();
+
+ // Jack sends a REQUEST with updated SEQUENCE, no notification is expected
+ $replace = [
+ 'SEQUENCE:0' => 'SEQUENCE:2',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertSame(2, (int) $list[0]->sequence);
+
+ Notification::assertNothingSent();
+
+ // Jack sends a REQUEST with same SEQUENCE, but reset John's PARTSTAT, no notification is expected
+ $replace = [
+ 'SEQUENCE:0' => 'SEQUENCE:2',
+ 'CN=John;PARTSTAT=ACCEPTED' => 'CN=John;PARTSTAT=NEEDS-ACTION',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(2, $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[0]['email']);
+ $this->assertSame('NEEDS-ACTION', $list[0]->attendees[0]['partstat']);
+
+ Notification::assertNothingSent();
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
+
+ // Jack sends a REQUEST to existing occurrence (SEQUENCE bump), no notification expected
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(2, $attendees = $list[0]->exceptions[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[0]['partstat']);
+
+ Notification::assertNothingSent();
+
+ // Jack sends a REQUEST to existing occurrence (PARTSTAT update), a notification is expected
+ $replace = [
+ 'CN=John;PARTSTAT=NEEDS-ACTION' => 'CN=John;PARTSTAT=ACCEPTED',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(2, $attendees = $list[0]->exceptions[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('ACCEPTED', $attendees[0]['partstat']);
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user = $this->getTestUser('john@kolab.org'),
+ static function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'request'
+ && $notification->params->senderEmail == 'jack@kolab.org'
+ && $notification->params->senderName == 'Jack'
+ && $notification->params->comment == 'Ex'
+ && $notification->params->start == '2024-07-17 12:30'
+ && $notification->params->summary == 'Test Meeting Ex'
+ && !empty($notification->params->recurrenceId);
+ }
+ );
+ }
+
+ /**
+ * Test spoofing protections on REQUEST
+ *
+ * @group dav
+ */
+ public function testItipRequestSpoofing(): void
+ {
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // A new meeting, Ned impersonates Jack
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'ned@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
+
+ Notification::assertNothingSent();
+
+ $this->davAppend($account, 'Calendar', ['mailfilter/event1.ics'], 'event');
+
+ // An existing meeting, Ned impersonates Jack
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'ned@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertSame('Test Meeting', $list[0]->summary);
+ $this->assertSame('12:43:04', $list[0]->lastModified->getDateTime()->format('H:i:s'));
+
+ Notification::assertNothingSent();
+
+ // An existing meeting, Ned as himself, but the organizer is already Jack
+ $replaces = [
+ 'CN=Jack:mailto:jack@kolab.org' => 'CN=Ned:mailto:ned@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'ned@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertSame('Test Meeting', $list[0]->summary);
+ $this->assertSame('12:43:04', $list[0]->lastModified->getDateTime()->format('H:i:s'));
+
+ Notification::assertNothingSent();
}
/**
@@ -87,18 +347,25 @@
$account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
$this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // A recurrence exception in the iTip, but the event does not exist yet
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
+
$this->davAppend($account, 'Calendar', ['mailfilter/event3.ics'], 'event');
// Jack invites John (and Ned) to a new meeting occurrence, the event
- // is already in John's calendar, but has no recurrence exceptions yet
- $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org');
+ // is already in John's calendar and has no recurrence exceptions yet
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
$this->assertCount(2, $attendees = $list[0]->attendees);
$this->assertSame('john@kolab.org', $attendees[0]['email']);
@@ -113,18 +380,32 @@
$this->assertSame('ned@kolab.org', $attendees[1]['email']);
$this->assertSame('NEEDS-ACTION', $attendees[1]['partstat']);
- // Test updating an existing occurence
+ // Test updating an existing occurence using old SEQUENCE - expect no changes to the event
$replaces = [
'CN=Ned;PARTSTAT=NEEDS-ACTION' => 'CN=Ned;PARTSTAT=ACCEPTED',
+ 'SEQUENCE:1' => 'SEQUENCE:0',
];
$parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org', $replaces);
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(2, $attendees = $list[0]->exceptions[0]->attendees);
+ $this->assertSame('ned@kolab.org', $attendees[1]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[1]['partstat']);
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ // Test updating an existing occurence using same SEQUENCE as in the existing exception
+ $replaces = [
+ 'CN=Ned;PARTSTAT=NEEDS-ACTION' => 'CN=Ned;PARTSTAT=ACCEPTED',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
$this->assertCount(2, $attendees = $list[0]->attendees);
$this->assertSame('john@kolab.org', $attendees[0]['email']);
@@ -139,28 +420,66 @@
$this->assertSame('ned@kolab.org', $attendees[1]['email']);
$this->assertSame('ACCEPTED', $attendees[1]['partstat']);
- // Jack sends REQUEST with RRULE containing UNTIL parameter, which is a case when
- // an organizer deletes "this and future" event occurence
- $this->davAppend($account, 'Calendar', ['mailfilter/event5.ics'], 'event');
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test spoofing protection on REQUEST with recurrence
+ *
+ * @group dav
+ */
+ public function testItipRequestRecurrenceSpoofing(): void
+ {
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
- $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org');
+ // A recurrence exception in the iTip, but the event does not exist yet, Ned impersonates Jack
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'ned@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
- $list = $this->davList($account, 'Calendar', 'event');
- $list = array_filter($list, static fn ($event) => $event->uid == '5464F1DDF6DA264A3FC70E7924B729A5-333333');
- $event = $list[array_key_first($list)];
+ $this->davAppend($account, 'Calendar', ['mailfilter/event3.ics'], 'event');
- $this->assertCount(1, $list);
- $this->assertCount(2, $attendees = $event->attendees);
- $this->assertSame('john@kolab.org', $attendees[0]['email']);
- $this->assertSame('ACCEPTED', $attendees[0]['partstat']);
- $this->assertSame('ned@kolab.org', $attendees[1]['email']);
- $this->assertSame('NEEDS-ACTION', $attendees[1]['partstat']);
- $this->assertCount(1, $event->exceptions);
- $this->assertSame('20240717T123000', $event->exceptions[0]->recurrenceId);
+ // The event is already in John's calendar, but has no recurrence exceptions yet, Ned impersonates Jack
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'ned@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+
+ // The event is already in John's calendar, but has no recurrence exceptions yet, Ned impersonates Jack
+ $replaces = [
+ 'CN=Jack:mailto:jack@kolab.org' => 'CN=Ned:mailto:ned@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'ned@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
+
+ // Test updating an existing occurence, Ned impersonates Jack
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'ned@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertSame('Test Meeting', $list[0]->exceptions[0]->summary);
Notification::assertNothingSent();
}
diff --git a/src/tests/Unit/Policy/Mailfilter/MailParserTest.php b/src/tests/Unit/Policy/Mailfilter/MailParserTest.php
--- a/src/tests/Unit/Policy/Mailfilter/MailParserTest.php
+++ b/src/tests/Unit/Policy/Mailfilter/MailParserTest.php
@@ -24,7 +24,7 @@
$body = $parser->getBody();
- $this->assertSame(1639, strlen($body));
+ $this->assertSame(1641, strlen($body));
$body = $parser->getBody(0); // text/plain part
diff --git a/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php b/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php
--- a/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php
+++ b/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php
@@ -83,4 +83,40 @@
// TODO: Test with some properties unset
}
+
+ /**
+ * Test REQUEST notification
+ */
+ public function testRequest(): void
+ {
+ $params = new ItipNotificationParams('request');
+ $params->user = new User(['email' => 'john@kolab.org']);
+ $params->senderEmail = 'jack@kolab.org';
+ $params->senderName = 'Jack Strong';
+ $params->summary = 'Test Meeting';
+ $params->start = '2024-01-01';
+ $params->comment = 'Organizer comment';
+ $params->recurrenceId = '2024-01-01';
+
+ $mail = $this->renderMail(new ItipNotificationMail($params));
+
+ $html = $mail['html'];
+ $plain = $mail['plain'];
+
+ $expected = [
+ "The event \"Test Meeting\" at 2024-01-01 has been updated in your calendar",
+ "Jack Strong provided comment: Organizer comment",
+ "NOTE: This only refers to this single occurrence!",
+ "*** This is an automated message. Please do not reply. ***",
+ ];
+
+ $this->assertSame("\"Test Meeting\" has been updated", $mail['subject']);
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ foreach ($expected as $line) {
+ $this->assertStringContainsString($line, $html);
+ $this->assertStringContainsString($line, $plain);
+ }
+
+ // TODO: Test with some properties unset
+ }
}
diff --git a/src/tests/data/mailfilter/event4.ics b/src/tests/data/mailfilter/event4.ics
--- a/src/tests/data/mailfilter/event4.ics
+++ b/src/tests/data/mailfilter/event4.ics
@@ -34,12 +34,12 @@
LOCATION:Berlin
RRULE:FREQ=WEEKLY;INTERVAL=1
SEQUENCE:0
-TRANSP:OPAQUE
ATTENDEE;CN=John;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
VIDUAL:mailto:john@kolab.org
ATTENDEE;CN=Ned;PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT;CUTYPE=IND
IVIDUAL:mailto:ned@kolab.org
ORGANIZER;CN=Jack:mailto:jack@kolab.org
+TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:5463F1DDF6DA264A3FC70E7924B729A5-222222
diff --git a/src/tests/data/mailfilter/event5.ics b/src/tests/data/mailfilter/event5.ics
--- a/src/tests/data/mailfilter/event5.ics
+++ b/src/tests/data/mailfilter/event5.ics
@@ -33,7 +33,7 @@
SUMMARY:Test Meeting
LOCATION:Berlin
RRULE:FREQ=WEEKLY;INTERVAL=1
-SEQUENCE:0
+SEQUENCE:1
TRANSP:OPAQUE
ATTENDEE;CN=John;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
VIDUAL:mailto:john@kolab.org
diff --git a/src/tests/data/mailfilter/itip1_request.eml b/src/tests/data/mailfilter/itip1_request.eml
--- a/src/tests/data/mailfilter/itip1_request.eml
+++ b/src/tests/data/mailfilter/itip1_request.eml
@@ -56,10 +56,10 @@
UID:5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5
DTSTAMP:20240709T124304Z
CREATED:20240709T124304Z
-LAST-MODIFIED:20240709T124304Z
+LAST-MODIFIED:20240709T124305Z
DTSTART;TZID=Europe/Berlin:20240710T103000
DTEND;TZID=Europe/Berlin:20240710T113000
-SUMMARY:Test Meeting
+SUMMARY:Test Meeting 1
LOCATION:Berlin
SEQUENCE:0
TRANSP:OPAQUE
diff --git a/src/tests/data/mailfilter/itip2_request.eml b/src/tests/data/mailfilter/itip2_request.eml
--- a/src/tests/data/mailfilter/itip2_request.eml
+++ b/src/tests/data/mailfilter/itip2_request.eml
@@ -53,15 +53,16 @@
LAST-MODIFIED:20240709T124304Z
DTSTART;TZID=Europe/Berlin:20240717T123000
DTEND;TZID=Europe/Berlin:20240717T133000
-SUMMARY:Test Meeting
+SUMMARY:Test Meeting Ex
LOCATION:Berlin
-SEQUENCE:0
+SEQUENCE:1
TRANSP:OPAQUE
ATTENDEE;CN=John;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
VIDUAL;RSVP=TRUE:mailto:john@kolab.org
ATTENDEE;CN=Ned;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=IND
IVIDUAL;RSVP=TRUE:mailto:ned@kolab.org
ORGANIZER;CN=Jack:mailto:jack@kolab.org
+COMMENT:Ex
END:VEVENT
END:VCALENDAR
--=_f77327deb61c6eccadcf01b3f6f854cb--
diff --git a/src/tests/data/mailfilter/itip3_request_rrule_update.eml b/src/tests/data/mailfilter/itip3_request_rrule_update.eml
--- a/src/tests/data/mailfilter/itip3_request_rrule_update.eml
+++ b/src/tests/data/mailfilter/itip3_request_rrule_update.eml
@@ -63,6 +63,7 @@
ATTENDEE;CN=Ned;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=IND
IVIDUAL;RSVP=TRUE:mailto:ned@kolab.org
ORGANIZER;CN=Jack:mailto:jack@kolab.org
+COMMENT:Test comment
END:VEVENT
END:VCALENDAR
--=_f77327deb61c6eccadcf01b3f6f854cb--
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 6:56 PM (11 h, 18 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18825770
Default Alt Text
D5511.1775242578.diff (63 KB)
Attached To
Mode
D5511: iTip spoofing protection
Attached
Detach File
Event Timeline