Postfix, Rate Limiting Inbound Emails Using SenderScore And Memcache

I received email from someone fiew days ago, he directed me to an article about senderscore and and asked if I could make it usable. Actually, I’m not very familiar with how senderscore work. I’ve read the article and see the FAQ at https://senderscore.org/. I have found that senderscore can be queried with a format like this:

reversed.ip.address.score.senderscore.com

Ie, I want to know the score value of ip address 202.127.97.97, the format of the query would be like this:

$ dig a 97.97.127.202.score.senderscore.com +short
127.0.4.75

Look at the answers given by senderscore’s NS. last octet is the score of the ip address 202.127.97.97, which scored 75.

Excerpts from senderscore faq:

All scores are based on a scale of 0 to 100, where 0 is the worst, and 100 is the best possible score. A score represents that IP address’s rank as measured against other IP addresses, much like a percentile ranking.

Now back to the article, The authors make a perl module that can perform queries to senderscore ns, put a “reputation score” into memcache, at the same time, calculating how many times an ip address connected to our smtp.

Let’s begin, first of all download Policy::Memcache from this git repository 
Create a working directory, and extract the tarball.

$ mkdir pol-mem && cd pol-mem
$ tar --extract --file=petermblair-libemail-f73612c.tar.gz petermblair-libemail-f73612c/perl/senderscore/memcache/
$ mv petermblair-libemail-f73612c/perl/senderscore/memcache/* .


At this point now we should have Policy directory containing Memcache.pm and SenderScore.pm and also t.pl (this used for test ge and increment). About the policy itself, since there is no standardization of how to implement the score from senderscore I will try to make our own policy class.

The idea is, we will restrict the number of emails from one ip address coming into our server within one hour.

Score 1 – 10, limited sending 100 emails per hour.
Score 11 – 20, limited sending 200 emails per hour.
Score 21 – 30, limited sending 300 emails per hour.
Score 31 – 40, limited sending 400 emails per hour.
Score 41 – 50, limited sending 500 emails per hour.
Score 51 – 60, limited sending 600 emails per hour.


Score 91 – 100, limited sending 1000 emails per hour.

Now, create a policy server, to implement our policy class, I modify the script a little bit that I downloaded from here

#!/usr/bin/perl
#
use strict;
use warnings;
use lib ( "." );
use Policy::Memcache;
use IO::Socket;
use threads;
use Proc::Daemon;
use Sys::Syslog qw(:DEFAULT setlogsock);
use Data::Dumper;

# Global config settings
my $TC = 15;
my $debug = 1;
our $pidfile = "/var/run/policy.pid";

my $mc = Policy::Memcache->new;

# Param1: Client socket
# Param2: hash_ref
sub parse_postfix_input( $$ ) {
        my ($socket,$hashref) = @_;

        while( my $line = <$socket> ){
                return if $line =~ /^(\r|\n)*$/;
                #print "DEBUG: $line" if $debug;
                if( $line =~ /^(\w+?)=(.+)$/ ){
                        $hashref->{$1} = $2;
                        $hashref->{$1} =~ s/\015?\012?$//;
                }
        }
}

sub process_policy_request( $ ){
        my ($href) = @_;
        my $action = "DUNNO";
        my $messages_limit;

        # Do something with the href that we've consumed...
        my $client_ip = $href->{client_address};
        my ($messages_count,$ss) = $mc->get( $client_ip );

        if ($ss == -1 ) {
                $action = 'DUNNO';
                $messages_limit = -1;
        }

        $mc = Policy::Memcache->new( -policy => 'ss' );
        $mc->increment( $client_ip );

        # classifying senderscore value, 100 messages / 10 score
        # last rule is unlimmited which has senderscore 90 - 100
        if (($ss >= 1) && ($ss <= 10)) {
                $action = ($messages_count <= 100) ? 'DUNNO' : 'REJECT';
                $messages_limit = 100;
        } elsif (($ss >= 11) && ($ss <= 20)) {
                $action = ($messages_count <= 200) ? 'DUNNO' : 'REJECT';
                $messages_limit = 200;
        } elsif (($ss >= 21) && ($ss <= 30)) {
                $action = ($messages_count <= 300) ? 'DUNNO' : 'REJECT';
                $messages_limit = 300;
        } elsif (($ss >= 31) && ($ss <= 40)) {
                $action = ($messages_count <= 400) ? 'DUNNO' : 'REJECT';
                $messages_limit = 400;
        } elsif (($ss >= 41) && ($ss <= 50)) {
                $action = ($messages_count <= 500) ? 'DUNNO' : 'REJECT';
                $messages_limit = 500;
        } elsif (($ss >= 51) && ($ss <= 60)) {
                $action = ($messages_count <= 600) ? 'DUNNO' : 'REJECT';
                $messages_limit = 600;
        } elsif (($ss >= 61) && ($ss <= 70)) {
                $action = ($messages_count <= 700) ? 'DUNNO' : 'REJECT';
                $messages_limit = 700;
        } elsif (($ss >= 71) && ($ss <= 80)) {
                $action = ($messages_count <= 800) ? 'DUNNO' : 'REJECT';
                $messages_limit = 800;
        } elsif (($ss >= 81) && ($ss <= 90)) {
                $action = ($messages_count <= 900) ? 'DUNNO' : 'REJECT';
                $messages_limit = 900;
        } elsif (($ss >= 91) && ($ss <= 100)) {
                $action = ($messages_count <= 1000) ? 'DUNNO' : 'REJECT';
                $messages_limit = 1000;
        }

        return ($action, $client_ip, $ss, $messages_count, $messages_limit);
}

sub process_client($){
        my ($socket) = @_;
        ACCEPT: while( my $client = $socket->accept() ){
                my $hash_ref = {};
                parse_postfix_input( $client, $hash_ref );

                #print "DEBUG: " . Dumper( $hash_ref ) . "\n";

                my @res = process_policy_request( $hash_ref );
                if( $res[0] eq 'REJECT' ){
                        syslog('info', "$res[0]: client ip: $res[1], sender score: $res[2], message count: $res[3], messages limit: $res[4]");
                        print $client "action=421 sender score = $res[2], you're limited to $res[4]  messages/hours in our policy, try again later\n\n";
                        next ACCEPT;
                }
                print $client "action=dunno\n\n";
        }
}

sub handle_sig_int
{
        unlink( $pidfile );
        exit(0);
}

openlog('policy', '', 'mail');
syslog('info', 'launching in daemon mode') if (defined($ARGV[0]) && $ARGV[0] eq 'quiet-quick-start');
Proc::Daemon::Init if (defined($ARGV[0]) && $ARGV[0] eq 'quiet-quick-start');

# Attempt to parse in the redirect config

$SIG{INT} = \&handle_sig_int;

# Ignore client disconnects
$SIG{PIPE} = "IGNORE";

open PID, "+>", "$pidfile" or die("Cannot open $pidfile: $!\n");
print PID "$$";
close( PID );

my $server = IO::Socket::INET->new(
    LocalAddr => 'localhost',
    LocalPort => 1234,
    Type      => SOCK_STREAM,
    Reuse     => 1,
    Listen    => 10
  )
  or die
  "Couldn't be a tcp server on port" . my $default_config->{serverport} . ": $@\n";

# Generate a number of listener threads
my @threads = ();
for( 1 .. $TC ){
        my $thread = threads->create( \&process_client, $server );
        push( @threads, $thread );
}

foreach my $thread ( @threads ){
        $thread->join();
}

unlink( $pidfile );
closelog;
exit( 0 );

Make the script executable and run it in the background

$ chmod 755 policy.pl
$ sudo ./policy.pl quiet-quick-start

Simple test using telnel localhost 1234

$ telnet localhost 1234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
request=smtpd_access_policy
protocol_state=RCPT
protocol_name=SMTP
helo_name=some.domain.tld
queue_id=8045F2AB23
sender=foo@bar.tld
recipient=bar@foo.tld
recipient_count=0
client_address=202.127.97.97
client_name=another.domain.tld
reverse_client_name=another.domain.tld
instance=123.456.7

action=dunno

Connection closed by foreign host.

After several test via telnet session we’ll get the value as example:

$ perl t.pl 202.127.97.97 get
202.127.97.97:75 => 6

202.127.97.97 had a score of 75, that means in our policy class it is allowed to send upto 800 emails.
to speed up so that its value exceeds 800 I run this simple command:

$ for i in $(seq 1 801);do perl t.pl 202.127.97.97 increment;done
$ perl t.pl 202.127.97.97 get
202.127.97.97:75 => 803

Now, its value exceeds 800, let’s try again using a telnet session

$ telnet localhost 1234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
request=smtpd_access_policy
protocol_state=RCPT
protocol_name=SMTP
helo_name=some.domain.tld
queue_id=8045F2AB23
sender=foo@bar.tld
recipient=bar@foo.tld
recipient_count=0
client_address=202.127.97.97
client_name=another.domain.tld
reverse_client_name=another.domain.tld
instance=123.456.7

action=421 sender score = 75, you're limited to 800  messages/hours in our policy, try again later

Connection closed by foreign host.

In main.cf

smtpd_end_of_data_restrictions =
   ...
   check_policy_service inet:127.0.0.1:1234
   ...

When email exceeding its limit in policy class, it’ll be rejected as shown below

$ telnet xxx.xxx.xx.xxx 25
Trying xxx.xxx.xx.xxx...
Connected to mx.example.com (xxx.xxx.xx.xxx).
Escape character is '^]'.
220 mx.example.com ESMTP Postfix (2.9-20110706)
ehlo mx.example.net
250-mx.example.com
250-PIPELINING
250-SIZE 52428800
250-ETRN
250-STARTTLS
250-AUTH LOGIN PLAIN
250-AUTH=LOGIN PLAIN
250-ENHANCEDSTATUSCODES
250-8BITMIME
250 DSN
mail from:<foo@example.com>
250 2.1.0 Ok
rcpt to:<bar@example.net>
250 2.1.5 Ok
data
354 End data with <CR><LF>.<CR><LF>
Subject: test
From from@example.com
To: bar@example
dasdasd
.
421 4.7.1 <END-OF-MESSAGE>: End-of-data rejected: sender score = 75, you're limited to 800  messages/hours in our policy, try again later
Connection closed by foreign host.

The log shows:

Aug 15 09:45:42 fire postfix/smtpd[15215]: 8F9CA400D5: reject: END-OF-MESSAGE from mx.example.net[xxx.xxx.xx.xxx] 421 4.7.1 <END-OF-MESSAGE>: End-of-data rejected: sender score = 75, you're limited to 900  messages/hours in our policy, try again later; from=<foo@example.com> to=<bar@example.com> proto=ESMTP helo=<mx.example.com>

Ok, that’s it for now.

reference:

3 Comments

  1. Alexandre

    I’ve tried your solution, but can’t get the senderscore.. I always get -1 and don’t know how to debug.

    perl t.pl 202.127.97.98 get
    202.127.97.98:-1 => 0

    I already modify nameservers in SenderScore.pm with my own (default was 1.2.3.4)
    But still no luck.

    Can you help me please ?

Leave a Reply

Your email address will not be published. Required fields are marked *