Changeset View
Standalone View
src/app/Http/Controllers/API/V4/PolicyController.php
Show First 20 Lines • Show All 41 Lines • ▼ Show 20 Lines | class PolicyController extends Controller | ||||
/* | /* | ||||
* Apply a sensible rate limitation to a request. | * Apply a sensible rate limitation to a request. | ||||
* | * | ||||
* @return \Illuminate\Http\JsonResponse | * @return \Illuminate\Http\JsonResponse | ||||
*/ | */ | ||||
public function ratelimit() | public function ratelimit() | ||||
{ | { | ||||
/* | |||||
$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); | |||||
*/ | |||||
/* | |||||
$data = \request()->input(); | $data = \request()->input(); | ||||
// TODO: normalize sender address | |||||
$sender = strtolower($data['sender']); | $sender = strtolower($data['sender']); | ||||
$alias = \App\UserAlias::where('alias', $sender)->first(); | if (strpos($sender, '+') !== false) { | ||||
list($local, $rest) = explode('+', $sender); | |||||
list($rest, $domain) = explode('@', $sender); | |||||
$sender = "{$local}@{$domain}"; | |||||
} | |||||
if (!$alias) { | list($local, $domain) = explode('@', $sender); | ||||
mollekopf: Remove as this is never used. | |||||
Done Inline ActionsUsed on line 90. vanmeeuwen: Used on line 90. | |||||
if (in_array($sender, \config('app.ratelimit_whitelist', []))) { | |||||
return response()->json(['response' => 'DUNNO'], 200); | |||||
} | |||||
// | |||||
// Examine the individual sender | |||||
// | |||||
$user = \App\User::where('email', $sender)->first(); | $user = \App\User::where('email', $sender)->first(); | ||||
if (!$user) { | if (!$user) { | ||||
// what's the situation here? | $alias = \App\UserAlias::where('alias', $sender)->first(); | ||||
if (!$alias) { | |||||
// use HOLD, so that it is silent (as opposed to REJECT) | |||||
return response()->json(['response' => 'HOLD', 'reason' => 'Sender not allowed here.'], 403); | |||||
} | } | ||||
} else { | |||||
$user = $alias->user; | $user = $alias->user; | ||||
} | } | ||||
// TODO time-limit | if ($user->isDeleted() || $user->isSuspended()) { | ||||
$userRates = \App\Policy\Ratelimit::where('user_id', $user->id); | // use HOLD, so that it is silent (as opposed to REJECT) | ||||
return response()->json(['response' => 'HOLD', 'reason' => 'Sender deleted or suspended'], 403); | |||||
} | |||||
// | |||||
// Examine the domain | |||||
// | |||||
$domain = \App\Domain::where('namespace', $domain)->first(); | |||||
if (!$domain) { | |||||
// external sender through where this policy is applied | |||||
return response()->json(['response' => 'DUNNO'], 200); | |||||
} | |||||
// TODO message vs. recipient limit | if ($domain->isDeleted() || $domain->isSuspended()) { | ||||
if ($userRates->count() > 10) { | // use HOLD, so that it is silent (as opposed to REJECT) | ||||
// TODO | return response()->json(['response' => 'HOLD', 'reason' => 'Sender domain deleted or suspended'], 403); | ||||
} | } | ||||
// this is the wallet to which the account is billed | // see if the user or domain is whitelisted | ||||
$wallet = $user->wallet; | // use ./artisan policy:ratelimit:whitelist:create <email|namespace> | ||||
$whitelist = \App\Policy\RateLimitWhitelist::where( | |||||
Done Inline Actions->exists() would be good enough for the usecase I think (instead of retrieving the record. mollekopf: ->exists() would be good enough for the usecase I think (instead of retrieving the record. | |||||
[ | |||||
'whitelistable_type' => \App\User::class, | |||||
'whitelistable_id' => $user->id | |||||
] | |||||
)->orWhere( | |||||
[ | |||||
'whitelistable_type' => \App\Domain::class, | |||||
'whitelistable_id' => $domain->id | |||||
] | |||||
)->exists(); | |||||
if ($whitelist) { | |||||
return response()->json(['response' => 'DUNNO'], 200); | |||||
} | |||||
// TODO: consider $wallet->payments; | // user nor domain whitelisted, continue scrutinizing request | ||||
$recipients = $data['recipients']; | |||||
sort($recipients); | |||||
$owner = $wallet->user; | $recipientCount = count($recipients); | ||||
$recipientHash = hash('sha256', implode(',', $recipients)); | |||||
// TODO time-limit | // | ||||
$ownerRates = \App\Policy\Ratelimit::where('owner_id', $owner->id); | // Retrieve the wallet to get to the owner | ||||
// | |||||
$wallet = $user->wallet(); | |||||
Done Inline ActionsYou can do $user->wallet(). machniak: You can do `$user->wallet()`. | |||||
Done Inline ActionsI don't see a function wallet(). vanmeeuwen: I don't see a function wallet(). | |||||
Done Inline ActionsFirst check if $wallet is not null, then try to get the owner. I'd also check if the $owner is not empty. machniak: First check if $wallet is not null, then try to get the owner. I'd also check if the $owner is… | |||||
// TODO message vs. recipient limit (w/ user counts) | // wait, there is no wallet? | ||||
if ($ownerRates->count() > 10) { | if (!$wallet) { | ||||
// TODO | return response()->json(['response' => 'HOLD', 'reason' => 'Sender without a wallet'], 403); | ||||
Done Inline ActionsSounds like we should log an error at this point as this should never happen. mollekopf: Sounds like we should log an error at this point as this should never happen. | |||||
Done Inline ActionsThe effect is already as desired -- we're not sending the email. We're basically just validating $wallet is something and not nothing, so as to avoid running in to error conditions later. vanmeeuwen: The effect is already as desired -- we're not sending the email. We're basically just… | |||||
} | } | ||||
*/ | |||||
$owner = $wallet->owner; | |||||
// find or create the request | |||||
$request = \App\Policy\RateLimit::where( | |||||
[ | |||||
'recipient_hash' => $recipientHash, | |||||
'user_id' => $user->id | |||||
] | |||||
)->where('updated_at', '>=', \Carbon\Carbon::now()->subHour())->first(); | |||||
if (!$request) { | |||||
$request = \App\Policy\RateLimit::create( | |||||
[ | |||||
'user_id' => $user->id, | |||||
'owner_id' => $owner->id, | |||||
'recipient_hash' => $recipientHash, | |||||
'recipient_count' => $recipientCount | |||||
] | |||||
); | |||||
// ensure the request has an up to date timestamp | |||||
} else { | |||||
Done Inline ActionsDoes this codepath not mean that you can basically send as many messages as you want to the same set of recipients? Perhaps we should keep track of that as well? mollekopf: Does this codepath not mean that you can basically send as many messages as you want to the… | |||||
Done Inline ActionsYes, you could, but that's not how spammers behave. At this stage though, we don't have any information that uniquely identifies one such message from another such message. vanmeeuwen: Yes, you could, but that's not how spammers behave.
At this stage though, we don't have any… | |||||
$request->updated_at = \Carbon\Carbon::now(); | |||||
$request->save(); | |||||
} | |||||
// excempt owners that have made at least two payments and currently maintain a positive balance. | |||||
Done Inline ActionsWhat about "or are at 100% discount"? Also, you coould do the balance check earlier for better performance. If balance>0 there's no need to check for payments. machniak: What about "or are at 100% discount"? Also, you coould do the balance check earlier for better… | |||||
Done Inline ActionsI was contemplating doing that but since there's public 100% discount codes it wouldn't suffice to combat spammers. Updating commentary. The need to get to count() is because I want >= 2 payments, so just checking ->balance > 0 is not enough. vanmeeuwen: I was contemplating doing that but since there's public 100% discount codes it wouldn't suffice… | |||||
$payments = $wallet->payments | |||||
->where('amount', '>', 0) | |||||
->where('status', 'paid'); | |||||
if ($payments->count() >= 2 && $wallet->balance > 0) { | |||||
return response()->json(['response' => 'DUNNO'], 200); | |||||
} | |||||
// | |||||
// Examine the rates at which the owner (or its users) is sending | |||||
// | |||||
$ownerRates = \App\Policy\RateLimit::where('owner_id', $owner->id) | |||||
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()); | |||||
if ($ownerRates->count() >= 10) { | |||||
$result = [ | |||||
'response' => 'DEFER_IF_PERMIT', | |||||
'reason' => 'The account is at 10 messages per hour, cool down.' | |||||
]; | |||||
// automatically suspend (recursively) if 2.5 times over the original limit and younger than two months | |||||
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); | |||||
if ($ownerRates->count() >= 25 && $owner->created_at > $ageThreshold) { | |||||
Done Inline Actionsuse ($owner) is redundant. machniak: `use ($owner)` is redundant. | |||||
$wallet->entitlements->each( | |||||
Done Inline ActionsThis can be moved outside of the each into the if on line 184 mollekopf: This can be moved outside of the each into the if on line 184 | |||||
function ($entitlement) { | |||||
if ($entitlement->entitleable_type == \App\Domain::class) { | |||||
$entitlement->entitleable->suspend(); | |||||
} | |||||
if ($entitlement->entitleable_type == \App\User::class) { | |||||
$entitlement->entitleable->suspend(); | |||||
} | |||||
} | |||||
); | |||||
} | |||||
return response()->json($result, 403); | |||||
} | |||||
$ownerRates = \App\Policy\RateLimit::where('owner_id', $owner->id) | |||||
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()) | |||||
Done Inline ActionsI guess we could skip this if it's a personal account (user == owner)? machniak: I guess we could skip this if it's a personal account (user == owner)? | |||||
Done Inline ActionsYeah, not always a personal account of course, but it's redundant for $user->id == $owner->id indeed. vanmeeuwen: Yeah, not always a personal account of course, but it's redundant for $user->id == $owner->id… | |||||
->sum('recipient_count'); | |||||
if ($ownerRates >= 100) { | |||||
$result = [ | |||||
'response' => 'DEFER_IF_PERMIT', | |||||
'reason' => 'The account is at 100 recipients per hour, cool down.' | |||||
]; | |||||
// automatically suspend if 2.5 times over the original limit and younger than two months | |||||
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); | |||||
if ($ownerRates >= 250 && $owner->created_at > $ageThreshold) { | |||||
$wallet->entitlements->each( | |||||
function ($entitlement) { | |||||
if ($entitlement->entitleable_type == \App\Domain::class) { | |||||
$entitlement->entitleable->suspend(); | |||||
Done Inline ActionsThis can be moved outside of the each into the if on line 217 mollekopf: This can be moved outside of the each into the if on line 217 | |||||
} | |||||
if ($entitlement->entitleable_type == \App\User::class) { | |||||
$entitlement->entitleable->suspend(); | |||||
} | |||||
} | |||||
); | |||||
} | |||||
return response()->json($result, 403); | |||||
} | |||||
// | |||||
// Examine the rates at which the user is sending (if not also the owner | |||||
// | |||||
if ($user->id != $owner->id) { | |||||
$userRates = \App\Policy\RateLimit::where('user_id', $user->id) | |||||
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()); | |||||
if ($userRates->count() >= 10) { | |||||
$result = [ | |||||
'response' => 'DEFER_IF_PERMIT', | |||||
'reason' => 'User is at 10 messages per hour, cool down.' | |||||
]; | |||||
// automatically suspend if 2.5 times over the original limit and younger than two months | |||||
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); | |||||
if ($userRates->count() >= 25 && $user->created_at > $ageThreshold) { | |||||
$user->suspend(); | |||||
} | |||||
return response()->json($result, 403); | |||||
} | |||||
$userRates = \App\Policy\RateLimit::where('user_id', $user->id) | |||||
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()) | |||||
->sum('recipient_count'); | |||||
if ($userRates >= 100) { | |||||
$result = [ | |||||
'response' => 'DEFER_IF_PERMIT', | |||||
'reason' => 'User is at 100 recipients per hour, cool down.' | |||||
]; | |||||
// automatically suspend if 2.5 times over the original limit | |||||
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); | |||||
if ($userRates >= 250 && $user->created_at > $ageThreshold) { | |||||
$user->suspend(); | |||||
} | |||||
return response()->json($result, 403); | |||||
} | |||||
} | |||||
$result = [ | |||||
'response' => 'DUNNO' | |||||
]; | |||||
return response()->json($result, 200); | |||||
} | } | ||||
/* | /* | ||||
* Apply the sender policy framework to a request. | * Apply the sender policy framework to a request. | ||||
* | * | ||||
* @return \Illuminate\Http\JsonResponse | * @return \Illuminate\Http\JsonResponse | ||||
*/ | */ | ||||
public function senderPolicyFramework() | public function senderPolicyFramework() | ||||
{ | { | ||||
$data = \request()->input(); | $data = \request()->input(); | ||||
if (!array_key_exists('client_address', $data)) { | if (!array_key_exists('client_address', $data)) { | ||||
\Log::error("SPF: Request without client_address: " . json_encode($data)); | \Log::error("SPF: Request without client_address: " . json_encode($data)); | ||||
return response()->json( | return response()->json( | ||||
[ | [ | ||||
'response' => 'DEFER_IF_PERMIT', | 'response' => 'DEFER_IF_PERMIT', | ||||
'reason' => 'Temporary error. Please try again later (' . __LINE__ . ')' | 'reason' => 'Temporary error. Please try again later.' | ||||
], | ], | ||||
403 | 403 | ||||
); | ); | ||||
} | } | ||||
list($netID, $netType) = \App\Utils::getNetFromAddress($data['client_address']); | list($netID, $netType) = \App\Utils::getNetFromAddress($data['client_address']); | ||||
// This network can not be recognized. | // This network can not be recognized. | ||||
if (!$netID) { | if (!$netID) { | ||||
\Log::error("SPF: Request without recognizable network: " . json_encode($data)); | \Log::error("SPF: Request without recognizable network: " . json_encode($data)); | ||||
return response()->json( | return response()->json( | ||||
[ | [ | ||||
'response' => 'DEFER_IF_PERMIT', | 'response' => 'DEFER_IF_PERMIT', | ||||
'reason' => 'Temporary error. Please try again later (' . __LINE__ . ')' | 'reason' => 'Temporary error. Please try again later.' | ||||
Done Inline ActionsI guess the line reference here could be also removed. machniak: I guess the line reference here could be also removed. | |||||
], | ], | ||||
403 | 403 | ||||
); | ); | ||||
} | } | ||||
$senderLocal = 'unknown'; | $senderLocal = 'unknown'; | ||||
$senderDomain = 'unknown'; | $senderDomain = 'unknown'; | ||||
▲ Show 20 Lines • Show All 113 Lines • Show Last 20 Lines |
Remove as this is never used.