Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117806309
D5511.1775273842.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
38 KB
Referenced Files
None
Subscribers
None
D5511.1775273842.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,79 @@
return $object;
}
+
+ /**
+ * Check iTip message origin. Spoofing detection
+ *
+ * @param Component $request iTip content
+ * @param ?Component $existing Existing event 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;
+ }
}
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
@@ -37,10 +37,6 @@
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);
@@ -50,6 +46,11 @@
return null;
}
+ // Spoofing protection
+ if (!$this->checkOrigin($cancelMaster, $existing)) {
+ 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) {
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
@@ -44,7 +44,6 @@
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?
@@ -56,6 +55,11 @@
return null;
}
+ // Spoofing protection
+ if (!$this->checkOrigin($replyMaster, $existing)) {
+ 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) {
@@ -119,6 +123,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
@@ -6,7 +6,6 @@
use App\Policy\Mailfilter\Modules\ItipModule;
use App\Policy\Mailfilter\Result;
use Sabre\VObject\Component;
-use Sabre\VObject\Document;
class RequestHandler extends ItipModule
{
@@ -52,12 +51,17 @@
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;
+ }
+
+ // 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?
+
// The event does not exist yet in the recipient's calendar, create it
if (!$existing) {
if (!empty($recurrence_id)) {
@@ -76,47 +80,37 @@
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;
- }
-
- $this->mergeComponents($existingInstance, $requestMaster);
- // TODO: Bump LAST-MODIFIED on the master object
+ // Old SEQUENCE in the request, let the MUA deal with this
+ if ($existingInstance && (int) ((string) $existingInstance->SEQUENCE) > (int) ((string) $requestMaster->SEQUENCE)) {
+ return null;
+ }
+
+ // 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) {
+ // Old SEQUENCE in the request, let the MUA deal with this
+ if ($existingMaster && (int) ((string) $existingMaster->SEQUENCE) > (int) ((string) $requestMaster->SEQUENCE)) {
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)) {
- 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;
}
+ // TODO: If the recipient status is not NEEDS-ACTION (the request does not require attention)
+ // we should replace the message with a notification
+
$parser->debug("Updating object at {$this->davLocation}");
$dav = $this->getDAVClient($user);
@@ -124,42 +118,4 @@
return null;
}
-
- /**
- * Merge VOBJECT component properties into another component
- */
- protected function mergeComponents(Component $to, Component $from): void
- {
- // 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};
- }
- }
-
- // 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 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);
- }
- }
- }
- }
- }
}
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,7 +34,7 @@
$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);
@@ -49,7 +49,7 @@
$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);
@@ -74,6 +74,45 @@
);
}
+ /**
+ * 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();
+ }
+
/**
* Test CANCEL method with recurrence
*
@@ -91,7 +130,7 @@
$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');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
@@ -120,4 +159,36 @@
}
);
}
+
+ /**
+ * 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,52 @@
}
);
- // TODO: Test corner cases, spoofing case, etc.
+ // TODO: Test corner cases
+ }
+
+ /**
+ * 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 +140,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 +155,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']);
@@ -160,4 +199,62 @@
// TODO: Test corner cases, etc.
}
+
+ /**
+ * 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
@@ -31,7 +31,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);
@@ -74,6 +74,60 @@
// TODO: Test various supported message structures (ItipModule::getItip())
}
+ /**
+ * 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();
+ }
+
/**
* Test REQUEST method with recurrence
*
@@ -87,18 +141,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 +174,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 +214,90 @@
$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
+ // 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');
+ $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);
- $list = $this->davList($account, 'Calendar', 'event');
- $list = array_filter($list, static fn ($event) => $event->uid == '5464F1DDF6DA264A3FC70E7924B729A5-333333');
- $event = $list[array_key_first($list)];
+ // Jack sends a REQUEST with updated SEQUENCE (master event, not exception)
+ $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->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);
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+
+ 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');
+
+ // 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'));
+
+ $this->davAppend($account, 'Calendar', ['mailfilter/event3.ics'], 'event');
+
+ // 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/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,9 +53,9 @@
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
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 3:37 AM (2 d, 15 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18827925
Default Alt Text
D5511.1775273842.diff (38 KB)
Attached To
Mode
D5511: iTip spoofing protection
Attached
Detach File
Event Timeline