anonaddy/postfix/AccessPolicy.php
2023-10-04 11:32:39 +01:00

352 lines
11 KiB
PHP

<?php
require __DIR__.'/vendor/autoload.php';
use Illuminate\Database\Capsule\Manager as Database;
use ParagonIE\ConstantTime\Base32;
try {
$repository = Dotenv\Repository\RepositoryBuilder::createWithDefaultAdapters()
->allowList([
'DB_HOST',
'DB_PORT',
'DB_DATABASE',
'DB_USERNAME',
'DB_PASSWORD',
'DB_SOCKET',
'MYSQL_ATTR_SSL_CA',
'ACTION_DOES_NOT_EXIST',
'ACTION_ALIAS_DISCARD',
'ACTION_USERNAME_DISCARD',
'ACTION_DOMAIN_DISCARD',
'ACTION_REJECT',
'ACTION_DEFER',
'ACTION_DEFER_NEW',
'ANONADDY_ALL_DOMAINS',
'ANONADDY_SECRET',
'ANONADDY_ADMIN_USERNAME',
])
->make();
$dotenv = Dotenv\Dotenv::create($repository, dirname(__DIR__));
$dotenv->load();
$database = new Database();
$database->addConnection([
'driver' => 'mysql',
'read' => [
'host' => [
$_ENV['DB_HOST'] ?? '127.0.0.1',
],
],
'write' => [
'host' => [
$_ENV['DB_HOST'] ?? '127.0.0.1',
],
],
'port' => $_ENV['DB_PORT'] ?? '3306',
'database' => $_ENV['DB_DATABASE'] ?? 'forge',
'username' => $_ENV['DB_USERNAME'] ?? 'forge',
'password' => $_ENV['DB_PASSWORD'] ?? '',
'unix_socket' => $_ENV['DB_SOCKET'] ?? '',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => $_ENV['MYSQL_ATTR_SSL_CA'] ?? null,
]) : [],
'sticky' => true,
]);
// Make this instance available globally via static methods
$database->setAsGlobal();
// Define actions, these can be overridden by adding the variables to your .env file
// e.g. ACTION_DOES_NOT_EXIST='550 5.1.1 User not found'
define('ACTION_DOES_NOT_EXIST', $_ENV['ACTION_DOES_NOT_EXIST'] ?? '550 5.1.1 Address does not exist');
define('ACTION_ALIAS_DISCARD', $_ENV['ACTION_ALIAS_DISCARD'] ?? 'DISCARD is inactive alias');
define('ACTION_USERNAME_DISCARD', $_ENV['ACTION_USERNAME_DISCARD'] ?? 'DISCARD has inactive username');
define('ACTION_DOMAIN_DISCARD', $_ENV['ACTION_DOMAIN_DISCARD'] ?? 'DISCARD has inactive domain');
define('ACTION_REJECT', $_ENV['ACTION_REJECT'] ?? '552 5.2.2 User over quota');
define('ACTION_DEFER', $_ENV['ACTION_DEFER'] ?? '452 4.2.2 User over quota');
define('ACTION_DEFER_NEW', $_ENV['ACTION_DEFER_NEW'] ?? '450 4.2.1 User over quota');
$args = getArgs();
$allDomains = explode(',', $_ENV['ANONADDY_ALL_DOMAINS'] ?? '');
$adminUsername = $_ENV['ANONADDY_ADMIN_USERNAME'] ?? null;
$aliasEmail = strtolower($args['recipient']);
// If no alias email is provided then exit
if (empty($aliasEmail) || empty($allDomains)) {
logData('No alias email or $allDomains not set.');
exit(0);
}
//$senderEmail = strtolower($args['sender']);
$aliasDomain = explode('@', $aliasEmail)[1];
$aliasHasSharedDomain = in_array($aliasDomain, $allDomains);
// Check if it is a bounce with a valid VERP...
if (substr($aliasEmail, 0, 2) === 'b_') {
if ($outboundMessageId = getIdFromVerp($aliasEmail)) {
// Is a valid bounce
$outboundMessage = Database::table('outbound_messages')->find($outboundMessageId);
// If there is no outbound message found or no alias_id then reject since we cannot forward this to the user
if (is_null($outboundMessage) || (is_null($outboundMessage?->alias_id) && $outboundMessage->bounced)) {
// Must have been more than 7 days or a notification that has already bounced
sendAction(ACTION_DOES_NOT_EXIST);
} else {
// Allow through, may be an auto-reply etc.
sendAction('DUNNO');
}
// exit to prevent running the rest of the script
exit(0);
}
}
// Check if the alias has a username subdomain
$aliasHasUsernameDomain = ! empty(array_filter($allDomains, fn ($domain) => endsWith($aliasDomain, ".{$domain}")));
// If the alias has a plus extension then remove it
if (str_contains($aliasEmail, '+')) {
$aliasEmail = before($aliasEmail, '+').'@'.afterLast($aliasEmail, '@');
}
// Check if the alias already exists or not
$noAliasExists = Database::table('aliases')->select('id')->where('email', $aliasEmail)->doesntExist();
if ($noAliasExists && $aliasHasSharedDomain) {
// If admin username is set then allow through with catch-all
if ($adminUsername) {
sendAction('DUNNO');
} else {
sendAction(ACTION_DOES_NOT_EXIST);
}
} else {
$aliasAction = null;
if (! $noAliasExists) {
$aliasActionQuery = Database::table('aliases')
->leftJoin('users', 'aliases.user_id', '=', 'users.id')
->where('aliases.email', $aliasEmail)
->selectRaw('CASE
WHEN aliases.deleted_at IS NOT NULL THEN ?
WHEN aliases.active = 0 THEN ?
WHEN users.reject_until > NOW() THEN ?
WHEN users.defer_until > NOW() THEN ?
ELSE "DUNNO"
END', [
ACTION_DOES_NOT_EXIST,
ACTION_ALIAS_DISCARD,
ACTION_REJECT,
ACTION_DEFER,
])
->first();
$aliasAction = getAction($aliasActionQuery);
}
if (in_array($aliasAction, [ACTION_ALIAS_DISCARD, ACTION_DOES_NOT_EXIST])) {
// If the alias is inactive or deleted then increment the blocked count
Database::table('aliases')
->where('email', $aliasEmail)
->increment('emails_blocked');
sendAction($aliasAction);
} elseif ($aliasHasSharedDomain || in_array($aliasAction, [ACTION_REJECT, ACTION_DEFER])) {
// If the alias has a shared domain then we don't need to check the usernames or domains
sendAction($aliasAction);
} elseif ($aliasHasUsernameDomain) {
$concatDomainsStatement = array_reduce(array_keys($allDomains), function ($carry, $key) {
$comma = $key === 0 ? '' : ',';
return "{$carry}{$comma}CONCAT(usernames.username, ?)";
}, '');
$dotDomains = array_map(fn ($domain) => ".{$domain}", $allDomains);
$usernameActionQuery = Database::table('usernames')
->leftJoin('users', 'usernames.user_id', '=', 'users.id')
->whereRaw('? IN ('.$concatDomainsStatement.')', [$aliasDomain, ...$dotDomains])
->selectRaw('CASE
WHEN ? AND usernames.catch_all = 0 THEN ?
WHEN usernames.active = 0 THEN ?
WHEN users.reject_until > NOW() THEN ?
WHEN users.defer_until > NOW() THEN ?
WHEN ? AND users.defer_new_aliases_until > NOW() THEN ?
ELSE "DUNNO"
END', [
$noAliasExists,
ACTION_DOES_NOT_EXIST,
ACTION_USERNAME_DISCARD,
ACTION_REJECT,
ACTION_DEFER,
$noAliasExists,
ACTION_DEFER_NEW,
])
->first();
sendAction(getAction($usernameActionQuery));
} else {
$domainActionQuery = Database::table('domains')
->leftJoin('users', 'domains.user_id', '=', 'users.id')
->where('domains.domain', $aliasDomain)
->selectRaw('CASE
WHEN ? AND domains.catch_all = 0 THEN ?
WHEN domains.active = 0 THEN ?
WHEN users.reject_until > NOW() THEN ?
WHEN users.defer_until > NOW() THEN ?
WHEN ? AND users.defer_new_aliases_until > NOW() THEN ?
ELSE "DUNNO"
END', [
$noAliasExists,
ACTION_DOES_NOT_EXIST,
ACTION_DOMAIN_DISCARD,
ACTION_REJECT,
ACTION_DEFER,
$noAliasExists,
ACTION_DEFER_NEW,
])
->first();
sendAction(getAction($domainActionQuery));
}
}
} catch (\Exception $e) {
logData($e->getMessage());
exit(0);
}
// Get the arguments sent by Postfix
function getArgs()
{
$args = [];
while ($line = trim(fgets(STDIN))) {
[$key, $value] = explode('=', $line, 2);
$args[$key] = $value;
}
return $args;
}
// Get the action from the action query result
function getAction($actionQuery)
{
return is_object($actionQuery) ? array_values(get_object_vars($actionQuery))[0] : null;
}
// Send the action back to Postfix
function sendAction($action)
{
echo 'action='.$action."\n\n";
}
// Get the outbound message ID from the VERP address
function getIdFromVerp($verp)
{
$localPart = beforeLast($verp, '@');
$parts = explode('_', $localPart);
if (count($parts) !== 3) {
//logData('VERP invalid email: '.$verp);
return;
}
try {
$id = Base32::decodeNoPadding($parts[1]);
$signature = Base32::decodeNoPadding($parts[2]);
} catch (\Exception $e) {
logData('VERP base32 decode failure: '.$verp.' '.$e->getMessage());
return;
}
$expectedSignature = substr(hash_hmac('sha3-224', $id, $_ENV['ANONADDY_SECRET'] ?? ''), 0, 8);
if ($signature !== $expectedSignature) {
logData('VERP invalid signature: '.$verp);
return;
}
return $id;
}
// Get the portion of a string before the first occurrence of a given value
function before($subject, $search)
{
if ($search === '') {
return $subject;
}
$result = strstr($subject, (string) $search, true);
return $result === false ? $subject : $result;
}
// Get the portion of a string before the last occurrence of a given value.
function beforeLast($subject, $search)
{
if ($search === '') {
return $subject;
}
$pos = mb_strrpos($subject, $search);
if ($pos === false) {
return $subject;
}
return mb_substr($subject, 0, $pos, 'UTF-8');
}
// Return the remainder of a string after the last occurrence of a given value
function afterLast($subject, $search)
{
if ($search === '') {
return $subject;
}
$position = strrpos($subject, (string) $search);
if ($position === false) {
return $subject;
}
return substr($subject, $position + strlen($search));
}
// Determine if a given string ends with a given substring
function endsWith($haystack, $needles)
{
if (! is_iterable($needles)) {
$needles = (array) $needles;
}
foreach ($needles as $needle) {
if ((string) $needle !== '' && str_ends_with($haystack, $needle)) {
return true;
}
}
return false;
}
function logData($data)
{
file_put_contents(__DIR__.'/../storage/logs/postfix-access-policy.log', '['.(new DateTime())->format('Y-m-d H:i:s').'] '.$data.PHP_EOL, FILE_APPEND);
}