Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: phishing detection #9610

Merged
merged 1 commit into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions lib/Address.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,27 @@ public function getLabel(): ?string {
// Fallback
return $this->getEmail();
}
$personal = trim(explode('<', $personal)[0]); // Remove the email part if present
st3iny marked this conversation as resolved.
Show resolved Hide resolved
return $personal;
}

/**
* @return string|null
*/
public function getCustomEmail(): ?string {
$personal = $this->wrapped->personal;
if ($personal === null) {
// Fallback
return null;
}
$parts = explode('<', $personal);
if (count($parts) === 1) {
return null;
}
$customEmail = trim($parts[1], '>');
return $customEmail;
}

/**
* @return string|null
*/
Expand Down
25 changes: 24 additions & 1 deletion lib/IMAP/ImapMessageFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use OCA\Mail\IMAP\Charset\Converter;
use OCA\Mail\Model\IMAPMessage;
use OCA\Mail\Service\Html;
use OCA\Mail\Service\PhishingDetection\PhishingDetectionService;
use OCA\Mail\Service\SmimeService;
use OCP\AppFramework\Db\DoesNotExistException;
use function str_starts_with;
Expand All @@ -36,8 +37,10 @@ class ImapMessageFetcher {

private Html $htmlService;
private SmimeService $smimeService;
private PhishingDetectionService $phishingDetectionService;
private string $userId;

private bool $runPhishingCheck = false;
// Conditional fetching/parsing
private bool $loadBody = false;

Expand All @@ -54,6 +57,7 @@ class ImapMessageFetcher {
private string $rawReferences = '';
private string $dispositionNotificationTo = '';
private bool $hasDkimSignature = false;
private array $phishingDetails = [];
private ?string $unsubscribeUrl = null;
private bool $isOneClickUnsubscribe = false;
private ?string $unsubscribeMailto = null;
Expand All @@ -64,13 +68,16 @@ public function __construct(int $uid,
string $userId,
Html $htmlService,
SmimeService $smimeService,
private Converter $converter) {
private Converter $converter,
PhishingDetectionService $phishingDetectionService,
) {
$this->uid = $uid;
$this->mailbox = $mailbox;
$this->client = $client;
$this->userId = $userId;
$this->htmlService = $htmlService;
$this->smimeService = $smimeService;
$this->phishingDetectionService = $phishingDetectionService;
}


Expand All @@ -85,6 +92,17 @@ public function withBody(bool $value): ImapMessageFetcher {
return $this;
}

/**
* Configure the fetcher to check for phishing.
*
* @param bool $value
* @return $this
*/
public function withPhishingCheck(bool $value): ImapMessageFetcher {
$this->runPhishingCheck = $value;
return $this;
}

/**
* @param Horde_Imap_Client_Data_Fetch|null $fetch
* Will be reused if no body is requested.
Expand Down Expand Up @@ -238,6 +256,7 @@ public function fetchMessage(?Horde_Imap_Client_Data_Fetch $fetch = null): IMAPM
$this->rawReferences,
$this->dispositionNotificationTo,
$this->hasDkimSignature,
$this->phishingDetails,
$this->unsubscribeUrl,
$this->isOneClickUnsubscribe,
$this->unsubscribeMailto,
Expand Down Expand Up @@ -495,6 +514,10 @@ private function parseHeaders(Horde_Imap_Client_Data_Fetch $fetch): void {
$dkimSignatureHeader = $parsedHeaders->getHeader('dkim-signature');
$this->hasDkimSignature = $dkimSignatureHeader !== null;

if ($this->runPhishingCheck) {
$this->phishingDetails = $this->phishingDetectionService->checkHeadersForPhishing($parsedHeaders, $this->hasHtmlMessage, $this->htmlMessage);
}

$listUnsubscribeHeader = $parsedHeaders->getHeader('list-unsubscribe');
if ($listUnsubscribeHeader !== null) {
$listHeaders = new Horde_ListHeaders();
Expand Down
7 changes: 6 additions & 1 deletion lib/IMAP/ImapMessageFetcherFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,23 @@
use Horde_Imap_Client_Base;
use OCA\Mail\IMAP\Charset\Converter;
use OCA\Mail\Service\Html;
use OCA\Mail\Service\PhishingDetection\PhishingDetectionService;
use OCA\Mail\Service\SmimeService;

class ImapMessageFetcherFactory {
private Html $htmlService;
private SmimeService $smimeService;
private Converter $charsetConverter;
private PhishingDetectionService $phishingDetectionService;

public function __construct(Html $htmlService,
SmimeService $smimeService,
Converter $charsetConverter) {
Converter $charsetConverter,
PhishingDetectionService $phishingDetectionService) {
$this->htmlService = $htmlService;
$this->smimeService = $smimeService;
$this->charsetConverter = $charsetConverter;
$this->phishingDetectionService = $phishingDetectionService;
}

public function build(int $uid,
Expand All @@ -39,6 +43,7 @@ public function build(int $uid,
$this->htmlService,
$this->smimeService,
$this->charsetConverter,
$this->phishingDetectionService,
);
}
}
8 changes: 5 additions & 3 deletions lib/IMAP/MessageMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public function find(Horde_Imap_Client_Base $client,
int $id,
string $userId,
bool $loadBody = false): IMAPMessage {
$result = $this->findByIds($client, $mailbox, new Horde_Imap_Client_Ids([$id]), $userId, $loadBody);
$result = $this->findByIds($client, $mailbox, new Horde_Imap_Client_Ids([$id]), $userId, $loadBody, true);

if (count($result) === 0) {
throw new DoesNotExistException("Message does not exist");
Expand Down Expand Up @@ -249,7 +249,8 @@ public function findByIds(Horde_Imap_Client_Base $client,
string $mailbox,
$ids,
string $userId,
bool $loadBody = false): array {
bool $loadBody = false,
bool $runPhishingCheck = false): array {
$query = new Horde_Imap_Client_Fetch_Query();
$query->envelope();
$query->flags();
Expand Down Expand Up @@ -294,7 +295,7 @@ public function findByIds(Horde_Imap_Client_Base $client,
$this->logger->debug("findByIds in $mailbox got " . count($ids) . " UIDs ($range) and found " . count($fetchResults) . ". minFetched=$minFetched maxFetched=$maxFetched");
}

return array_map(function (Horde_Imap_Client_Data_Fetch $fetchResult) use ($client, $mailbox, $loadBody, $userId) {
return array_map(function (Horde_Imap_Client_Data_Fetch $fetchResult) use ($client, $mailbox, $loadBody, $userId, $runPhishingCheck) {
return $this->imapMessageFactory
->build(
$fetchResult->getUid(),
Expand All @@ -303,6 +304,7 @@ public function findByIds(Horde_Imap_Client_Base $client,
$userId,
)
->withBody($loadBody)
->withPhishingCheck($runPhishingCheck)
->fetchMessage($fetchResult);
}, $fetchResults);
}
Expand Down
4 changes: 4 additions & 0 deletions lib/Model/IMAPMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class IMAPMessage implements IMessage, JsonSerializable {
private string $rawReferences;
private string $dispositionNotificationTo;
private bool $hasDkimSignature;
private array $phishingDetails;
private ?string $unsubscribeUrl;
private bool $isOneClickUnsubscribe;
private ?string $unsubscribeMailto;
Expand Down Expand Up @@ -85,6 +86,7 @@ public function __construct(int $uid,
string $rawReferences,
string $dispositionNotificationTo,
bool $hasDkimSignature,
array $phishingDetails,
?string $unsubscribeUrl,
bool $isOneClickUnsubscribe,
?string $unsubscribeMailto,
Expand Down Expand Up @@ -113,6 +115,7 @@ public function __construct(int $uid,
$this->rawReferences = $rawReferences;
$this->dispositionNotificationTo = $dispositionNotificationTo;
$this->hasDkimSignature = $hasDkimSignature;
$this->phishingDetails = $phishingDetails;
$this->unsubscribeUrl = $unsubscribeUrl;
$this->isOneClickUnsubscribe = $isOneClickUnsubscribe;
$this->unsubscribeMailto = $unsubscribeMailto;
Expand Down Expand Up @@ -299,6 +302,7 @@ public function jsonSerialize() {
'hasHtmlBody' => $this->hasHtmlMessage,
'dispositionNotificationTo' => $this->getDispositionNotificationTo(),
'hasDkimSignature' => $this->hasDkimSignature,
'phishingDetails' => $this->phishingDetails,
'unsubscribeUrl' => $this->unsubscribeUrl,
'isOneClickUnsubscribe' => $this->isOneClickUnsubscribe,
'unsubscribeMailto' => $this->unsubscribeMailto,
Expand Down
53 changes: 53 additions & 0 deletions lib/PhishingDetectionList.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail;

use JsonSerializable;
use ReturnTypeWillChange;

class PhishingDetectionList implements JsonSerializable {

/** @var PhishingDetectionResult[] */
private array $checks;

private bool $warning = false;

/**
* @param PhishingDetectionResult[] $checks
*/
public function __construct(array $checks = []) {
hamza221 marked this conversation as resolved.
Show resolved Hide resolved
$this->checks = $checks;
}

public function addCheck(PhishingDetectionResult $check) {
$this->checks[] = $check;
}

private function isWarning() {
foreach ($this->checks as $check) {
if (in_array($check->getType(), [PhishingDetectionResult::DATE_CHECK, PhishingDetectionResult::LINK_CHECK, PhishingDetectionResult::CUSTOM_EMAIL_CHECK, PhishingDetectionResult::CONTACTS_CHECK]) && $check->isPhishing()) {
return true;
}
}
return false;
}

#[ReturnTypeWillChange]
public function jsonSerialize() {
$result = array_map(static function (PhishingDetectionResult $check) {
return $check->jsonSerialize();
}, $this->checks);
return [
'checks' => $result,
'warning' => $this->isWarning(),
];
}

}
57 changes: 57 additions & 0 deletions lib/PhishingDetectionResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail;

use JsonSerializable;
use ReturnTypeWillChange;

/**
* @psalm-immutable
*/
class PhishingDetectionResult implements JsonSerializable {

public const DATE_CHECK = "Date";
public const LINK_CHECK = "Link";
public const REPLYTO_CHECK = "Reply-To";
public const CUSTOM_EMAIL_CHECK = "Custom Email";
public const CONTACTS_CHECK = "Contacts";
public const TRUSTED_CHECK = "Trusted";

private string $message = "";
private bool $isPhishing;
private array $additionalData = [];
private string $type;

public function __construct(string $type, bool $isPhishing, string $message = "", array $additionalData = []) {
$this->type = $type;
$this->message = $message;
$this->isPhishing = $isPhishing;
$this->additionalData = $additionalData;

}

public function getType(): string {
return $this->type;
}

public function isPhishing(): bool {
return $this->isPhishing;
}

#[ReturnTypeWillChange]
public function jsonSerialize() {
return [
'type' => $this->type,
'isPhishing' => $this->isPhishing,
'message' => $this->message,
'additionalData' => $this->additionalData,
];
}

}
10 changes: 6 additions & 4 deletions lib/Service/ContactsIntegration.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,22 +223,24 @@ public function newContact(string $name, string $mailAddr, string $type = 'HOME'
/**
* @param string[] $fields
*/
private function doSearch(string $term, array $fields, bool $strictSearch): array {
private function doSearch(string $term, array $fields, bool $strictSearch, bool $forceSAB = false) : array {
$allowSystemUsers = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'no') === 'yes';

$result = $this->contactsManager->search($term, $fields, [
'strict_search' => $strictSearch
]);
$matches = [];
foreach ($result as $r) {
if (!$allowSystemUsers && isset($r['isLocalSystemBook']) && $r['isLocalSystemBook']) {
if ((!$allowSystemUsers && !$forceSAB) && isset($r['isLocalSystemBook']) && $r['isLocalSystemBook']) {
continue;
}
$id = $r['UID'];
$fn = $r['FN'];
$email = $r['EMAIL'];
$matches[] = [
'id' => $id,
'label' => $fn,
'email' => $email,
];
}
return $matches;
Expand All @@ -257,7 +259,7 @@ public function getContactsWithMail(string $mailAddr) {
/**
* Extracts all Contacts with the specified name
*/
public function getContactsWithName(string $name): array {
return $this->doSearch($name, ['FN'], false);
public function getContactsWithName(string $name, bool $forceSAB = false): array {
return $this->doSearch($name, ['FN'], false, $forceSAB);
}
}
Loading
Loading