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. | |||||
vanmeeuwenAuthorUnsubmitted 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( | |||||
mollekopfUnsubmitted 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 | |||||
] | |||||
)->first(); | |||||
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->entitlements->first()->wallet; | |||||
machniakUnsubmitted Done Inline ActionsYou can do $user->wallet(). machniak: You can do `$user->wallet()`. | |||||
vanmeeuwenAuthorUnsubmitted Done Inline ActionsI don't see a function wallet(). vanmeeuwen: I don't see a function wallet(). | |||||
$owner = $wallet->owner; | |||||
machniakUnsubmitted 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) { | ||||
mollekopfUnsubmitted 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. | |||||
vanmeeuwenAuthorUnsubmitted 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… | |||||
// TODO | return response()->json(['response' => 'HOLD', 'reason' => 'Sender without a wallet'], 403); | ||||
} | } | ||||
*/ | |||||
// 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 { | |||||
$request->updated_at = \Carbon\Carbon::now(); | |||||
mollekopfUnsubmitted 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… | |||||
vanmeeuwenAuthorUnsubmitted 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->save(); | |||||
} | |||||
// excempt owners that have made their payments, or are at 100% discount | |||||
$payments = $wallet->payments | |||||
machniakUnsubmitted 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… | |||||
vanmeeuwenAuthorUnsubmitted 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… | |||||
->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 | |||||
if ($ownerRates->count() >= 25) { | |||||
$wallet->entitlements->each( | |||||
function ($entitlement) use ($owner) { | |||||
// older than 2 months do not deserve automatic suspension | |||||
if ($owner->created_at <= \Carbon\Carbon::now()->subMonthsWithoutOverflow(2)) { | |||||
mollekopfUnsubmitted 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 | |||||
return; | |||||
} | |||||
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()) | |||||
->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 | |||||
if ($ownerRates >= 250) { | |||||
$wallet->entitlements->each( | |||||
function ($entitlement) use ($owner) { | |||||
// older than 2 months do not deserve automatic suspension | |||||
if ($owner->created_at <= \Carbon\Carbon::now()->subMonthsWithoutOverflow(2)) { | |||||
mollekopfUnsubmitted 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 | |||||
return; | |||||
} | |||||
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); | |||||
} | |||||
// | |||||
// Examine the rates at which the user is sending | |||||
// | |||||
$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 | |||||
if ($userRates->count() >= 25) { | |||||
// users older than 2 months do not deserve automatic suspension | |||||
if ($user->created_at > \Carbon\Carbon::now()->subMonthsWithoutOverflow(2)) { | |||||
$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 | |||||
if ($userRates >= 250) { | |||||
// users older than 2 months do not deserve automatic suspension | |||||
if ($user->created_at > \Carbon\Carbon::now()->subMonthsWithoutOverflow(2)) { | |||||
$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(); | ||||
Done Inline Actionsuse ($owner) is redundant. machniak: `use ($owner)` is redundant. | |||||
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)); | ||||
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… | |||||
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 (' . __LINE__ . ')' | ||||
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.