diff --git a/bin/quickstart.sh b/bin/quickstart.sh
--- a/bin/quickstart.sh
+++ b/bin/quickstart.sh
@@ -78,6 +78,8 @@
rm -rf database/database.sqlite
./artisan db:ping --wait
php -dmemory_limit=512M ./artisan migrate:refresh --seed
-./artisan serve
+./artisan data:import
+./artisan swoole:http stop >/dev/null 2>&1 || :
+./artisan swoole:http start
popd
diff --git a/extras/kolab_policy_greylist.py b/extras/kolab_policy_greylist.py
new file mode 100755
--- /dev/null
+++ b/extras/kolab_policy_greylist.py
@@ -0,0 +1,89 @@
+#!/usr/bin/python3
+"""
+An example implementation of a policy service.
+"""
+
+import json
+import time
+import sys
+
+import requests
+
+
+def read_request_input():
+ """
+ Read a single policy request from sys.stdin, and return a dictionary
+ containing the request.
+ """
+ start_time = time.time()
+
+ policy_request = {}
+ end_of_request = False
+
+ while not end_of_request:
+ if (time.time() - start_time) >= 10:
+ sys.exit(0)
+
+ request_line = sys.stdin.readline()
+
+ if request_line.strip() == '':
+ if 'request' in policy_request:
+ end_of_request = True
+ else:
+ request_line = request_line.strip()
+ request_key = request_line.split('=')[0]
+ request_value = '='.join(request_line.split('=')[1:])
+
+ policy_request[request_key] = request_value
+
+ return policy_request
+
+
+if __name__ == "__main__":
+ TOKEN = 'abcdef'
+ # URL = 'https://services.kolabnow.com/api/webhooks/greylist'
+ # URL = 'http://127.0.0.1:8000/api/webhooks/greylist'
+ URL = 'https://kanarip.dev.kolab.io/api/webhooks/greylist'
+
+ # Start the work
+ while True:
+ REQUEST = read_request_input()
+
+ # print("timestamp={0}".format(REQUEST['timestamp']))
+
+ try:
+ RESPONSE = requests.post(
+ URL,
+ data=REQUEST,
+ headers={'X-Token': TOKEN},
+ verify=True
+ )
+ # pylint: disable=broad-except
+ except Exception:
+ print("action=DEFER_IF_PERMIT Temporary error, try again later.")
+ sys.exit(1)
+
+ try:
+ R = json.loads(RESPONSE.text)
+ # pylint: disable=broad-except
+ except Exception:
+ sys.exit(1)
+
+ if 'prepend' in R:
+ for prepend in R['prepend']:
+ print("action=PREPEND {0}".format(prepend))
+
+ if RESPONSE.ok:
+ print(
+ "action={0}\n".format(R['response'])
+ )
+
+ sys.stdout.flush()
+ else:
+ print(
+ "action={0} {1}\n".format(R['response'], R['reason'])
+ )
+
+ sys.stdout.flush()
+
+ sys.exit(0)
diff --git a/extras/kolab_policy_spf.py b/extras/kolab_policy_spf.py
new file mode 100755
--- /dev/null
+++ b/extras/kolab_policy_spf.py
@@ -0,0 +1,90 @@
+#!/usr/bin/python3
+"""
+This is the implementation of a (postfix) MTA policy service to enforce the
+Sender Policy Framework.
+"""
+
+import json
+import time
+import sys
+
+import requests
+
+
+def read_request_input():
+ """
+ Read a single policy request from sys.stdin, and return a dictionary
+ containing the request.
+ """
+ start_time = time.time()
+
+ policy_request = {}
+ end_of_request = False
+
+ while not end_of_request:
+ if (time.time() - start_time) >= 10:
+ sys.exit(0)
+
+ request_line = sys.stdin.readline()
+
+ if request_line.strip() == '':
+ if 'request' in policy_request:
+ end_of_request = True
+ else:
+ request_line = request_line.strip()
+ request_key = request_line.split('=')[0]
+ request_value = '='.join(request_line.split('=')[1:])
+
+ policy_request[request_key] = request_value
+
+ return policy_request
+
+
+if __name__ == "__main__":
+ TOKEN = 'abcdef'
+ # URL = 'https://services.kolabnow.com/api/webhooks/spf'
+ # URL = 'http://127.0.0.1:8000/api/webhooks/spf'
+ URL = 'https://kanarip.dev.kolab.io/api/webhooks/spf'
+
+ # Start the work
+ while True:
+ REQUEST = read_request_input()
+
+ # print("timestamp={0}".format(REQUEST['timestamp']))
+
+ try:
+ RESPONSE = requests.post(
+ URL,
+ data=REQUEST,
+ headers={'X-Token': TOKEN},
+ verify=True
+ )
+ # pylint: disable=broad-except
+ except Exception:
+ print("action=DEFER_IF_PERMIT Temporary error, try again later.")
+ sys.exit(1)
+
+ try:
+ R = json.loads(RESPONSE.text)
+ # pylint: disable=broad-except
+ except Exception:
+ sys.exit(1)
+
+ if 'prepend' in R:
+ for prepend in R['prepend']:
+ print("action=PREPEND {0}".format(prepend))
+
+ if RESPONSE.ok:
+ print(
+ "action={0}\n".format(R['response'])
+ )
+
+ sys.stdout.flush()
+ else:
+ print(
+ "action={0} {1}\n".format(R['response'], R['reason'])
+ )
+
+ sys.stdout.flush()
+
+ sys.exit(0)
diff --git a/src/.gitignore b/src/.gitignore
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -7,6 +7,8 @@
public/js/*.js
public/storage/
storage/*.key
+storage/*.log
+storage/*-????-??-??*
storage/export/
tests/report/
vendor
diff --git a/src/app/Console/Commands/User/GreylistCommand.php b/src/app/Console/Commands/User/GreylistCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/User/GreylistCommand.php
@@ -0,0 +1,73 @@
+argument('user');
+ $recipientHash = hash('sha256', $recipientAddress);
+
+ $lastConnect = \App\Greylist\Connect::where('recipient_hash', $recipientHash)
+ ->orderBy('updated_at', 'desc')
+ ->first();
+
+ if ($lastConnect) {
+ $timestamp = $lastConnect->updated_at->copy();
+ $this->info("Going from timestamp (last connect) {$timestamp}");
+ } else {
+ $timestamp = \Carbon\Carbon::now();
+ $this->info("Going from timestamp (now) {$timestamp}");
+ }
+
+
+ \App\Greylist\Connect::where('recipient_hash', $recipientHash)
+ ->where('greylisting', true)
+ ->whereDate('updated_at', '>=', $timestamp->copy()->subDays(7))
+ ->orderBy('created_at')->each(
+ function ($connect) {
+ $this->info(
+ sprintf(
+ "From %s@%s since %s",
+ $connect->sender_local,
+ $connect->sender_domain,
+ $connect->created_at
+ )
+ );
+ }
+ );
+ }
+}
diff --git a/src/app/Greylist/Connect.php b/src/app/Greylist/Connect.php
new file mode 100644
--- /dev/null
+++ b/src/app/Greylist/Connect.php
@@ -0,0 +1,62 @@
+recipient_type == \App\Domain::class) {
+ return $this->recipient;
+ }
+
+ return null;
+ }
+
+ // determine if the sender is a penpal of the recipient.
+ public function isPenpal()
+ {
+ return false;
+ }
+
+ public function user()
+ {
+ if ($this->recipient_type == \App\User::class) {
+ return $this->recipient;
+ }
+
+ return null;
+ }
+
+ public function net()
+ {
+ return $this->morphTo();
+ }
+
+ public function recipient()
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/src/app/Greylist/Request.php b/src/app/Greylist/Request.php
new file mode 100644
--- /dev/null
+++ b/src/app/Greylist/Request.php
@@ -0,0 +1,299 @@
+request = $request;
+
+ if (array_key_exists('timestamp', $this->request)) {
+ $this->timestamp = \Carbon\Carbon::parse($this->request['timestamp']);
+ } else {
+ $this->timestamp = \Carbon\Carbon::now();
+ }
+ }
+
+ public function headerGreylist()
+ {
+ if ($this->whitelist) {
+ if ($this->whitelist->sender_local) {
+ return sprintf(
+ "Received-Greylist: sender %s whitelisted since %s",
+ $this->sender,
+ $this->whitelist->created_at->toDateString()
+ );
+ }
+
+ return sprintf(
+ "Received-Greylist: domain %s from %s whitelisted since %s (UTC)",
+ $this->senderDomain,
+ $this->request['client_address'],
+ $this->whitelist->created_at->toDateTimeString()
+ );
+ }
+
+ $connect = $this->findConnectsCollection()->orderBy('created_at')->first();
+
+ if ($connect) {
+ return sprintf(
+ "Received-Greylist: greylisted from %s until %s.",
+ $connect->created_at,
+ $this->timestamp
+ );
+ }
+
+ return "Received-Greylist: no opinion here";
+ }
+
+ public function shouldDefer()
+ {
+ $deferIfPermit = true;
+
+ list($this->netID, $this->netType) = \App\Utils::getNetFromAddress($this->request['client_address']);
+
+ if (!$this->netID) {
+ return true;
+ }
+
+ $recipient = $this->recipientFromRequest();
+
+ $this->sender = $this->senderFromRequest();
+
+ list($this->senderLocal, $this->senderDomain) = explode('@', $this->sender);
+
+ $entry = $this->findConnectsCollectionRecent()->orderBy('updated_at')->first();
+
+ if (!$entry) {
+ // purge all entries to avoid a unique constraint violation.
+ $this->findConnectsCollection()->delete();
+
+ $entry = \App\Greylist\Connect::create(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_hash' => $this->recipientHash,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType,
+ 'connect_count' => 1,
+ 'created_at' => $this->timestamp,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+ }
+
+ // see if all recipients and their domains are opt-outs
+ $enabled = false;
+
+ if ($recipient) {
+ $setting = \App\Greylist\Setting::where(
+ [
+ 'object_id' => $this->recipientID,
+ 'object_type' => $this->recipientType,
+ 'key' => 'greylist_enabled'
+ ]
+ )->first();
+
+ if (!$setting) {
+ $setting = \App\Greylist\Setting::where(
+ [
+ 'object_id' => $recipient->domain()->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled'
+ ]
+ )->first();
+
+ if (!$setting) {
+ $enabled = true;
+ } else {
+ if ($setting->{'value'} !== 'false') {
+ $enabled = true;
+ }
+ }
+ } else {
+ if ($setting->{'value'} !== 'false') {
+ $enabled = true;
+ }
+ }
+ } else {
+ $enabled = true;
+ }
+
+ // the following block is to maintain statistics and state ...
+ $entries = \App\Greylist\Connect::where(
+ [
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType
+ ]
+ )
+ ->whereDate('updated_at', '>=', $this->timestamp->copy()->subDays(7));
+
+ // determine if the sender domain is a whitelist from this network
+ $this->whitelist = \App\Greylist\Whitelist::where(
+ [
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType
+ ]
+ )->first();
+
+ if ($this->whitelist) {
+ if ($this->whitelist->updated_at < $this->timestamp->copy()->subMonthsWithoutOverflow(1)) {
+ $this->whitelist->delete();
+ } else {
+ $this->whitelist->updated_at = $this->timestamp;
+ $this->whitelist->save(['timestamps' => false]);
+
+ $entries->update(
+ [
+ 'greylisting' => false,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+
+ return false;
+ }
+ } else {
+ if ($entries->count() >= 5) {
+ $this->whitelist = \App\Greylist\Whitelist::create(
+ [
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'created_at' => $this->timestamp,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+
+ $entries->update(
+ [
+ 'greylisting' => false,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+ }
+ }
+
+ // TODO: determine if the sender (individual) is a whitelist
+
+ // TODO: determine if the sender is a penpal of any of the recipients. First recipient wins.
+
+ if (!$enabled) {
+ return false;
+ }
+
+ // determine if the sender, net and recipient combination has existed before, for each recipient
+ // any one recipient matching should supersede the other recipients not having matched
+ $connect = \App\Greylist\Connect::where(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType,
+ ]
+ )
+ ->whereDate('updated_at', '>=', $this->timestamp->copy()->subMonthsWithoutOverflow(1))
+ ->orderBy('updated_at')
+ ->first();
+
+ if (!$connect) {
+ $connect = \App\Greylist\Connect::create(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType,
+ 'connect_count' => 0,
+ 'created_at' => $this->timestamp,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+ }
+
+ $connect->connect_count += 1;
+
+ // TODO: The period of time for which the greylisting persists is configurable.
+ if ($connect->created_at < $this->timestamp->copy()->subMinutes(5)) {
+ $deferIfPermit = false;
+
+ $connect->greylisting = false;
+ }
+
+ $connect->save();
+
+ return $deferIfPermit;
+ }
+
+ private function findConnectsCollection()
+ {
+ $collection = \App\Greylist\Connect::where(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType
+ ]
+ );
+
+ return $collection;
+ }
+
+ private function findConnectsCollectionRecent()
+ {
+ return $this->findConnectsCollection()
+ ->where('updated_at', '>=', $this->timestamp->copy()->subDays(7));
+ }
+
+ private function recipientFromRequest()
+ {
+ $recipients = \App\Utils::findObjectsByRecipientAddress($this->request['recipient']);
+
+ if (sizeof($recipients) > 1) {
+ \Log::warning(
+ "Only taking the first recipient from the request in to account for {$this->request['recipient']}"
+ );
+ }
+
+ if (count($recipients) >= 1) {
+ $recipient = $recipients[0];
+ $this->recipientID = $recipient->id;
+ $this->recipientType = get_class($recipient);
+ } else {
+ $recipient = null;
+ }
+
+ $this->recipientHash = hash('sha256', $this->request['recipient']);
+
+ return $recipient;
+ }
+
+ public function senderFromRequest()
+ {
+ return \App\Utils::normalizeAddress($this->request['sender']);
+ }
+}
diff --git a/src/app/Greylist/Setting.php b/src/app/Greylist/Setting.php
new file mode 100644
--- /dev/null
+++ b/src/app/Greylist/Setting.php
@@ -0,0 +1,17 @@
+input();
+
+ list($local, $domainName) = explode('@', $data['recipient']);
+
+ $request = new \App\Greylist\Request($data);
+
+ $shouldDefer = $request->shouldDefer();
+
+ if ($shouldDefer) {
+ return response()->json(
+ ['response' => 'DEFER_IF_PERMIT', 'reason' => "Greylisted for 5 minutes. Try again later."],
+ 403
+ );
+ }
+
+ $prependGreylist = $request->headerGreylist();
+
+ $result = [
+ 'response' => 'DUNNO',
+ 'prepend' => [$prependGreylist]
+ ];
+
+ return response()->json($result, 200);
+ }
+
+ /*
+ * Apply the sender policy framework to a request.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function senderPolicyFramework()
+ {
+ $data = \request()->input();
+
+ list($netID, $netType) = \App\Utils::getNetFromAddress($data['client_address']);
+ list($senderLocal, $senderDomain) = explode('@', $data['sender']);
+
+ // This network can not be recognized.
+ if (!$netID) {
+ return response()->json(
+ [
+ 'response' => 'DEFER_IF_PERMIT',
+ 'reason' => 'Temporary error. Please try again later.'
+ ],
+ 403
+ );
+ }
+
+ // Compose the cache key we want.
+ $cacheKey = "{$netType}_{$netID}_{$senderDomain}";
+
+ $result = \App\SPF\Cache::get($cacheKey);
+
+ if (!$result) {
+ $environment = new \SPFLib\Check\Environment(
+ $data['client_address'],
+ $data['client_name'],
+ $data['sender']
+ );
+
+ $result = (new \SPFLib\Checker())->check($environment);
+
+ \App\SPF\Cache::set($cacheKey, serialize($result));
+ } else {
+ $result = unserialize($result);
+ }
+
+ $fail = false;
+
+ switch ($result->getCode()) {
+ case \SPFLib\Check\Result::CODE_ERROR_PERMANENT:
+ $fail = true;
+ $prependSPF = "Received-SPF: Permerror";
+ break;
+
+ case \SPFLib\Check\Result::CODE_ERROR_TEMPORARY:
+ $prependSPF = "Received-SPF: Temperror";
+ break;
+
+ case \SPFLib\Check\Result::CODE_FAIL:
+ $fail = true;
+ $prependSPF = "Received-SPF: Fail";
+ break;
+
+ case \SPFLib\Check\Result::CODE_SOFTFAIL:
+ $prependSPF = "Received-SPF: Softfail";
+ break;
+
+ case \SPFLib\Check\Result::CODE_NEUTRAL:
+ $prependSPF = "Received-SPF: Neutral";
+ break;
+
+ case \SPFLib\Check\Result::CODE_PASS:
+ $prependSPF = "Received-SPF: Pass";
+ break;
+
+ case \SPFLib\Check\Result::CODE_NONE:
+ $prependSPF = "Received-SPF: None";
+ break;
+ }
+
+ $prependSPF .= " identity=mailfrom;";
+ $prependSPF .= " client-ip={$data['client_address']};";
+ $prependSPF .= " helo={$data['client_name']};";
+ $prependSPF .= " envelope-from={$data['sender']};";
+
+ if ($fail) {
+ // TODO: check the recipient's policy, such as using barracuda for anti-spam and anti-virus as a relay for
+ // inbound mail to a local recipient address.
+ $objects = \App\Utils::findObjectsByRecipientAddress($data['recipient']);
+
+ if (!empty($objects)) {
+ // check if any of the recipient objects have whitelisted the helo, first one wins.
+ foreach ($objects as $object) {
+ if (method_exists($object, 'senderPolicyFrameworkWhitelist')) {
+ $result = $object->senderPolicyFrameworkWhitelist($data['client_name']);
+
+ if ($result) {
+ $response = [
+ 'response' => 'DUNNO',
+ 'prepend' => ["Received-SPF: Pass Check skipped at recipient's discretion"],
+ 'reason' => 'HELO name whitelisted'
+ ];
+
+ return response()->json($response, 200);
+ }
+ }
+ }
+ }
+
+ $result = [
+ 'response' => 'REJECT',
+ 'prepend' => [$prependSPF],
+ 'reason' => "Prohibited by Sender Policy Framework"
+ ];
+
+ return response()->json($result, 403);
+ }
+
+ $result = [
+ 'response' => 'DUNNO',
+ 'prepend' => [$prependSPF],
+ 'reason' => "Don't know"
+ ];
+
+ return response()->json($result, 200);
+ }
+}
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -40,7 +40,7 @@
],
'api' => [
- 'throttle:120,1',
+ //'throttle:120,1',
'bindings',
],
];
diff --git a/src/app/IP4Net.php b/src/app/IP4Net.php
--- a/src/app/IP4Net.php
+++ b/src/app/IP4Net.php
@@ -10,10 +10,31 @@
protected $table = "ip4nets";
protected $fillable = [
+ 'rir_name',
'net_number',
'net_mask',
'net_broadcast',
'country',
- 'serial'
+ 'serial',
+ 'created_at',
+ 'updated_at'
];
+
+ public static function getNet($ip, $mask = 32)
+ {
+ $query = "
+ SELECT id FROM ip4nets
+ WHERE INET_ATON(net_number) <= INET_ATON(?)
+ AND INET_ATON(net_broadcast) >= INET_ATON(?)
+ ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1
+ ";
+
+ $results = DB::select($query, [$ip, $ip]);
+
+ if (sizeof($results) == 0) {
+ return null;
+ }
+
+ return \App\IP4Net::find($results[0]->id);
+ }
}
diff --git a/src/app/SPF/Cache.php b/src/app/SPF/Cache.php
new file mode 100644
--- /dev/null
+++ b/src/app/SPF/Cache.php
@@ -0,0 +1,40 @@
+getSetting('spf_whitelist');
+
+ if (!$setting) {
+ return false;
+ }
+
+ $whitelist = json_decode($setting);
+
+ $matchFound = false;
+
+ foreach ($whitelist as $entry) {
+ if (substr($entry, 0, 1) == '/') {
+ $match = preg_match($entry, $clientName);
+
+ if ($match) {
+ $matchFound = true;
+ }
+
+ continue;
+ }
+
+ if (substr($entry, 0, 1) == '.') {
+ if (substr($clientName, (-1 * strlen($entry))) == $entry) {
+ $matchFound = true;
+ }
+
+ continue;
+ }
+
+ if ($entry == $clientName) {
+ $matchFound = true;
+ continue;
+ }
+ }
+
+ return $matchFound;
+ }
+
/**
* Any (additional) properties of this user.
*
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -41,7 +41,7 @@
*/
public static function countryForIP($ip)
{
- if (strpos(':', $ip) === false) {
+ if (strpos($ip, ':') === false) {
$query = "
SELECT country FROM ip4nets
WHERE INET_ATON(net_number) <= INET_ATON(?)
@@ -155,6 +155,107 @@
fclose($fp);
}
+ /**
+ * Find an object that is the recipient for the specified address.
+ *
+ * @param string $address
+ *
+ * @return array
+ */
+ public static function findObjectsByRecipientAddress($address)
+ {
+ $address = \App\Utils::normalizeAddress($address);
+
+ list($local, $domainName) = explode('@', $address);
+
+ $domain = \App\Domain::where('namespace', $domainName)->first();
+
+ if (!$domain) {
+ return [];
+ }
+
+ $user = \App\User::where('email', $address)->first();
+
+ if ($user) {
+ return [$user];
+ }
+
+ $userAliases = \App\UserAlias::where('alias', $address)->get();
+
+ if (count($userAliases) > 0) {
+ $users = [];
+
+ foreach ($userAliases as $userAlias) {
+ $users[] = $userAlias->user;
+ }
+
+ return $users;
+ }
+
+ $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get();
+
+ if (count($userAliases) > 0) {
+ $users = [];
+
+ foreach ($userAliases as $userAlias) {
+ $users[] = $userAlias->user;
+ }
+
+ return $users;
+ }
+
+ return [];
+ }
+
+ /**
+ * Generate a passphrase. Not intended for use in production, so limited to environments that are not production.
+ *
+ * @return string
+ */
+ public static function generatePassphrase()
+ {
+ $alphaLow = 'abcdefghijklmnopqrstuvwxyz';
+ $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ $num = '0123456789';
+ $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<';
+
+ $source = $alphaLow . $alphaUp . $num . $stdSpecial;
+
+ $result = '';
+
+ for ($x = 0; $x < 16; $x++) {
+ $result .= substr($source, rand(0, (strlen($source) - 1)), 1);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Retrieve the network ID and Type from a client address
+ *
+ * @param string $clientAddress The IPv4 or IPv6 address.
+ *
+ * @return array An array of ID and class or null and null.
+ */
+ public static function getNetFromAddress($clientAddress)
+ {
+ if (strpos($clientAddress, ':') === false) {
+ $net = \App\IP4Net::getNet($clientAddress);
+
+ if ($net) {
+ return [$net->id, \App\IP4Net::class];
+ }
+ } else {
+ $net = \App\IP6Net::getNet($clientAddress);
+
+ if ($net) {
+ return [$net->id, \App\IP6Net::class];
+ }
+ }
+
+ return [null, null];
+ }
+
/**
* Calculate the broadcast address provided a net number and a prefix.
*
@@ -210,6 +311,32 @@
return $lastaddrstr;
}
+ /**
+ * Normalize an email address.
+ *
+ * This means to lowercase and strip components separated with recipient delimiters.
+ *
+ * @param string $address The address to normalize.
+ *
+ * @return string
+ */
+ public static function normalizeAddress($address)
+ {
+ $address = strtolower($address);
+
+ list($local, $domain) = explode('@', $address);
+
+ if (strpos($local, '+') === false) {
+ return "{$local}@{$domain}";
+ }
+
+ $localComponents = explode('+', $local);
+
+ $local = array_pop($localComponents);
+
+ return "{$local}@{$domain}";
+ }
+
/**
* Provide all unique combinations of elements in $input, with order and duplicates irrelevant.
*
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -21,6 +21,7 @@
"kolab/net_ldap3": "dev-master",
"laravel/framework": "6.*",
"laravel/tinker": "^2.4",
+ "mlocati/spf-lib": "^3.0",
"mollie/laravel-mollie": "^2.9",
"morrislaptop/laravel-queue-clear": "^1.2",
"silviolleite/laravelpwa": "^2.0",
diff --git a/src/config/cache.php b/src/config/cache.php
--- a/src/config/cache.php
+++ b/src/config/cache.php
@@ -18,7 +18,7 @@
|
*/
- 'default' => env('CACHE_DRIVER', 'file'),
+ 'default' => env('CACHE_DRIVER', 'redis'),
/*
|--------------------------------------------------------------------------
diff --git a/src/config/database.php b/src/config/database.php
--- a/src/config/database.php
+++ b/src/config/database.php
@@ -122,7 +122,7 @@
'options' => [
'cluster' => env('REDIS_CLUSTER', 'predis'),
- 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
+ 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'),
],
'default' => [
diff --git a/src/database/migrations/2020_10_18_091319_create_greylist_tables.php b/src/database/migrations/2020_10_18_091319_create_greylist_tables.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_10_18_091319_create_greylist_tables.php
@@ -0,0 +1,123 @@
+bigIncrements('id');
+ $table->string('sender_local', 256);
+ $table->string('sender_domain', 256);
+ $table->string('recipient_hash', 64);
+ $table->bigInteger('recipient_id')->unsigned()->nullable();
+ $table->string('recipient_type', 16)->nullable();
+ $table->bigInteger('net_id');
+ $table->string('net_type', 16);
+ $table->boolean('greylisting')->default(true);
+ $table->bigInteger('connect_count')->unsigned()->default(1);
+ $table->timestamps();
+
+ /**
+ * Index for recipient request.
+ */
+ $table->index(
+ [
+ 'sender_local',
+ 'sender_domain',
+ 'recipient_hash',
+ 'net_id',
+ 'net_type'
+ ],
+ 'ssrnn_idx'
+ );
+
+ /**
+ * Index for domain whitelist query.
+ */
+ $table->index(
+ [
+ 'sender_domain',
+ 'net_id',
+ 'net_type'
+ ],
+ 'snn_idx'
+ );
+
+ /**
+ * Index for updated_at
+ */
+ $table->index('updated_at');
+
+ $table->unique(
+ ['sender_local', 'sender_domain', 'recipient_hash', 'net_id', 'net_type'],
+ 'ssrnn_unq'
+ );
+ }
+ );
+
+ Schema::create(
+ 'greylist_penpals',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('local_id');
+ $table->string('local_type', 16);
+ $table->string('remote_local', 128);
+ $table->string('remote_domain', 256);
+ $table->timestamps();
+ }
+ );
+
+ Schema::create(
+ 'greylist_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('object_id');
+ $table->string('object_type', 16);
+ $table->string('key', 64);
+ $table->text('value');
+ $table->timestamps();
+
+ $table->index(['object_id', 'object_type', 'key'], 'ook_idx');
+ }
+ );
+
+ Schema::create(
+ 'greylist_whitelist',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('sender_local', 128)->nullable();
+ $table->string('sender_domain', 256);
+ $table->bigInteger('net_id');
+ $table->string('net_type', 16);
+ $table->timestamps();
+
+ $table->index(['sender_local', 'sender_domain', 'net_id', 'net_type'], 'ssnn_idx');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('greylist_connect');
+ Schema::dropIfExists('greylist_penpals');
+ Schema::dropIfExists('greylist_settings');
+ Schema::dropIfExists('greylist_whitelist');
+ }
+}
diff --git a/src/database/seeds/local/DomainSeeder.php b/src/database/seeds/local/DomainSeeder.php
--- a/src/database/seeds/local/DomainSeeder.php
+++ b/src/database/seeds/local/DomainSeeder.php
@@ -25,7 +25,8 @@
"groupoffice.ch",
"journalistmail.ch",
"legalprivilege.ch",
- "libertymail.co"
+ "libertymail.co",
+ "libertymail.net"
];
foreach ($domains as $domain) {
diff --git a/src/phpunit.xml b/src/phpunit.xml
--- a/src/phpunit.xml
+++ b/src/phpunit.xml
@@ -40,5 +40,6 @@
+
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -85,10 +85,12 @@
Route::group(
[
'domain' => \config('app.domain'),
- 'prefix' => $prefix . 'api/webhooks',
+ 'prefix' => $prefix . 'api/webhooks'
],
function () {
+ Route::post('greylist', 'API\V4\PolicyController@greylist');
Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook');
+ Route::post('spf', 'API\V4\PolicyController@senderPolicyFramework');
}
);
diff --git a/src/tests/Feature/Stories/GreylistTest.php b/src/tests/Feature/Stories/GreylistTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Stories/GreylistTest.php
@@ -0,0 +1,606 @@
+instance = $this->generateInstanceId();
+ $this->clientAddress = '212.103.80.148';
+
+ $this->net = \App\IP4Net::getNet($this->clientAddress);
+
+ DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';");
+ DB::delete("DELETE FROM greylist_settings;");
+ DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';");
+ }
+
+ public function tearDown(): void
+ {
+ DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';");
+ DB::delete("DELETE FROM greylist_settings;");
+ DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';");
+
+ parent::tearDown();
+ }
+
+ public function testWithTimestamp()
+ {
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(7)->toString()
+ ]
+ );
+
+ $timestamp = $this->getObjectProperty($request, 'timestamp');
+
+ $this->assertTrue(
+ \Carbon\Carbon::parse($timestamp, 'UTC') < \Carbon\Carbon::now()
+ );
+ }
+
+ public function testNoNet()
+ {
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => '127.128.129.130',
+ 'client_name' => 'some.mx'
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testIp6Net()
+ {
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => '2a00:1450:400a:803::2005',
+ 'client_name' => 'some.mx'
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ // public function testMultiRecipientThroughAlias() {}
+
+ public function testWhitelistNew()
+ {
+ $whitelist = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNull($whitelist);
+
+ for ($i = 0; $i < 5; $i++) {
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => "someone{$i}@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ $whitelist = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNotNull($whitelist);
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => "someone5@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ // public function testWhitelistedHit() {}
+
+ public function testWhitelistStale()
+ {
+ $whitelist = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNull($whitelist);
+
+ for ($i = 0; $i < 5; $i++) {
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => "someone{$i}@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ $whitelist = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNotNull($whitelist);
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => "someone5@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+
+ $whitelist->updated_at = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
+ $whitelist->save(['timestamps' => false]);
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ // public function testWhitelistUpdate() {}
+
+ public function testNew()
+ {
+ $data = [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx'
+ ];
+
+ $response = $this->post('/api/webhooks/greylist', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testRetry()
+ {
+ $connect = \App\Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabled()
+ {
+ $setting = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainEnabled()
+ {
+ $connect = \App\Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => \App\IP4Net::getNet('212.103.80.148')->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $setting = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabledUserDisabled()
+ {
+ $connect = \App\Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $settingDomain = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $settingUser = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabledUserEnabled()
+ {
+ $connect = \App\Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $settingDomain = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $settingUser = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testInvalidDomain()
+ {
+ $connect = \App\Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => 1234,
+ 'recipient_type' => \App\Domain::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => 'not.someone@that.exists',
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testInvalidUser()
+ {
+ $connect = \App\Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => 1234,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => 'not.someone@that.exists',
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testUserDisabled()
+ {
+ $connect = \App\Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $setting = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testUserEnabled()
+ {
+ $connect = \App\Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $setting = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testMultipleUsersAllDisabled()
+ {
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ foreach ($this->domainUsers as $user) {
+ \App\Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $user->email),
+ 'recipient_id' => $user->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ \App\Greylist\Setting::create(
+ [
+ 'object_id' => $user->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ if ($user->email == $this->domainOwner->email) {
+ continue;
+ }
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $user->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+ }
+
+ public function testMultipleUsersAnyEnabled()
+ {
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ foreach ($this->domainUsers as $user) {
+ \App\Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $user->email),
+ 'recipient_id' => $user->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ \App\Greylist\Setting::create(
+ [
+ 'object_id' => $user->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => ($user->id == $this->jack->id) ? 'true' : 'false'
+ ]
+ );
+
+ if ($user->email == $this->domainOwner->email) {
+ continue;
+ }
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $user->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ if ($user->id == $this->jack->id) {
+ $this->assertTrue($request->shouldDefer());
+ } else {
+ $this->assertFalse($request->shouldDefer());
+ }
+ }
+ }
+
+ private function generateInstanceId()
+ {
+ $instance = [];
+
+ for ($x = 0; $x < 3; $x++) {
+ for ($y = 0; $y < 3; $y++) {
+ $instance[] .= substr('01234567889', rand(0, 9), 1);
+ }
+ }
+
+ return implode('.', $instance);
+ }
+}
diff --git a/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php b/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php
@@ -0,0 +1,306 @@
+ 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-fail.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderFailv6()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-fail.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ // actually IN AAAA gmail.com.
+ 'client_address' => '2a00:1450:400a:801::2005',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $this->assertFalse(strpos(':', $data['client_address']));
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderNone()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-none.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderNoNet()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-none.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '256.0.0.1',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderPass()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-pass.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderPassAll()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-passall.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderPermerror()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-permerror.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderSoftfail()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-fail.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderTemperror()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-temperror.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderRelayPolicyHeloExactNegative()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['the.only.acceptable.helo']));
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyHeloExactPositive()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['helo.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(200);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+
+ public function testSenderRelayPolicyRegexpNegative()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['/a\.domain/']));
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyRegexpPositive()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['/relayservice\.domain/']));
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(200);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyWildcardSubdomainNegative()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['.helo.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyWildcardSubdomainPositive()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(200);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+}
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -8,19 +8,6 @@
{
use TestCaseTrait;
- protected function backdateEntitlements($entitlements, $targetDate)
- {
- foreach ($entitlements as $entitlement) {
- $entitlement->created_at = $targetDate;
- $entitlement->updated_at = $targetDate;
- $entitlement->save();
-
- $owner = $entitlement->wallet->owner;
- $owner->created_at = $targetDate;
- $owner->save();
- }
- }
-
/**
* Set baseURL to the admin UI location
*/
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -12,6 +12,82 @@
trait TestCaseTrait
{
+ /**
+ * A domain that is hosted.
+ *
+ * @var \App\Domain
+ */
+ protected $domainHosted;
+
+ /**
+ * The hosted domain owner.
+ *
+ * @var \App\User
+ */
+ protected $domainOwner;
+
+ /**
+ * Some profile details for an owner of a domain
+ *
+ * @var array
+ */
+ protected $domainOwnerSettings = [
+ 'first_name' => 'John',
+ 'last_name' => 'Doe',
+ 'organization' => 'Test Domain Owner',
+ ];
+
+ /**
+ * Some users for the hosted domain, ultimately including the owner.
+ *
+ * @var \App\User[]
+ */
+ protected $domainUsers = [];
+
+ /**
+ * A specific user that is a regular user in the hosted domain.
+ */
+ protected $jack;
+
+ /**
+ * A specific user that is a controller on the wallet to which the hosted domain is charged.
+ */
+ protected $jane;
+
+ /**
+ * A specific user that has a second factor configured.
+ */
+ protected $joe;
+
+ /**
+ * One of the domains that is available for public registration.
+ *
+ * @var \App\Domain
+ */
+ protected $publicDomain;
+
+ /**
+ * A newly generated user in a public domain.
+ *
+ * @var \App\User
+ */
+ protected $publicDomainUser;
+
+ /**
+ * A placeholder for a password that can be generated.
+ *
+ * Should be generated with `\App\Utils::generatePassphrase()`.
+ *
+ * @var string
+ */
+ protected $userPassword;
+
+ /**
+ * Assert that the entitlements for the user match the expected list of entitlements.
+ *
+ * @param \App\User $user The user for which the entitlements need to be pulled.
+ * @param array $expected An array of expected \App\SKU titles.
+ */
protected function assertUserEntitlements($user, $expected)
{
// Assert the user entitlements
@@ -26,6 +102,25 @@
Assert::assertSame($expected, $skus);
}
+ /**
+ * Backdate entitlements to the desired target date.
+ *
+ * @param \App\Entitlement[] $entitlements
+ * @param \Carbon\Carbon $targetDate
+ */
+ protected function backdateEntitlements($entitlements, $targetDate)
+ {
+ foreach ($entitlements as $entitlement) {
+ $entitlement->created_at = $targetDate;
+ $entitlement->updated_at = $targetDate;
+ $entitlement->save();
+
+ $owner = $entitlement->wallet->domainOwner;
+ $owner->created_at = $targetDate;
+ $owner->save();
+ }
+ }
+
/**
* Creates the application.
*
@@ -49,6 +144,7 @@
$date = Carbon::now();
$debit = 0;
$entitlementTransactions = [];
+
foreach ($wallet->entitlements as $entitlement) {
if ($entitlement->cost) {
$debit += $entitlement->cost;
@@ -59,28 +155,35 @@
}
}
- $transaction = Transaction::create([
+ $transaction = Transaction::create(
+ [
'user_email' => 'jeroen@jeroen.jeroen',
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_DEBIT,
'amount' => $debit,
'description' => 'Payment',
- ]);
+ ]
+ );
+
$result[] = $transaction;
Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]);
- $transaction = Transaction::create([
+ $transaction = Transaction::create(
+ [
'user_email' => null,
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_CREDIT,
'amount' => 2000,
'description' => 'Payment',
- ]);
+ ]
+ );
+
$transaction->created_at = $date->next(Carbon::MONDAY);
$transaction->save();
+
$result[] = $transaction;
$types = [
@@ -91,22 +194,31 @@
// The page size is 10, so we generate so many to have at least two pages
$loops = 10;
while ($loops-- > 0) {
- $transaction = Transaction::create([
- 'user_email' => 'jeroen.@jeroen.jeroen',
- 'object_id' => $wallet->id,
- 'object_type' => \App\Wallet::class,
- 'type' => $types[count($result) % count($types)],
- 'amount' => 11 * (count($result) + 1),
- 'description' => 'TRANS' . $loops,
- ]);
+ $transaction = Transaction::create(
+ [
+ 'user_email' => 'jeroen.@jeroen.jeroen',
+ 'object_id' => $wallet->id,
+ 'object_type' => \App\Wallet::class,
+ 'type' => $types[count($result) % count($types)],
+ 'amount' => 11 * (count($result) + 1),
+ 'description' => 'TRANS' . $loops,
+ ]
+ );
+
$transaction->created_at = $date->next(Carbon::MONDAY);
$transaction->save();
+
$result[] = $transaction;
}
return $result;
}
+ /**
+ * Delete a test domain whatever it takes.
+ *
+ * @coversNothing
+ */
protected function deleteTestDomain($name)
{
Queue::fake();
@@ -123,6 +235,11 @@
$domain->forceDelete();
}
+ /**
+ * Delete a test user whatever it takes.
+ *
+ * @coversNothing
+ */
protected function deleteTestUser($email)
{
Queue::fake();
@@ -139,9 +256,23 @@
$user->forceDelete();
}
+ /**
+ * Helper to access protected property of an object
+ */
+ protected static function getObjectProperty($object, $property_name)
+ {
+ $reflection = new \ReflectionClass($object);
+ $property = $reflection->getProperty($property_name);
+ $property->setAccessible(true);
+
+ return $property->getValue($object);
+ }
+
/**
* Get Domain object by namespace, create it if needed.
* Skip LDAP jobs.
+ *
+ * @coversNothing
*/
protected function getTestDomain($name, $attrib = [])
{
@@ -153,6 +284,8 @@
/**
* Get User object by email, create it if needed.
* Skip LDAP jobs.
+ *
+ * @coversNothing
*/
protected function getTestUser($email, $attrib = [])
{
@@ -171,18 +304,6 @@
return $user;
}
- /**
- * Helper to access protected property of an object
- */
- protected static function getObjectProperty($object, $property_name)
- {
- $reflection = new \ReflectionClass($object);
- $property = $reflection->getProperty($property_name);
- $property->setAccessible(true);
-
- return $property->getValue($object);
- }
-
/**
* Call protected/private method of a class.
*
@@ -200,4 +321,90 @@
return $method->invokeArgs($object, $parameters);
}
+
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->userPassword = \App\Utils::generatePassphrase();
+
+ $this->domainHosted = $this->getTestDomain(
+ 'test.domain',
+ [
+ 'type' => \App\Domain::TYPE_EXTERNAL,
+ 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED
+ ]
+ );
+
+ $packageKolab = \App\Package::where('title', 'kolab')->first();
+
+ $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]);
+ $this->domainOwner->assignPackage($packageKolab);
+ $this->domainOwner->setSettings($this->domainOwnerSettings);
+
+ // separate for regular user
+ $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]);
+
+ // separate for wallet controller
+ $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]);
+
+ $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]);
+
+ $this->domainUsers[] = $this->jack;
+ $this->domainUsers[] = $this->jane;
+ $this->domainUsers[] = $this->joe;
+ $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]);
+
+ foreach ($this->domainUsers as $user) {
+ $this->domainOwner->assignPackage($packageKolab, $user);
+ }
+
+ $this->domainUsers[] = $this->domainOwner;
+
+ // assign second factor to joe
+ $this->joe->assignSku(\App\Sku::where('title', '2fa')->first());
+ \App\Auth\SecondFactor::seed($this->joe->email);
+
+ usort(
+ $this->domainUsers,
+ function ($a, $b) {
+ return $a->email > $b->email;
+ }
+ );
+
+ $this->domainHosted->assignPackage(
+ \App\Package::where('title', 'domain-hosting')->first(),
+ $this->domainOwner
+ );
+
+ $wallet = $this->domainOwner->wallets()->first();
+
+ $wallet->addController($this->jane);
+
+ $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first();
+ $this->publicDomainUser = $this->getTestUser(
+ 'john@' . $this->publicDomain->namespace,
+ ['password' => $this->userPassword]
+ );
+
+ $this->publicDomainUser->assignPackage($packageKolab);
+ }
+
+ public function tearDown(): void
+ {
+ foreach ($this->domainUsers as $user) {
+ if ($user == $this->domainOwner) {
+ continue;
+ }
+
+ $this->deleteTestUser($user->email);
+ }
+
+ $this->deleteTestUser($this->domainOwner->email);
+ $this->deleteTestDomain($this->domainHosted->namespace);
+
+ $this->deleteTestUser($this->publicDomainUser->email);
+
+ parent::tearDown();
+ }
}