helo: new plugin
helo - validate a HELO message delivered from a connecting host. Includes the following tests: is_in_badhelo invalid_localhost is_plain_ip is_address_literal [N.N.N.N] is_forged_literal is_not_fqdn no_forward_dns no_reverse_dns no_matching_dns
This commit is contained in:
parent
600b0db54d
commit
74ae957936
393
plugins/helo
Normal file
393
plugins/helo
Normal file
@ -0,0 +1,393 @@
|
||||
#!perl -w
|
||||
|
||||
=head1 NAME
|
||||
|
||||
helo - validate a HELO message delivered from a connecting host.
|
||||
|
||||
=head1 DESCRIPTION
|
||||
|
||||
This plugin validates the HELO hostname presented by a remote sender. It
|
||||
includes a suite of optional tests, selectable by the I<policy> setting.
|
||||
|
||||
The following tests are available. The policy section details which tests
|
||||
are enforced by each policy:
|
||||
|
||||
=over 4
|
||||
|
||||
=item is_in_badhelo
|
||||
|
||||
Matches in the I<badhelo> config file, including yahoo.com and aol.com, which
|
||||
neither the real Yahoo or the real AOL use, but which spammers use often.
|
||||
|
||||
B<badhelo> can also contain perl regular expressions. In addition to normal
|
||||
regexp processing, a pattern can start with a ! character, and get a !~ match
|
||||
instead of the customary =~ match.
|
||||
|
||||
=item invalid_localhost
|
||||
|
||||
Assure that if a sender uses the 'localhost' hostname, they are coming from
|
||||
the localhost IP.
|
||||
|
||||
=item is_plain_ip
|
||||
|
||||
Disallow plain IP addresses. They are neither FQDN nor an address literal.
|
||||
|
||||
=item is_address_literal [N.N.N.N]
|
||||
|
||||
An address literal (an IP enclosed in brackets] is legal but rarely, if ever,
|
||||
encountered from legit senders. Disallow them.
|
||||
|
||||
=item is_forged_literal
|
||||
|
||||
If a literal is presented, make sure it matches the senders IP.
|
||||
|
||||
=item is_not_fqdn
|
||||
|
||||
Makes sure the HELO hostname contains at least one dot and no invalid characters.
|
||||
|
||||
=item no_forward_dns
|
||||
|
||||
Make sure the HELO hostname resolves.
|
||||
|
||||
=item no_reverse_dns
|
||||
|
||||
Make sure the senders IP address resolves to a hostname.
|
||||
|
||||
=item no_matching_dns
|
||||
|
||||
Make sure the HELO hostname has an A or AAAA record that matches the senders
|
||||
IP address, and make sure that the senders IP has a PTR that resolves to the
|
||||
HELO hostname.
|
||||
|
||||
This might sound pedantic, but since time immemorial, having matching DNS is
|
||||
a minimum standard expected, and frequently required, of mail servers.
|
||||
|
||||
=back
|
||||
|
||||
=head1 CONFIGURATION
|
||||
|
||||
=head2 policy [ lenient | rfc | strict ]
|
||||
|
||||
Default: lenient
|
||||
|
||||
=head3 lenient
|
||||
|
||||
Reject failures of the following tests: is_in_badhelo, invalid_localhost, and
|
||||
is_forged_literal.
|
||||
|
||||
If you are not using the B<naughty> plugin, this setting is lenient enough
|
||||
not to cause problems for your Windows users. It also makes you more vulnerable
|
||||
to abuse by every other Windows PC connected to the internet.
|
||||
|
||||
=head3 rfc
|
||||
|
||||
Per RFC 2821, the HELO hostname must be the FQDN of the sending server or an
|
||||
address literal. When I<policy rfc> is selected, all the lenient checks and
|
||||
the following are enforced: is_plain_ip, is_not_fqdn, no_forward_dns,
|
||||
no_reverse_dns, and no_matching_dns.
|
||||
|
||||
If you have Windows users that send mail via your server, do not choose RFC
|
||||
unless you are using the B<naughty> plugin. Windows users often send
|
||||
unqualified HELO names and will have trouble sending mail. <Naughty> can defer
|
||||
the rejection, and if the user authenticates, the reject is cancelled entirely.
|
||||
|
||||
=head3 strict
|
||||
|
||||
Strict includes all the RFC tests and also rejects adddress literals. So long
|
||||
as you use I<reject naughty>, this test should reject only spam.
|
||||
|
||||
=head2 badhelo
|
||||
|
||||
Add domains, hostnames, or perl regexp patterns to the F<badhelo> config
|
||||
file; one per line.
|
||||
|
||||
=head2 reject [ 0 | 1 | naughty ]
|
||||
|
||||
0: do not reject
|
||||
|
||||
1: reject
|
||||
|
||||
naughty: naughty plugin handles rejection
|
||||
|
||||
Default: 1
|
||||
|
||||
=head2 reject_type [ temp | perm | disconnect ]
|
||||
|
||||
What type of rejection should be sent? See docs/config.pod
|
||||
|
||||
=head2 loglevel
|
||||
|
||||
Adjust the quantity of logging for this plugin. See docs/logging.pod
|
||||
|
||||
=head1 RFC 2821
|
||||
|
||||
=head2 4.1.1.1
|
||||
|
||||
The HELO hostname "...contains the fully-qualified domain name of the SMTP
|
||||
client if one is available. In situations in which the SMTP client system
|
||||
does not have a meaningful domain name (e.g., when its address is dynamically
|
||||
allocated and no reverse mapping record is available), the client SHOULD send
|
||||
an address literal (see section 4.1.3), optionally followed by information
|
||||
that will help to identify the client system."
|
||||
|
||||
=head2 2.3.5
|
||||
|
||||
The domain name, as described in this document and in [22], is the
|
||||
entire, fully-qualified name (often referred to as an "FQDN"). A domain name
|
||||
that is not in FQDN form is no more than a local alias. Local aliases MUST
|
||||
NOT appear in any SMTP transaction.
|
||||
|
||||
|
||||
=head1 AUTHOR
|
||||
|
||||
2012 - Matt Simerson
|
||||
|
||||
=head1 ACKNOWLEDGEMENTS
|
||||
|
||||
badhelo processing from check_badhelo plugin
|
||||
|
||||
badhelo regex processing idea from qmail-regex patch
|
||||
|
||||
additional check ideas from Hakura helo plugin
|
||||
|
||||
=cut
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
use Qpsmtpd::Constants;
|
||||
|
||||
use Net::DNS;
|
||||
|
||||
sub register {
|
||||
my ($self, $qp) = shift, shift;
|
||||
$self->{_args} = { @_ };
|
||||
$self->{_args}{reject_type} = 'temp';
|
||||
$self->{_args}{policy} ||= 'lenient';
|
||||
|
||||
if ( ! defined $self->{_args}{reject} ) {
|
||||
$self->{_args}{reject} = 1;
|
||||
};
|
||||
|
||||
$self->populate_tests();
|
||||
$self->init_resolver();
|
||||
|
||||
$self->register_hook('helo', 'helo_handler');
|
||||
$self->register_hook('ehlo', 'helo_handler');
|
||||
};
|
||||
|
||||
sub helo_handler {
|
||||
my ($self, $transaction, $host) = @_;
|
||||
|
||||
if ( ! $host ) {
|
||||
$self->log(LOGINFO, "fail, no helo host");
|
||||
return DECLINED;
|
||||
};
|
||||
|
||||
#return DECLINED if $self->is_immune();
|
||||
|
||||
foreach my $test ( @{ $self->{_helo_tests} } ) {
|
||||
my @err = $self->$test( $host );
|
||||
return $self->get_reject( @err ) if scalar @err;
|
||||
};
|
||||
|
||||
$self->log(LOGINFO, "pass, all HELO test");
|
||||
return DECLINED;
|
||||
}
|
||||
|
||||
sub populate_tests {
|
||||
my $self = shift;
|
||||
|
||||
my $policy = $self->{_args}{policy};
|
||||
@{ $self->{_helo_tests} } = qw/ is_in_badhelo invalid_localhost is_forged_literal /;
|
||||
|
||||
if ( $policy eq 'rfc' || $policy eq 'strict' ) {
|
||||
push @{ $self->{_helo_tests} }, qw/ is_plain_ip is_not_fqdn no_forward_dns
|
||||
no_reverse_dns no_matching_dns /;
|
||||
};
|
||||
|
||||
if ( $policy eq 'strict' ) {
|
||||
push @{ $self->{_helo_tests} }, qw/ is_address_literal /;
|
||||
};
|
||||
};
|
||||
|
||||
sub init_resolver {
|
||||
my $self = shift;
|
||||
return $self->{_resolver} if $self->{_resolver};
|
||||
$self->log( LOGDEBUG, "initializing Net::DNS::Resolver");
|
||||
$self->{_resolver} = Net::DNS::Resolver->new(dnsrch => 0);
|
||||
$self->{_resolver}->tcp_timeout(5);
|
||||
$self->{_resolver}->udp_timeout(5);
|
||||
return $self->{_resolver};
|
||||
};
|
||||
|
||||
sub is_in_badhelo {
|
||||
my ( $self, $host ) = @_;
|
||||
|
||||
my $error = "I do not believe you are $host.";
|
||||
|
||||
$host = lc $host;
|
||||
foreach my $bad ($self->qp->config('badhelo')) {
|
||||
if ( $bad =~ /[\{\}\[\]\(\)\^\$\|\*\+\?\\\!]/ ) { # it's a regexp
|
||||
return $self->is_regex_match( $host, $bad );
|
||||
};
|
||||
if ( $host eq lc $bad) {
|
||||
return ($error, "in badhelo");
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
sub is_regex_match {
|
||||
my ( $self, $host, $pattern ) = @_;
|
||||
|
||||
my $error = "Your HELO hostname is not allowed";
|
||||
|
||||
#$self->log( LOGDEBUG, "is regex ($pattern)");
|
||||
if ( substr( $pattern, 0, 1) eq '!' ) {
|
||||
$pattern = substr $pattern, 1;
|
||||
if ( $host !~ /$pattern/ ) {
|
||||
#$self->log( LOGDEBUG, "matched ($pattern)");
|
||||
return ($error, "badhelo pattern match ($pattern)");
|
||||
};
|
||||
return;
|
||||
}
|
||||
if ( $host =~ /$pattern/ ) {
|
||||
#$self->log( LOGDEBUG, "matched ($pattern)");
|
||||
return ($error, "badhelo pattern match ($pattern)");
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
sub invalid_localhost {
|
||||
my ( $self, $host ) = @_;
|
||||
return if lc $host ne 'localhost';
|
||||
if ( $self->qp->connection->remote_ip ne '127.0.0.1' ) {
|
||||
#$self->log( LOGINFO, "fail, not localhost" );
|
||||
return ("You are not localhost", "invalid localhost");
|
||||
};
|
||||
$self->log( LOGDEBUG, "pass, is localhost" );
|
||||
return;
|
||||
};
|
||||
|
||||
sub is_plain_ip {
|
||||
my ( $self, $host ) = @_;
|
||||
return if $host =~ /[^\d\.]+/; # has chars other than digits and a dot
|
||||
return if $host !~ m/^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
|
||||
$self->log( LOGDEBUG, "fail, plain IP" );
|
||||
return ("Plain IP is invalid HELO hostname (RFC 2821)", "plain IP");
|
||||
};
|
||||
|
||||
sub is_address_literal {
|
||||
my ( $self, $host ) = @_;
|
||||
return if $host !~ m/^\[(\d{1,3}\.){3}\d{1,3}\]$/;
|
||||
|
||||
$self->log( LOGDEBUG, "fail, bracketed IP" );
|
||||
return ("RFC 2821 allows an address literal, but we do not", "bracketed IP");
|
||||
};
|
||||
|
||||
sub is_forged_literal {
|
||||
my ( $self, $host ) = @_;
|
||||
return if $host !~ m/^\[(\d{1,3}\.){3}\d{1,3}\]$/;
|
||||
|
||||
$host = substr $host, 1, -1;
|
||||
return if $host eq $self->qp->connection->remote_ip;
|
||||
return ("Forged IPs not accepted here", "forged IP literal");
|
||||
};
|
||||
|
||||
sub is_not_fqdn {
|
||||
my ($self, $host) = @_;
|
||||
return if $host =~ m/^\[(\d{1,3}\.){3}\d{1,3}\]$/; # address literal, skip
|
||||
if ( $host !~ /\./ ) { # has no dots
|
||||
return ("HELO name is not fully qualified. Read RFC 2821", "not FQDN");
|
||||
};
|
||||
if ( $host =~ /[^a-zA-Z0-9\-\.]/ ) {
|
||||
return ("HELO name contains invalid FQDN characters. Read RFC 1035", "invalid FQDN chars");
|
||||
};
|
||||
return;
|
||||
};
|
||||
|
||||
sub no_forward_dns {
|
||||
my ( $self, $host ) = @_;
|
||||
|
||||
my $res = $self->init_resolver();
|
||||
|
||||
$host = "$host." if $host !~ /\.$/; # fully qualify name
|
||||
my $query = $res->search($host);
|
||||
|
||||
if (! $query) {
|
||||
if ( $res->errorstring eq 'NXDOMAIN' ) {
|
||||
return ("no such domain", "no such domain");
|
||||
}
|
||||
$self->log(LOGERROR, "skip, query failed (", $res->errorstring, ")" );
|
||||
return;
|
||||
};
|
||||
my $hits = 0;
|
||||
foreach my $rr ($query->answer) {
|
||||
next unless $rr->type =~ /^(?:A|AAAA)$/;
|
||||
if ( $rr->address eq $self->qp->connection->remote_ip ) {
|
||||
$self->qp->connection->notes('helo_forward_match', 1);
|
||||
};
|
||||
$hits++;
|
||||
}
|
||||
if ( $hits ) {
|
||||
$self->log(LOGDEBUG, "pass, forward DNS") if $hits;
|
||||
return;
|
||||
};
|
||||
return ("helo hostname did not resolve", "fail, forward DNS");
|
||||
};
|
||||
|
||||
sub no_reverse_dns {
|
||||
my ( $self, $host, $ip ) = @_;
|
||||
|
||||
my $res = $self->init_resolver();
|
||||
$ip ||= $self->qp->connection->remote_ip;
|
||||
|
||||
my $query = $res->query( $ip ) or do {
|
||||
if ( $res->errorstring eq 'NXDOMAIN' ) {
|
||||
return ("no rDNS for $ip", "no rDNS");
|
||||
};
|
||||
$self->log( LOGINFO, $res->errorstring );
|
||||
return ("error getting reverse DNS for $ip", "rDNS " . $res->errorstring);
|
||||
};
|
||||
|
||||
my $hits = 0;
|
||||
for my $rr ($query->answer) {
|
||||
next if $rr->type ne 'PTR';
|
||||
$self->log(LOGINFO, "PTR: " . $rr->ptrdname );
|
||||
if ( lc $rr->ptrdname eq lc $host ) {
|
||||
$self->qp->connection->notes('helo_reverse_match', 1);
|
||||
};
|
||||
$hits++;
|
||||
};
|
||||
if ( $hits ) {
|
||||
$self->log(LOGINFO, "pass, has rDNS");
|
||||
return;
|
||||
};
|
||||
return ("no reverse DNS for $ip", "no rDNS");
|
||||
};
|
||||
|
||||
sub no_matching_dns {
|
||||
my ( $self, $host ) = @_;
|
||||
|
||||
if ( $self->qp->connection->notes('helo_forward_match') &&
|
||||
$self->qp->connection->notes('helo_reverse_match') ) {
|
||||
$self->log( LOGINFO, "pass, foward and reverse match" );
|
||||
# TODO: consider adding some karma here
|
||||
return;
|
||||
};
|
||||
|
||||
if ( $self->qp->connection->notes('helo_forward_match') ) {
|
||||
$self->log( LOGINFO, "pass, name matches IP" );
|
||||
return;
|
||||
}
|
||||
if ( $self->qp->connection->notes('helo_reverse_match') ) {
|
||||
$self->log( LOGINFO, "pass, reverse matches name" );
|
||||
return;
|
||||
};
|
||||
|
||||
$self->log( LOGINFO, "fail, no forward or reverse DNS match" );
|
||||
return ("That HELO hostname fails forward and reverse DNS checks", "no matching DNS");
|
||||
};
|
||||
|
142
t/plugin_tests/helo
Normal file
142
t/plugin_tests/helo
Normal file
@ -0,0 +1,142 @@
|
||||
#!perl -w
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
use Qpsmtpd::Constants;
|
||||
|
||||
sub register_tests {
|
||||
my $self = shift;
|
||||
|
||||
$self->register_test('test_init_resolver', 2);
|
||||
$self->register_test('test_is_in_badhelo', 2);
|
||||
$self->register_test('test_is_regex_match', 3);
|
||||
$self->register_test('test_invalid_localhost', 4);
|
||||
$self->register_test('test_is_plain_ip', 3);
|
||||
$self->register_test('test_is_address_literal', 3);
|
||||
$self->register_test('test_no_forward_dns', 2);
|
||||
$self->register_test('test_no_reverse_dns', 2);
|
||||
$self->register_test('test_no_matching_dns', 4);
|
||||
$self->register_test('test_helo_handler', 1);
|
||||
}
|
||||
|
||||
sub test_helo_handler {
|
||||
my $self = shift;
|
||||
|
||||
cmp_ok( $self->helo_handler(undef, undef), '==', DECLINED, "empty host");
|
||||
};
|
||||
|
||||
sub test_init_resolver {
|
||||
my $self = shift;
|
||||
my $net_dns = $self->init_resolver();
|
||||
ok( $net_dns, "net::dns" );
|
||||
cmp_ok( ref $net_dns, 'eq', 'Net::DNS::Resolver', "ref ok");
|
||||
};
|
||||
|
||||
sub test_is_in_badhelo {
|
||||
my $self = shift;
|
||||
|
||||
my ($err, $why) = $self->is_in_badhelo('yahoo.com');
|
||||
ok( $err, "yahoo.com, $why");
|
||||
|
||||
($err, $why) = $self->is_in_badhelo('example.com');
|
||||
ok( ! $err, "example.com");
|
||||
};
|
||||
|
||||
sub test_is_regex_match {
|
||||
my $self = shift;
|
||||
|
||||
my ($err, $why) = $self->is_regex_match('yahoo.com', 'ya.oo\.com$' );
|
||||
ok( $err, "yahoo.com, $why");
|
||||
|
||||
($err, $why) = $self->is_regex_match('yoda.com', 'ya.oo\.com$' );
|
||||
ok( ! $err, "yahoo.com");
|
||||
|
||||
($err, $why) = $self->is_regex_match('host-only', '!\.' );
|
||||
ok( $err, "negated pattern, $why");
|
||||
};
|
||||
|
||||
sub test_invalid_localhost {
|
||||
my $self = shift;
|
||||
|
||||
$self->qp->connection->remote_ip(undef);
|
||||
my ($err, $why) = $self->invalid_localhost('localhost' );
|
||||
ok( $err, "localhost, undefined remote IP: $why");
|
||||
|
||||
$self->qp->connection->remote_ip('');
|
||||
($err, $why) = $self->invalid_localhost('localhost' );
|
||||
ok( $err, "localhost, empty remote IP: $why");
|
||||
|
||||
$self->qp->connection->remote_ip('192.0.99.5');
|
||||
($err, $why) = $self->invalid_localhost('localhost');
|
||||
ok( $err, "localhost, invalid remote IP: $why");
|
||||
|
||||
$self->qp->connection->remote_ip('127.0.0.1');
|
||||
($err, $why) = $self->invalid_localhost('localhost');
|
||||
ok( ! $err, "localhost, correct remote IP");
|
||||
};
|
||||
|
||||
sub test_is_plain_ip {
|
||||
my $self = shift;
|
||||
|
||||
my ($err, $why) = $self->is_plain_ip('0.0.0.0');
|
||||
ok( $err, "plain IP, $why");
|
||||
|
||||
($err, $why) = $self->is_plain_ip('255.255.255.255');
|
||||
ok( $err, "plain IP, $why");
|
||||
|
||||
($err, $why) = $self->is_plain_ip('[255.255.255.255]');
|
||||
ok( ! $err, "address literal");
|
||||
};
|
||||
|
||||
sub test_is_address_literal {
|
||||
my $self = shift;
|
||||
|
||||
my ($err, $why) = $self->is_address_literal('[0.0.0.0]');
|
||||
ok( $err, "plain IP, $why");
|
||||
|
||||
($err, $why) = $self->is_address_literal('[255.255.255.255]');
|
||||
ok( $err, "plain IP, $why");
|
||||
|
||||
($err, $why) = $self->is_address_literal('255.255.255.255');
|
||||
ok( ! $err, "address literal");
|
||||
};
|
||||
|
||||
sub test_no_forward_dns {
|
||||
my $self = shift;
|
||||
|
||||
my ($err, $why) = $self->no_forward_dns('perl.org');
|
||||
ok( ! $err, "perl.org");
|
||||
|
||||
# reserved .test TLD: http://tools.ietf.org/html/rfc2606
|
||||
($err, $why) = $self->no_forward_dns('perl.org.test');
|
||||
ok( $err, "test.perl.org.test");
|
||||
};
|
||||
|
||||
sub test_no_reverse_dns {
|
||||
my $self = shift;
|
||||
|
||||
my ($err, $why) = $self->no_reverse_dns('test-host', '192.0.2.0');
|
||||
ok( $err, "192.0.2.0, $why");
|
||||
|
||||
($err, $why) = $self->no_reverse_dns('test-host', '192.0.2.1');
|
||||
ok( $err, "192.0.2.1, $why");
|
||||
|
||||
($err, $why) = $self->no_reverse_dns('mail.theartfarm.com', '208.75.177.101');
|
||||
ok( ! $err, "208.75.177.101");
|
||||
};
|
||||
|
||||
sub test_no_matching_dns {
|
||||
my $self = shift;
|
||||
|
||||
$self->qp->connection->notes('helo_forward_match', undef);
|
||||
$self->qp->connection->notes('helo_reverse_match', undef);
|
||||
|
||||
my ($err, $why) = $self->no_matching_dns('matt.test');
|
||||
ok( $err, "fail, $why");
|
||||
|
||||
$self->qp->connection->notes('helo_forward_match', 1);
|
||||
($err, $why) = $self->no_matching_dns('matt.test');
|
||||
ok( ! $err, "pass");
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user