PageRenderTime 43ms CodeModel.GetById 14ms RepoModel.GetById 0ms app.codeStats 0ms

/plugins/fcrdns

http://github.com/smtpd/qpsmtpd
Perl | 312 lines | 302 code | 9 blank | 1 comment | 1 complexity | 5f3057c4a3c9f714bceba6aea776d50b MD5 | raw file
  1. #!perl -w
  2. =head1 NAME
  3. Forward Confirmed RDNS - http://en.wikipedia.org/wiki/FCrDNS
  4. =head1 DESCRIPTION
  5. Determine if the SMTP sender has matching forward and reverse DNS.
  6. Adds the 'iprev' section to the Authentication-Results header when
  7. there's sufficient DNS (at least rDNS) to be meaningful.
  8. =head1 WHY IT WORKS
  9. The reverse DNS of zombie PCs is out of the spam operators control. Their
  10. only way to pass this test is to limit themselves to hosts with matching
  11. forward and reverse DNS. At present, this presents a significant hurdle.
  12. =head1 VALIDATION TESTS
  13. =over 4
  14. =item has_reverse_dns
  15. Determine if the senders IP address resolves to a hostname.
  16. =item has_forward_dns
  17. If the remote IP has a PTR hostname(s), see if that host has an A or AAAA. If
  18. so, see if any of the host IPs (A or AAAA records) match the remote IP.
  19. Since the dawn of SMTP, having matching DNS has been a standard expected and
  20. oft required of mail servers. While requiring matching DNS is prudent,
  21. requiring an exact match will reject valid email. This often hinders the
  22. use of FcRDNS. While testing this plugin, I noticed that mx0.slc.paypal.com
  23. sends mail from an IP that reverses to mx1.slc.paypal.com. While that's
  24. technically an error, so too would rejecting that connection.
  25. To avoid false positives, matches are extended to the first 3 octets of the
  26. IP and the last two labels of the FQDN. The following are considered a match:
  27. 192.0.1.2, 192.0.1.3
  28. foo.example.com, bar.example.com
  29. This allows FcRDNS to be used without rejecting mail from orgs with
  30. pools of servers where the HELO name and IP don't exactly match. This list
  31. includes Yahoo, Gmail, PayPal, cheaptickets.com, exchange.microsoft.com, etc.
  32. =back
  33. =head1 CONFIGURATION
  34. =head2 timeout [seconds]
  35. Default: 5
  36. The number of seconds before DNS queries timeout.
  37. =head2 reject [ 0 | 1 | naughty ]
  38. Default: 1
  39. 0: do not reject
  40. 1: reject
  41. naughty: naughty plugin handles rejection
  42. =head2 reject_type [ temp | perm | disconnect ]
  43. Default: disconnect
  44. What type of rejection should be sent? See docs/config.pod
  45. =head2 loglevel
  46. Adjust the quantity of logging for this plugin. See docs/logging.pod
  47. =head1 RFC 1912, RFC 5451
  48. From Wikipedia summary:
  49. 1. First a reverse DNS lookup (PTR query) is performed on the IP address, which returns a list of zero or more PTR records. (has_reverse_dns)
  50. 2. For each domain name returned in the PTR query results, a regular 'forward' DNS lookup (type A or AAAA query) is then performed on that domain name. (has_forward_dns)
  51. 3. Any A or AAAA record returned by the second query is then compared against the original IP address (check_ip_match), and if there is a match, then the FCrDNS check passes.
  52. =head1 iprev
  53. # https://www.ietf.org/rfc/rfc5451.txt
  54. 2.4.3. "iprev" Results
  55. The result values are used by the "iprev" method, defined in
  56. Section 3, are as follows:
  57. pass: The DNS evaluation succeeded, i.e., the "reverse" and
  58. "forward" lookup results were returned and were in agreement.
  59. fail: The DNS evaluation failed. In particular, the "reverse" and
  60. "forward" lookups each produced results but they were not in
  61. agreement, or the "forward" query completed but produced no
  62. result, e.g., a DNS RCODE of 3, commonly known as NXDOMAIN, or an
  63. RCODE of 0 (NOERROR) in a reply containing no answers, was
  64. returned.
  65. temperror: The DNS evaluation could not be completed due to some
  66. error that is likely transient in nature, such as a temporary DNS
  67. error, e.g., a DNS RCODE of 2, commonly known as SERVFAIL, or
  68. other error condition resulted. A later attempt may produce a
  69. final result.
  70. permerror: The DNS evaluation could not be completed because no PTR
  71. data are published for the connecting IP address, e.g., a DNS
  72. RCODE of 3, commonly known as NXDOMAIN, or an RCODE of 0 (NOERROR)
  73. in a reply containing no answers, was returned. This prevented
  74. completion of the evaluation.
  75. =head1 AUTHOR
  76. 2013 - Matt Simerson
  77. =cut
  78. use strict;
  79. use warnings;
  80. use Qpsmtpd::Constants;
  81. sub register {
  82. my ($self, $qp) = (shift, shift);
  83. $self->{_args} = {@_};
  84. $self->{_args}{reject_type} = 'temp';
  85. $self->{_args}{timeout} ||= 5;
  86. $self->{_args}{ptr_hosts} = {};
  87. if (!defined $self->{_args}{reject}) {
  88. $self->{_args}{reject} = 0;
  89. }
  90. $self->register_hook('connect', 'fcrdns_tests');
  91. }
  92. sub fcrdns_tests {
  93. my ($self) = @_;
  94. return DECLINED if $self->is_immune();
  95. # run cheap tests before the more expensive DNS tests
  96. foreach my $test (
  97. qw/ is_valid_localhost is_fqdn has_reverse_dns has_forward_dns / ) {
  98. $self->$test() or return DECLINED;
  99. }
  100. $self->log(LOGINFO, "pass");
  101. return DECLINED;
  102. }
  103. sub is_valid_localhost {
  104. my ($self) = @_;
  105. if ($self->is_localhost($self->qp->connection->remote_ip)) {
  106. $self->adjust_karma(1);
  107. $self->log(LOGINFO, "pass, is localhost");
  108. return 1;
  109. }
  110. my $rh = $self->qp->connection->remote_host;
  111. if (! $rh || lc $self->qp->connection->remote_host ne 'localhost') {
  112. $self->log(LOGDEBUG, "skip, not pretending to be localhost");
  113. return 1;
  114. }
  115. $self->adjust_karma(-1);
  116. $self->log(LOGINFO, "fail, not localhost");
  117. return 0;
  118. }
  119. sub is_fqdn {
  120. my ($self) = @_;
  121. my $host = $self->qp->connection->remote_host or return 0;
  122. return 0 if $host eq 'Unknown'; # QP assigns this to a "no DNS result"
  123. # Since QP looked it up, perform some quick validation
  124. if ($host !~ /\./) { # has no dots
  125. $self->adjust_karma(-1);
  126. $self->log(LOGINFO, "fail, not FQDN");
  127. return 0;
  128. }
  129. if ($host =~ /[^a-zA-Z0-9\-\.]/) {
  130. $self->adjust_karma(-1);
  131. $self->log(LOGINFO, "fail, invalid FQDN chars");
  132. return 0;
  133. }
  134. $self->log(LOGDEBUG, "pass, is FQDN");
  135. return 1;
  136. }
  137. sub has_reverse_dns {
  138. my ($self) = @_;
  139. my $res = $self->get_resolver();
  140. my $ip = $self->qp->connection->remote_ip;
  141. my $query = $res->query($ip, 'PTR') or do {
  142. if ($res->errorstring eq 'NXDOMAIN') {
  143. $self->adjust_karma(-1);
  144. $self->store_auth_results("iprev=permerror");
  145. $self->log(LOGINFO, "fail, no rDNS: " . $res->errorstring);
  146. return;
  147. }
  148. if ( $res->errorstring eq 'SERVFAIL' ) {
  149. $self->log(LOGINFO, "fail, error getting rDNS: " . $res->errorstring);
  150. $self->store_auth_results("iprev=temperror");
  151. }
  152. elsif ( $res->errorstring eq 'NOERROR' ) {
  153. $self->log(LOGINFO, "fail, no PTR (NOERROR)" );
  154. $self->store_auth_results("iprev=permerror");
  155. }
  156. else {
  157. $self->store_auth_results("iprev=fail");
  158. $self->log(LOGINFO, "fail, error getting rDNS: " . $res->errorstring);
  159. };
  160. return;
  161. };
  162. my $hits = 0;
  163. $self->{_args}{ptr_hosts} = {}; # reset hash
  164. for my $rr ($query->answer) {
  165. next if $rr->type ne 'PTR';
  166. $hits++;
  167. $self->{_args}{ptr_hosts}{$rr->ptrdname} = 1;
  168. $self->log(LOGDEBUG, "PTR: " . $rr->ptrdname);
  169. }
  170. if (!$hits) {
  171. $self->adjust_karma(-1);
  172. $self->log(LOGINFO, "fail, no PTR records");
  173. $self->store_auth_results("iprev=permerror");
  174. return;
  175. }
  176. $self->log(LOGDEBUG, "has rDNS");
  177. return 1;
  178. }
  179. sub has_forward_dns {
  180. my ($self) = @_;
  181. my $res = $self->get_resolver();
  182. foreach my $host (keys %{$self->{_args}{ptr_hosts}}) {
  183. $host .= '.' if '.' ne substr($host, -1, 1); # fully qualify name
  184. my $query = $res->query($host) or do {
  185. if ($res->errorstring eq 'NXDOMAIN') {
  186. $self->store_auth_results("iprev=permerror");
  187. $self->log(LOGDEBUG, "host $host does not exist");
  188. next;
  189. }
  190. $self->store_auth_results("iprev=fail");
  191. $self->log(LOGDEBUG, "query for $host failed (",
  192. $res->errorstring, ")");
  193. next;
  194. };
  195. my $hits = 0;
  196. foreach my $rr ($query->answer) {
  197. next unless $rr->type =~ /^(?:A|AAAA)$/;
  198. $hits++;
  199. $self->check_ip_match($rr->address) and return 1;
  200. }
  201. if ($hits) {
  202. $self->store_auth_results("iprev=fail");
  203. $self->log(LOGDEBUG, "PTR host has forward DNS") if $hits;
  204. return 1;
  205. }
  206. }
  207. $self->adjust_karma(-1);
  208. $self->store_auth_results("iprev=fail");
  209. $self->log(LOGINFO, "fail, no PTR hosts have forward DNS");
  210. return;
  211. }
  212. sub check_ip_match {
  213. my $self = shift;
  214. my $ip = shift or return;
  215. if ($ip eq $self->qp->connection->remote_ip) {
  216. $self->log(LOGDEBUG, "forward ip match");
  217. $self->store_auth_results("iprev=pass");
  218. $self->adjust_karma(1);
  219. return 1;
  220. }
  221. # TODO: make this IPv6 compatible
  222. my $dns_net = join('.', (split(/\./, $ip))[0, 1, 2]);
  223. my $rem_net =
  224. join('.', (split(/\./, $self->qp->connection->remote_ip))[0, 1, 2]);
  225. if ($dns_net eq $rem_net) {
  226. $self->log(LOGNOTICE, "forward network match");
  227. $self->store_auth_results("iprev=pass");
  228. return 1;
  229. }
  230. return;
  231. }