Skip to content

Loading…

Fromhost #21

Merged
merged 2 commits into from

2 participants

@msimerson
qpsmtpd member

require_resolvable_fromhost: refactored, added: POD, tests, reject, reject_type

@abh

I realize you didn't change this, but just reformatted it, but:

Why does this plugin depend on TcpServer? Shouldn't we always check for AAAA records? (I might not be remembering correctly how this stuff works, obviously!)

qpsmtpd member

I don't know enough about the other deployment models (yet) to say about the dependency.

If we're receiving the message in an IPv4 address, and we do not have a IPv6 address, then we obviously could not contact, reply-to, or bounce the message if they only had an AAAA record. I think whoever wrote this had the right idea.

@abh abh merged commit bfa7324 into smtpd:master
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on May 24, 2012
  1. @msimerson
  2. @msimerson

    move 'use ParaDNS' into register and eval it

    msimerson committed
    so eventually, plugin tests can run against it, if ParaDNS can be loaded
Showing with 468 additions and 126 deletions.
  1. +16 −7 plugins/async/require_resolvable_fromhost
  2. +287 −119 plugins/require_resolvable_fromhost
  3. +165 −0 t/plugin_tests/require_resolvable_fromhost
View
23 plugins/async/require_resolvable_fromhost
@@ -1,10 +1,15 @@
#!perl -w
+use strict;
+use warnings;
+
+use Qpsmtpd::Constants;
use Qpsmtpd::DSN;
-use ParaDNS;
-use Socket;
use Qpsmtpd::TcpServer;
+#use ParaDNS; # moved into register
+use Socket;
+
my %invalid = ();
my $has_ipv6 = Qpsmtpd::TcpServer::has_ipv6();
@@ -19,6 +24,11 @@ sub register {
}
}
+ eval 'use ParaDNS';
+ if ( $@ ) {
+ warn "could not load ParaDNS, plugin disabled";
+ return DECLINED;
+ };
$self->register_hook( mail => 'hook_mail_start' );
$self->register_hook( mail => 'hook_mail_done' );
}
@@ -26,11 +36,10 @@ sub register {
sub hook_mail_start {
my ( $self, $transaction, $sender ) = @_;
- return DECLINED
- if ( $self->qp->connection->notes('whitelisthost') );
+ return DECLINED if $self->qp->connection->notes('whitelisthost');
+
+ if ( $sender ne '<>' ) {
- if ( $sender ne "<>" ) {
-
unless ( $sender->host ) {
# default of addr_bad_from_system is DENY, we use DENYSOFT here to
# get the same behaviour as without Qpsmtpd::DSN...
@@ -47,7 +56,7 @@ sub hook_mail_start {
return YIELD;
}
-
+
return DECLINED;
}
View
406 plugins/require_resolvable_fromhost
@@ -1,150 +1,318 @@
#!perl -w
+
+=head1 NAME
+
+resolvable_fromhost
+
+=head1 SYNOPSIS
+
+Determine if the from host resolves to a valid MX or host.
+
+=head1 DESCRIPTION
+
+The fromhost is the part of the email address after the @ symbol, provided by
+the sending server during the SMTP conversation. This is usually, but not
+always, the same as the hostname in the From: header.
+
+B<resolvable_fromhost> tests to see if the fromhost resolves. It saves the
+results in the transaction note I<resolvable_fromhost> where other plugins can
+use that information. Typical results are:
+
+ a - fromhost resolved as an A record
+ mx - fromhost has valid MX record(s)
+ ip - fromhost was an IP
+ whitelist - skipped checks due to whitelisting
+ null - null sender
+ config - fromhost not resolvable, but I<reject 0> was set.
+
+Any other result is an error message with details of the failure.
+
+If B<resolvable_fromhost> is enabled, the from hostname is also stored in
+I<resolvable_fromhost_host>, making it accessible when $sender is not.
+
+=head1 CONFIGURATION
+
+=head2 reject <bool>
+
+If I<reject 1> is set, the old require_resolvable_fromhost plugin behavior of
+temporary rejection is the default.
+
+ resolvable_fromhost reject [ 0 | 1 ]
+
+Default: 1
+
+=head2 reject_type
+
+ reject_type [ perm | temp ]
+
+Set I<reject_type perm> to reject mail instead of deferring it.
+
+Default: temp (temporary, aka soft, aka 4xx).
+
+=head1 EXAMPLE LOG ENTRIES
+
+ 80072 (mail) resolvable_fromhost: googlegroups.com has valid MX at gmr-smtp-in.l.google.com
+ 80108 (mail) resolvable_fromhost: zerobarriers.net has valid MX at zerobarriers.net
+ 80148 (mail) resolvable_fromhost: uhin.com has valid MX at filter.itsafemail.com
+ 86627 (mail) resolvable_fromhost: no MX records for palmalar.com
+ 86627 (mail) resolvable_fromhost: fail: palmalar.com (SERVFAIL)
+
+=head1 AUTHORS
+
+2012 - Matt Simerson - refactored, added: POD, tests, reject, reject_type
+
+2002 - Ask Bjørn Hansen - intial plugin
+
+=cut
+
+
+use strict;
+use warnings;
+
+use Qpsmtpd::Constants;
use Qpsmtpd::DSN;
-use Net::DNS qw(mx);
+use Qpsmtpd::TcpServer;
+
use Socket;
+use Net::DNS qw(mx);
use Net::IP qw(:PROC);
-use Qpsmtpd::TcpServer;
-my %invalid = ();
+my %invalid = ();
my $has_ipv6 = Qpsmtpd::TcpServer::has_ipv6();
+sub register {
+ my ($self, $qp, %args) = @_;
+
+ foreach (keys %args) {
+ $self->{_args}->{$_} = $args{$_};
+ }
+ if ( ! defined $self->{_args}{reject} ) {
+ $self->{_args}{reject} = 1;
+ };
+ $self->{_args}{reject_type} ||= 'soft';
+}
+
sub hook_mail {
- my ($self, $transaction, $sender, %param) = @_;
+ my ($self, $transaction, $sender, %param) = @_;
- return DECLINED
- if ($self->qp->connection->notes('whitelisthost'));
+ $self->populate_invalid_networks();
- foreach my $i ($self->qp->config("invalid_resolvable_fromhost")) {
- $i =~ s/^\s*//;
- $i =~ s/\s*$//;
- if ($i =~ m#^((\d{1,3}\.){3}\d{1,3})/(\d\d?)#) {
- $invalid{$1} = $3;
- }
- }
-
- if ($sender ne "<>"
- and $self->qp->config("require_resolvable_fromhost")
- and !$self->check_dns($sender->host)) {
- if ($sender->host) {
- $transaction->notes('temp_resolver_failed', $sender->host);
- }
- else {
- # default of addr_bad_from_system is DENY, we use DENYSOFT here to
- # get the same behaviour as without Qpsmtpd::DSN...
- return Qpsmtpd::DSN->addr_bad_from_system(DENYSOFT,
- "FQDN required in the envelope sender");
- }
- }
- return DECLINED;
+ # check first, so results are noted for other plugins
+ my $resolved = $self->check_dns($sender->host, $transaction);
+
+ return DECLINED if $resolved; # success, no need to continue
+ return DECLINED if $self->is_immune( $sender, $transaction );
+ return DECLINED if ! $self->{_args}{reject};
+ return DECLINED if $sender->host; # reject later
+
+ $self->log(LOGWARN, "FQDN required in envelope sender");
+ return Qpsmtpd::DSN->addr_bad_from_system( $self->get_reject_type(),
+ "FQDN required in the envelope sender");
}
sub hook_rcpt {
- my ($self, $transaction, $recipient, %args) = @_;
+ my ($self, $transaction, $recipient, %args) = @_;
- if (my $host = $transaction->notes('temp_resolver_failed')) {
- # default of temp_resolver_failed is DENYSOFT
- return Qpsmtpd::DSN->temp_resolver_failed("Could not resolve " . $host);
- }
+ my $result = $transaction->notes('resolvable_fromhost');
+ return DECLINED if ! $self->{_args}{reject}; # no reject policy
+ return DECLINED if $result =~ /^(a|ip|mx)$/; # success
+ return DECLINED if $result =~ /^(whitelist|null|config)$/; # immunity
- return DECLINED;
+ $self->log(LOGINFO, $result ); # log error
+ return Qpsmtpd::DSN->temp_resolver_failed( $self->get_reject_type(), $result );
}
sub check_dns {
- my ($self, $host) = @_;
- my @host_answers;
-
- # for stuff where we can't even parse a hostname out of the address
- return 0 unless $host;
-
- return 1 if $host =~ m/^\[(\d{1,3}\.){3}\d{1,3}\]$/;
-
- my $res = new Net::DNS::Resolver(dnsrch => 0);
- $res->tcp_timeout(30);
- $res->udp_timeout(30);
- my @mx = mx($res, $host);
- foreach my $mx (@mx) {
- # if any MX is valid, then we consider the domain
- # resolvable
- return 1 if mx_valid($self, $mx->exchange, $host);
- }
- # if there are MX records, and we got here,
- # then none of them are valid
- return 0 if (@mx > 0);
-
- my $query = $res->search($host);
- if ($query) {
- foreach my $rrA ($query->answer) {
- push(@host_answers, $rrA);
- }
- }
- if ($has_ipv6) {
- my $query = $res->search($host, 'AAAA');
- if ($query) {
- foreach my $rrAAAA ($query->answer) {
- push(@host_answers, $rrAAAA);
- }
- }
- }
- if (@host_answers) {
+ my ($self, $host, $transaction) = @_;
+
+ # we can't even parse a hostname out of the address
+ if ( ! $host ) {
+ $transaction->notes('resolvable_fromhost', 'unparsable host');
+ return;
+ };
+
+ $transaction->notes('resolvable_fromhost_host', $host);
+
+ if ( $host =~ m/^\[(\d{1,3}\.){3}\d{1,3}\]$/ ) {
+ $self->log(LOGINFO, "skip: $host is an IP");
+ $transaction->notes('resolvable_fromhost', 'ip');
+ return 1;
+ };
+
+ my $res = new Net::DNS::Resolver(dnsrch => 0);
+ $res->tcp_timeout(30);
+ $res->udp_timeout(30);
+
+ my $has_mx = $self->get_and_validate_mx( $res, $host, $transaction );
+ return 1 if $has_mx == 1; # success!
+ return if $has_mx == -1; # has invalid MX records
+
+ my @host_answers = $self->get_host_records( $res, $host, $transaction );
foreach my $rr (@host_answers) {
- return is_valid($rr->address) if $rr->type eq "A" or $rr->type eq "AAAA";
- return mx_valid($self, $rr->exchange, $host) if $rr->type eq "MX";
+ if ( $rr->type eq 'A' || $rr->type eq 'AAAA' ) {
+ $self->log(LOGINFO, "pass: found valid A for $host");
+ $transaction->notes('resolvable_fromhost', 'a');
+ return $self->ip_is_valid($rr->address);
+ };
+ if ( $rr->type eq 'MX' ) {
+ $self->log(LOGINFO, "pass: found valid MX for $host");
+ $transaction->notes('resolvable_fromhost', 'mx');
+ return $self->mx_address_resolves($rr->exchange, $host);
+ };
}
- }
- else {
- $self->log(LOGWARN, "$$ query for $host failed: ", $res->errorstring)
- unless $res->errorstring eq "NXDOMAIN";
- }
- return 0;
+ return;
}
-sub is_valid {
- my $ip = shift;
- my ($net,$mask);
- ### while (($net,$mask) = each %invalid) {
- ### ... does NOT reset to beginning, will start on
- ### 2nd invocation after where it denied the first time..., so
- ### 2nd time the same "MAIL FROM" would be accepted!
- foreach $net (keys %invalid) {
- $mask = $invalid{$net};
- $mask = pack "B32", "1"x($mask)."0"x(32-$mask);
- return 0
- if join(".", unpack("C4", inet_aton($ip) & $mask)) eq $net;
- }
- return 1;
+sub ip_is_valid {
+ my ($self, $ip) = @_;
+ my ($net, $mask);
+ ### while (($net,$mask) = each %invalid) {
+ ### ... does NOT reset to beginning, will start on
+ ### 2nd invocation after where it denied the first time..., so
+ ### 2nd time the same "MAIL FROM" would be accepted!
+ foreach $net (keys %invalid) {
+ $mask = $invalid{$net};
+ $mask = pack "B32", "1" x ($mask) . "0" x (32 - $mask);
+ return if $net eq join('.', unpack("C4", inet_aton($ip) & $mask));
+ }
+ return 1;
}
-sub mx_valid {
- my ($self, $name, $host) = @_;
- my $res = new Net::DNS::Resolver(dnsrch => 0);
- # IP in MX
- return is_valid($name) if ip_is_ipv4($name) or ip_is_ipv6($name);
-
- my @mx_answers;
- my $query = $res->search($name, 'A');
- if ($query) {
- foreach my $rrA ($query->answer) {
- push(@mx_answers, $rrA);
+sub get_and_validate_mx {
+ my ($self, $res, $host, $transaction ) = @_;
+
+ my @mx = mx($res, $host);
+ if ( ! scalar @mx ) { # no mx records
+ $self->log(LOGINFO, "no MX records for $host");
+ return 0;
+ };
+
+ foreach my $mx (@mx) {
+ # if any MX is valid, then we consider the domain resolvable
+ if ( $self->mx_address_resolves($mx->exchange, $host) ) {
+ $self->log(LOGINFO, "pass: $host has valid MX at " . $mx->exchange);
+ $transaction->notes('resolvable_fromhost', 'mx');
+ return 1;
+ };
+ }
+
+ # if there are MX records, and we got here, none are valid
+ $self->log(LOGINFO, "fail: invalid MX for $host");
+ $transaction->notes('resolvable_fromhost', "invalid MX for $host");
+ return -1;
+};
+
+sub get_host_records {
+ my ($self, $res, $host, $transaction ) = @_;
+
+ my @answers;
+ my $query = $res->search($host);
+
+ if ($query) {
+ foreach my $rrA ($query->answer) {
+ push(@answers, $rrA);
+ }
+ }
+
+ if ($has_ipv6) {
+ $query = $res->search($host, 'AAAA');
+ if ($query) {
+ foreach my $rrAAAA ($query->answer) {
+ push(@answers, $rrAAAA);
+ }
+ }
}
- }
- if ($has_ipv6) {
- my $query = $res->search($name, 'AAAA');
+
+ if ( ! scalar @answers) {
+ if ( $res->errorstring ne 'NXDOMAIN' ) {
+ $self->log(LOGWARN, "$$ query for $host failed: ", $res->errorstring);
+ };
+ return;
+ };
+
+ return @answers;
+};
+
+sub mx_address_resolves {
+ my ($self, $name, $fromhost) = @_;
+
+ # IP in MX
+ return $self->ip_is_valid($name) if ip_is_ipv4($name) || ip_is_ipv6($name);
+
+ my $res = new Net::DNS::Resolver(dnsrch => 0);
+ my @mx_answers;
+ my $query = $res->search($name, 'A');
if ($query) {
- foreach my $rrAAAA ($query->answer) {
- push(@mx_answers, $rrAAAA);
- }
+ foreach my $rrA ($query->answer) {
+ push(@mx_answers, $rrA);
+ }
+ }
+ if ($has_ipv6) {
+ my $query = $res->search($name, 'AAAA');
+ if ($query) {
+ foreach my $rrAAAA ($query->answer) {
+ push(@mx_answers, $rrAAAA);
+ }
+ }
+ }
+ if (! @mx_answers) {
+ $self->log(LOGWARN, "query for $fromhost failed: ", $res->errorstring)
+ unless $res->errorstring eq "NXDOMAIN";
+ return;
}
- }
- if (@mx_answers) {
+
foreach my $rr (@mx_answers) {
- next unless $rr->type eq "A" or $rr->type eq "AAAA";
- return is_valid($rr->address);
+ next if ( $rr->type ne 'A' && $rr->type ne 'AAAA' );
+ return $self->ip_is_valid($rr->address);
}
- }
- else {
- $self->log(LOGWARN, "$$ query for $host failed: ", $res->errorstring)
- unless $res->errorstring eq "NXDOMAIN";
- }
- return 0;
+
+ return;
}
+
+sub populate_invalid_networks {
+ my $self = shift;
+
+ foreach my $i ($self->qp->config("invalid_resolvable_fromhost")) {
+ $i =~ s/^\s*//; # trim leading spaces
+ $i =~ s/\s*$//; # trim trailing spaces
+ if ($i =~ m#^((\d{1,3}\.){3}\d{1,3})/(\d\d?)#) {
+ $invalid{$1} = $3;
+ }
+ }
+};
+
+sub is_immune {
+ my ($self, $sender, $transaction) = @_;
+
+ if ( $self->qp->connection->notes('whitelisthost') ) {
+ $transaction->notes('resolvable_fromhost', 'whitelist');
+ $self->log(LOGINFO, "pass: whitelisted");
+ return 1;
+ };
+
+ if ( $sender eq '<>' ) {
+ $transaction->notes('resolvable_fromhost', 'null');
+ $self->log(LOGINFO, "pass: null sender");
+ return 1;
+ };
+
+ if ( ! $self->{_args}{reject} ) {
+ $transaction->notes('resolvable_fromhost', 'config');
+ $self->log(LOGINFO, "skip: reject not enabled in config.");
+ return;
+ };
+
+ return;
+};
+
+sub get_reject_type {
+ my $self = shift;
+ my $default = shift || DENYSOFT;
+ my $deny = $self->{_args}{reject_type} or return $default;
+
+ return $deny =~ /^(temp|soft)$/i ? DENYSOFT
+ : $deny =~ /^(perm|hard)$/i ? DENY
+ : $deny eq 'disconnect' ? DENY_DISCONNECT
+ : $default;
+};
View
165 t/plugin_tests/require_resolvable_fromhost
@@ -0,0 +1,165 @@
+#!perl -w
+
+use strict;
+use warnings;
+
+use Data::Dumper;
+use Net::DNS;
+use Qpsmtpd::Address;
+use Qpsmtpd::Constants;
+
+my $res = new Net::DNS::Resolver(dnsrch => 0);
+my $test_email = 'user@example.com';
+
+sub register_tests {
+ my $self = shift;
+
+ my %args = ( );
+ $self->register( $self->qp, reject => 0 );
+
+ $self->register_test('test_is_immune', 3);
+ $self->register_test('test_populate_invalid_networks', 2);
+ $self->register_test('test_mx_address_resolves', 2);
+ $self->register_test('test_get_host_records', 2);
+ $self->register_test('test_get_and_validate_mx', 2);
+ $self->register_test('test_check_dns', 2);
+ $self->register_test('test_hook_rcpt', 10);
+ $self->register_test('test_hook_mail', 4);
+}
+
+sub test_hook_mail {
+ my $self = shift;
+
+ my $transaction = $self->qp->transaction;
+ my $address = Qpsmtpd::Address->new('remote@example.com');
+ $transaction->sender($address);
+
+ my $sender = $transaction->sender;
+ $sender->host('perl.com');
+
+ ok( $self->hook_mail( $transaction, $sender ) );
+ ok( $self->hook_mail( $transaction, $sender ) );
+
+ $sender->host('');
+ $self->{_args}{reject} = 1;
+ $self->{_args}{reject_type} = 'soft';
+ my ($r) = $self->hook_mail( $transaction, $sender );
+ ok( $r == DENYSOFT, "($r)");
+
+ $self->{_args}{reject_type} = 'hard';
+ ($r) = $self->hook_mail( $transaction, $sender );
+ ok( $r == DENY, "($r)");
+};
+
+sub test_hook_rcpt {
+ my $self = shift;
+
+ my $transaction = $self->qp->transaction;
+ my $recipient = 'foo@example.com';
+
+ $transaction->notes('resolvable_fromhost', 'a');
+ ok( DECLINED == $self->hook_rcpt( $transaction, $recipient ) );
+
+ $transaction->notes('resolvable_fromhost', 'mx');
+ ok( DECLINED == $self->hook_rcpt( $transaction, $recipient ) );
+
+ $transaction->notes('resolvable_fromhost', 'ip');
+ ok( DECLINED == $self->hook_rcpt( $transaction, $recipient ) );
+
+ $transaction->notes('resolvable_fromhost', 'whitelist');
+ ok( DECLINED == $self->hook_rcpt( $transaction, $recipient ) );
+
+ $transaction->notes('resolvable_fromhost', 'null');
+ ok( DECLINED == $self->hook_rcpt( $transaction, $recipient ) );
+
+ $transaction->notes('resolvable_fromhost', 'config');
+ ok( DECLINED == $self->hook_rcpt( $transaction, $recipient ) );
+
+ $transaction->notes('resolvable_fromhost', 'oops!');
+ ok( DECLINED == $self->hook_rcpt( $transaction, $recipient ) );
+
+ $transaction->notes('resolvable_fromhost', 'oops!');
+ ok( DECLINED == $self->hook_rcpt( $transaction, $recipient ) );
+
+ $transaction->notes('resolvable_fromhost', 'oops!');
+ $self->{_args}{reject} = 1;
+ $self->{_args}{reject_type} = 'soft';
+ my ($r) = $self->hook_rcpt( $transaction, $recipient );
+ ok( DENYSOFT == $r, "($r)");
+
+ $transaction->notes('resolvable_fromhost', 'failed again');
+ $self->{_args}{reject_type} = 'hard';
+ ($r) = $self->hook_rcpt( $transaction, $recipient );
+ ok( DENY == $r, "($r)");
+};
+
+sub test_check_dns {
+ my $self = shift;
+
+ my $transaction = $self->qp->transaction;
+ ok( ! $self->check_dns( '', $transaction ) );
+ ok( $self->check_dns( 'perl.com', $transaction ) );
+}
+
+sub test_get_and_validate_mx {
+ my $self = shift;
+ my $transaction = $self->qp->transaction;
+
+ ok( scalar $self->get_and_validate_mx( $res, 'perl.com', $transaction ) );
+
+ ok( ! scalar $self->get_host_records( $res, 'fake-domain-name-for-test.com', $transaction ) );
+};
+
+sub test_get_host_records {
+ my $self = shift;
+ my $transaction = $self->qp->transaction;
+
+ ok( scalar $self->get_host_records( $res, 'perl.com', $transaction ) );
+ ok( ! scalar $self->get_host_records( $res, 'fake-domain-name-for-test.com', $transaction ) );
+};
+
+sub test_mx_address_resolves {
+ my $self = shift;
+
+ my $fromhost = 'perl.com';
+
+ ok( $self->mx_address_resolves('mail.perl.com', $fromhost) );
+ ok( ! $self->mx_address_resolves('no-such-mx.perl.com', $fromhost) );
+};
+
+sub test_populate_invalid_networks {
+ my $self = shift;
+
+ my $ip = '10.9.8.7';
+ ok( $self->ip_is_valid($ip) );
+
+ $self->qp->config('invalid_resolvable_fromhost', $ip);
+ $self->populate_invalid_networks();
+ ok( ! $self->ip_is_valid($ip) );
+
+ # clean up afterwards
+ $self->qp->config('invalid_resolvable_fromhost', undef );
+ $self->{invalid} = ();
+};
+
+sub test_is_immune {
+ my $self = shift;
+
+ my $transaction = $self->qp->transaction;
+
+ # null sender should be immune
+ $transaction->sender('<>');
+ ok( $self->is_immune( $transaction->sender, $transaction ) );
+
+ # whitelisted host should be immune
+ my $connection = $self->qp->connection->notes('whitelisthost', 1);
+ ok( $self->is_immune( $transaction->sender, $transaction ) );
+ $self->qp->connection->notes('whitelisthost', undef);
+
+ # reject is not defined, so email should not be immune
+ my $address = Qpsmtpd::Address->new( "<$test_email>" );
+ $transaction->sender($address);
+ ok( ! $self->is_immune( $transaction->sender, $transaction ) );
+};
+
+
Something went wrong with that request. Please try again.