PageRenderTime 42ms CodeModel.GetById 2ms RepoModel.GetById 0ms app.codeStats 0ms

/plugins/helo

http://github.com/smtpd/qpsmtpd
Perl | 541 lines | 425 code | 106 blank | 10 comment | 56 complexity | 6096ab1d97cb826a8f6597f1ceb9021a MD5 | raw file
  1. #!perl -w
  2. =head1 NAME
  3. helo - validate the HELO message presented by a connecting host.
  4. =head1 DESCRIPTION
  5. Validate the HELO hostname. This plugin includes a suite of optional tests,
  6. selectable by the I<policy> setting. The policy section details which tests
  7. are enforced by each policy option.
  8. It sets the connection notes helo_forward_match and helo_reverse_match when
  9. I<policy rfc> or I<policy strict> are used.
  10. Adds an X-HELO header with the HELO hostname to the message.
  11. Using I<policy rfc> will reject a very large portion of the spam from hosts
  12. that have yet to get blacklisted.
  13. =head1 WHY IT WORKS
  14. The reverse DNS of the zombie PCs is out of the spam operators control. Their
  15. only way to get past these tests is to limit themselves to hosts with matching
  16. forward and reverse DNS, and then use the proper HELO hostname when spamming.
  17. At present, this presents a very high hurdle.
  18. =head1 HELO VALIDATION TESTS
  19. =over 4
  20. =item is_in_badhelo
  21. Matches in the I<badhelo> config file, including yahoo.com and aol.com, which
  22. neither the real Yahoo or the real AOL use, but which spammers use a lot.
  23. Like qmail with the qregex patch, the B<badhelo> file can also contain perl
  24. regular expressions. In addition to normal regexp processing, a pattern can
  25. start with a ! character, and get a negated (!~) match.
  26. =item invalid_localhost
  27. Assure that if a sender uses the 'localhost' hostname, they are coming from
  28. the localhost IP.
  29. =item is_plain_ip
  30. Disallow plain IP addresses. They are neither a FQDN nor an address literal.
  31. =item is_address_literal [N.N.N.N]
  32. An address literal (an IP enclosed in brackets) is legal but rarely, if ever,
  33. encountered from legit senders.
  34. =item is_forged_literal
  35. If a literal is presented, make sure it matches the senders IP.
  36. =item is_not_fqdn
  37. Makes sure the HELO hostname contains at least one dot and has only those
  38. characters specifically allowed in domain names (RFC 1035).
  39. =item no_forward_dns
  40. Make sure the HELO hostname resolves.
  41. =item no_reverse_dns
  42. Make sure the senders IP address resolves to a hostname.
  43. =item no_matching_dns
  44. Make sure the HELO hostname has an A or AAAA record that matches the senders
  45. IP address, and make sure that the senders IP has a PTR that resolves to the
  46. HELO hostname.
  47. Per RFC 5321 section 4.1.4, it is impermissible to block a message I<soley>
  48. on the basis of the HELO hostname not matching the senders IP.
  49. Since the dawn of SMTP, having matching DNS has been a minimum standard
  50. expected and oft required of mail servers. While requiring matching DNS is
  51. prudent, requiring an exact match will reject valid email. While testing this
  52. plugin with rejection disabled, I noticed that mx0.slc.paypal.com sends email
  53. from an IP that reverses to mx1.slc.paypal.com. While that's technically an
  54. error, I believe it's an error to reject mail based on it. Especially since
  55. SLD and TLD match.
  56. To avoid snagging false positives, matches are extended to the first
  57. 3 octets of the IP and the last two labels of the FQDN. The following are
  58. considered a match:
  59. 192.0.1.2, 192.0.1.3
  60. foo.example.com, bar.example.com
  61. This allows I<no_matching_dns> to be used without rejecting mail from orgs with
  62. pools of servers where the HELO name and IP don't exactly match. This list
  63. includes Yahoo, Gmail, PayPal, cheaptickets.com, exchange.microsoft.com, and
  64. likely many more.
  65. =back
  66. =head1 CONFIGURATION
  67. =head2 policy [ lenient | rfc | strict ]
  68. Default: lenient
  69. =head3 lenient
  70. Runs the following tests: is_in_badhelo, invalid_localhost,
  71. is_forged_literal, and is_plain_ip.
  72. This setting is lenient enough not to cause problems for your Windows users.
  73. It is comparable to running check_spamhelo, but with the addition of regexp
  74. support, the prevention of forged localhost, forged IP literals, and plain
  75. IPs.
  76. =head3 rfc
  77. Per RFC 2821, the HELO hostname is the FQDN of the sending server or an
  78. address literal. When I<policy rfc> is selected, all the lenient checks and
  79. the following are tested: is_not_fqdn, no_forward_dns, and no_reverse_dns.
  80. If you have Windows users that send mail via your server, do not choose
  81. I<policy rfc> without setting I<reject> to 0 or naughty.
  82. Windows PCs often send unqualified HELO names and will have trouble
  83. sending mail. The B<naughty> plugin defers the rejection, giving the user
  84. the opportunity to authenticate and bypass the rejection.
  85. =head3 strict
  86. Strict includes all the RFC tests and the following: no_matching_dns, and
  87. is_address_literal.
  88. I have yet to see an address literal being used by a hammy sender. But I am
  89. not certain that blocking them all is prudent.
  90. It is recommended that I<policy strict> be used with <reject 0> and that you
  91. examine your logs for false positives.
  92. =head2 badhelo
  93. Add domains, hostnames, or perl regexp patterns to the F<badhelo> config
  94. file; one per line.
  95. =head2 timeout [seconds]
  96. Default: 5
  97. The number of seconds before DNS queries timeout.
  98. =head2 reject [ 0 | 1 | naughty ]
  99. Default: 1
  100. 0: do not reject
  101. 1: reject
  102. naughty: naughty plugin handles rejection
  103. =head2 reject_type [ temp | perm | disconnect ]
  104. Default: disconnect
  105. What type of rejection should be sent? See docs/config.pod
  106. =head2 loglevel
  107. Adjust the quantity of logging for this plugin. See docs/logging.pod
  108. =head1 RFC 2821
  109. =head2 4.1.1.1
  110. The HELO hostname "...contains the fully-qualified domain name of the SMTP
  111. client if one is available. In situations in which the SMTP client system
  112. does not have a meaningful domain name (e.g., when its address is dynamically
  113. allocated and no reverse mapping record is available), the client SHOULD send
  114. an address literal (see section 4.1.3), optionally followed by information
  115. that will help to identify the client system."
  116. =head2 2.3.5
  117. The domain name, as described in this document and in [22], is the
  118. entire, fully-qualified name (often referred to as an "FQDN"). A domain name
  119. that is not in FQDN form is no more than a local alias. Local aliases MUST
  120. NOT appear in any SMTP transaction.
  121. =head1 RFC 5321
  122. =head2 4.1.4
  123. An SMTP server MAY verify that the domain name argument in the EHLO
  124. command actually corresponds to the IP address of the client.
  125. However, if the verification fails, the server MUST NOT refuse to
  126. accept a message on that basis. Information captured in the
  127. verification attempt is for logging and tracing purposes. Note that
  128. this prohibition applies to the matching of the parameter to its IP
  129. address only; see Section 7.9 for a more extensive discussion of
  130. rejecting incoming connections or mail messages.
  131. =head1 TODO
  132. is_forged_literal, if the forged IP is an internal IP, it's likely one
  133. of our clients that should have authenticated. Perhaps when we check back
  134. later in data_post, if they have added relay_client, then give back the
  135. karma.
  136. =head1 AUTHOR
  137. 2012 - Matt Simerson
  138. =head1 ACKNOWLEDGEMENTS
  139. badhelo processing from check_badhelo plugin
  140. badhelo regex processing idea from qregex patch
  141. additional check ideas from Haraka helo plugin
  142. =cut
  143. use strict;
  144. use warnings;
  145. use Net::IP;
  146. use Qpsmtpd::Constants;
  147. sub register {
  148. my ($self, $qp) = (shift, shift);
  149. $self->{_args} = {@_};
  150. $self->{_args}{reject_type} = 'disconnect';
  151. $self->{_args}{policy} ||= 'lenient';
  152. $self->{_args}{dns_timeout} ||= $self->{_args}{timeout} || 5;
  153. if (!defined $self->{_args}{reject}) {
  154. $self->{_args}{reject} = 1;
  155. }
  156. $self->populate_tests();
  157. $self->get_resolver() or return;
  158. $self->register_hook('helo', 'helo_handler');
  159. $self->register_hook('ehlo', 'helo_handler');
  160. $self->register_hook('data_post', 'data_post_handler');
  161. }
  162. sub helo_handler {
  163. my ($self, $transaction, $host) = @_;
  164. if (!$host) {
  165. $self->log(LOGINFO, "fail, tolerated, no helo host");
  166. $self->adjust_karma(-2);
  167. return DECLINED;
  168. }
  169. return DECLINED if $self->is_immune();
  170. foreach my $test (@{$self->{_helo_tests}}) {
  171. my @err = $self->$test($host);
  172. if (scalar @err) {
  173. $self->adjust_karma(-1);
  174. return $self->get_reject(@err);
  175. }
  176. }
  177. $self->log(LOGINFO, "pass");
  178. return DECLINED;
  179. }
  180. sub data_post_handler {
  181. my ($self, $transaction) = @_;
  182. $transaction->header->delete('X-HELO');
  183. $transaction->header->add('X-HELO', $self->qp->connection->hello_host, 0);
  184. return DECLINED;
  185. }
  186. sub populate_tests {
  187. my $self = shift;
  188. my $policy = $self->{_args}{policy};
  189. @{$self->{_helo_tests}} =
  190. qw/ is_in_badhelo invalid_localhost is_forged_literal is_plain_ip /;
  191. if ($policy eq 'rfc' || $policy eq 'strict') {
  192. push @{$self->{_helo_tests}},
  193. qw/ is_not_fqdn no_forward_dns no_reverse_dns /;
  194. }
  195. if ($policy eq 'strict') {
  196. push @{$self->{_helo_tests}}, qw/ is_address_literal no_matching_dns /;
  197. }
  198. }
  199. sub is_in_badhelo {
  200. my ($self, $host) = @_;
  201. my $error = "I do not believe you are $host.";
  202. $host = lc $host;
  203. foreach my $bad ($self->qp->config('badhelo')) {
  204. if ($bad =~ /[\{\}\[\]\(\)\^\$\|\*\+\?\\\!]/) { # it's a regexp
  205. if ($self->is_regex_match($host, $bad)) {
  206. return $error, "in badhelo";
  207. }
  208. }
  209. if ($host eq lc $bad) {
  210. return $error, "in badhelo";
  211. }
  212. }
  213. return;
  214. }
  215. sub is_regex_match {
  216. my ($self, $host, $pattern) = @_;
  217. my $error = "Your HELO hostname is not allowed";
  218. #$self->log( LOGDEBUG, "is regex ($pattern)");
  219. if (substr($pattern, 0, 1) eq '!') {
  220. $pattern = substr $pattern, 1;
  221. if ($host !~ /$pattern/) {
  222. #$self->log( LOGDEBUG, "matched ($pattern)");
  223. return $error, "badhelo pattern match ($pattern)";
  224. }
  225. return;
  226. }
  227. if ($host =~ /$pattern/) {
  228. #$self->log( LOGDEBUG, "matched ($pattern)");
  229. return $error, "badhelo pattern match ($pattern)";
  230. }
  231. return;
  232. }
  233. sub invalid_localhost {
  234. my ($self, $host) = @_;
  235. if ($self->is_localhost($self->qp->connection->remote_ip)) {
  236. $self->log(LOGDEBUG, "pass, is localhost");
  237. return;
  238. }
  239. if ($host && lc $host ne 'localhost') {
  240. $self->log(LOGDEBUG, "pass, host is localhost");
  241. return;
  242. };
  243. #$self->log( LOGINFO, "fail, not localhost" );
  244. return "You are not localhost", "invalid localhost";
  245. }
  246. sub is_plain_ip {
  247. my ($self, $host) = @_;
  248. return if ! $self->is_valid_ip($host);
  249. $self->log(LOGDEBUG, "fail, plain IP");
  250. return "Plain IP is invalid HELO hostname (RFC 2821)", "plain IP";
  251. }
  252. sub is_address_literal {
  253. my ($self, $host) = @_;
  254. my ($ip) = $host =~ /^\[(.*)\]/; # strip off any brackets
  255. return if !$ip; # no brackets, not a literal
  256. return if ! $self->is_valid_ip($ip);
  257. $self->log(LOGDEBUG, "fail, bracketed IP");
  258. return "RFC 2821 allows an address literal, but we do not","bracketed IP";
  259. }
  260. sub is_forged_literal {
  261. my ($self, $host) = @_;
  262. return if ! $self->is_valid_ip($host);
  263. # should we add exceptions for reserved internal IP space? (192.168,10., etc)
  264. $host = substr $host, 1, -1;
  265. return if $host eq $self->qp->connection->remote_ip;
  266. return "Forged IPs not accepted here", "forged IP literal";
  267. }
  268. sub is_not_fqdn {
  269. my ($self, $host) = @_;
  270. return if $host =~ m/^\[(\d{1,3}\.){3}\d{1,3}\]$/; # address literal, skip
  271. if ($host !~ /\./) { # has no dots
  272. return "HELO name is not fully qualified. Read RFC 2821", "not FQDN";
  273. }
  274. if ($host =~ /[^a-zA-Z0-9\-\.]/) {
  275. return "HELO name contains invalid FQDN characters. Read RFC 1035","invalid FQDN chars";
  276. }
  277. return;
  278. }
  279. sub no_forward_dns {
  280. my ($self, $host) = @_;
  281. return if $self->is_address_literal($host);
  282. my $res = $self->get_resolver();
  283. $host = "$host." if $host !~ /\.$/; # fully qualify name
  284. my $query = $res->query($host);
  285. if (!$query) {
  286. if ($res->errorstring eq 'NXDOMAIN') {
  287. return "HELO hostname does not exist", "no such host";
  288. }
  289. $self->log(LOGERROR, "skip, query failed (", $res->errorstring, ")");
  290. return;
  291. }
  292. my $hits = 0;
  293. foreach my $rr ($query->answer) {
  294. next unless $rr->type =~ /^(?:A|AAAA)$/;
  295. $self->check_ip_match($rr->address);
  296. $hits++;
  297. last if $self->connection->notes('helo_forward_match');
  298. }
  299. if ($hits) {
  300. $self->log(LOGDEBUG, "pass, forward DNS") if $hits;
  301. return;
  302. }
  303. return "HELO hostname did not resolve", "no forward DNS";
  304. }
  305. sub no_reverse_dns {
  306. my ($self, $host, $ip) = @_;
  307. my $res = $self->get_resolver();
  308. $ip ||= $self->qp->connection->remote_ip;
  309. my $query = $res->query($ip) or do {
  310. if ($res->errorstring eq 'NXDOMAIN') {
  311. return "no rDNS for $ip", "no rDNS";
  312. }
  313. $self->log(LOGINFO, $res->errorstring);
  314. return "error getting reverse DNS for $ip", "rDNS " . $res->errorstring;
  315. };
  316. my $hits = 0;
  317. for my $rr ($query->answer) {
  318. next if $rr->type ne 'PTR';
  319. $self->log(LOGDEBUG, "PTR: " . $rr->ptrdname);
  320. $self->check_name_match(lc $rr->ptrdname, lc $host);
  321. $hits++;
  322. }
  323. if ($hits) {
  324. $self->log(LOGDEBUG, "has rDNS");
  325. return;
  326. }
  327. return "no reverse DNS for $ip", "no rDNS";
  328. }
  329. sub no_matching_dns {
  330. my ($self, $host) = @_;
  331. # this is called iprev, or "Forward-confirmed reverse DNS" and is discussed
  332. # in RFC 5451. FCrDNS is done for the remote IP in the fcrdns plugin. Here
  333. # we do it on the HELO hostname.
  334. # consider adding status to Authentication-Results header
  335. if ( $self->connection->notes('helo_forward_match')
  336. && $self->connection->notes('helo_reverse_match'))
  337. {
  338. $self->log(LOGDEBUG, "foward and reverse match");
  339. $self->adjust_karma(1); # a perfect match
  340. return;
  341. }
  342. if ($self->connection->notes('helo_forward_match')) {
  343. $self->log(LOGDEBUG, "name matches IP");
  344. return;
  345. }
  346. if ($self->connection->notes('helo_reverse_match')) {
  347. $self->log(LOGDEBUG, "reverse matches name");
  348. return;
  349. }
  350. $self->log(LOGINFO, "fail, no forward or reverse DNS match");
  351. return "That HELO hostname fails FCrDNS", "no matching DNS";
  352. }
  353. sub check_ip_match {
  354. my $self = shift;
  355. my $ip = shift or return;
  356. my $rip = $self->qp->connection->remote_ip;
  357. if ($ip eq $rip) {
  358. $self->log(LOGDEBUG, "forward ip match");
  359. $self->connection->notes('helo_forward_match', 1);
  360. return;
  361. }
  362. my ($dns_net, $rem_net);
  363. if ($ip =~ /:/) {
  364. if ($ip =~ /::/) { $ip = Net::IP::ip_expand_address($ip, 6); }
  365. if ($rip =~ /::/) { $rip = Net::IP::ip_expand_address($rip, 6); }
  366. $dns_net = join(':', (split(/:/, $ip ))[0, 1, 2, 3, 4, 5]);
  367. $rem_net = join(':', (split(/:/, $rip))[0, 1, 2, 3, 4, 5]);
  368. }
  369. else {
  370. $dns_net = join('.', (split(/\./, $ip))[0, 1, 2]);
  371. $rem_net = join('.', (split(/\./, $rip))[0, 1, 2]);
  372. }
  373. if ($dns_net eq $rem_net) {
  374. $self->log(LOGNOTICE, "forward network match");
  375. $self->connection->notes('helo_forward_match', 1);
  376. }
  377. }
  378. sub check_name_match {
  379. my $self = shift;
  380. my ($dns_name, $helo_name) = @_;
  381. return if !$dns_name;
  382. my @dots = split(/\./, $dns_name);
  383. return if scalar @dots < 2; # not a FQDN
  384. if ($dns_name eq $helo_name) {
  385. $self->log(LOGDEBUG, "reverse name match");
  386. $self->connection->notes('helo_reverse_match', 1);
  387. return;
  388. }
  389. my $dns_dom = join('.', (split(/\./, $dns_name))[-2, -1]);
  390. my $helo_dom = join('.', (split(/\./, $helo_name))[-2, -1]);
  391. if ($dns_dom eq $helo_dom) {
  392. $self->log(LOGNOTICE, "reverse domain match");
  393. $self->connection->notes('helo_reverse_match', 1);
  394. }
  395. }