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(); + } }