summaryrefslogtreecommitdiffstats
path: root/network/opendmarc/patches/z03_reportDestVerificationV2.patch
blob: 6e428b76ce6e87e52d200c49ba944c9a5d0966c2 (plain)
diff --git b/reports/opendmarc-reports.in a/reports/opendmarc-reports.in
index 43be1ff..fff9f8d 100755
--- b/reports/opendmarc-reports.in
+++ a/reports/opendmarc-reports.in
@@ -24,6 +24,8 @@ use POSIX;
 use MIME::Base64;
 use Net::SMTP;
 use Time::Local;
+use Net::DNS;
+use Domain::PublicSuffix;
 
 require DBD::@SQL_BACKEND@;
 
@@ -39,7 +41,6 @@ my $showversion   = 0;
 my $interval;
 
 my $gen;
-my $uri;
 
 my $buf;
 
@@ -95,8 +96,6 @@ my $dkimdomain;
 my $reason;
 my $comment;
 
-my $repdest;
-
 my $smtpstatus;
 my $smtpfail;
 
@@ -140,6 +139,18 @@ my $smtp;
 
 my $answer;
 
+my $suffix;
+my $publicsuffixlist = "/etc/opendmarc/public_suffix_list.dat";
+if (-r $publicsuffixlist) {
+	$suffix = Domain::PublicSuffix->new(
+		{ 'data_file' => $publicsuffixlist }
+	);
+}
+else
+{
+	$suffix = Domain::PublicSuffix->new();
+}
+
 ###
 ### NO user-serviceable parts beyond this point
 ###
@@ -172,6 +183,71 @@ sub usage
 	print STDERR "\t--version          print version and exit\n";
 }
 
+sub check_size_restriction
+{
+	my ($destination, $size) = @_;
+	my $report_maxbytes = $report_maxbytes_global;
+
+	# check for max report size
+	if ($destination =~ m/^(\S+)!(\d{1,15})([kmgt])?$/i)
+	{
+		$destination = $1;
+		$report_maxbytes = $2;
+		if ($3)
+		{
+			my $letter = lc($3);
+			if ($letter eq 'k')
+			{
+				$report_maxbytes = $report_maxbytes * 1024;
+			}
+			if ($letter eq 'm')
+			{
+				$report_maxbytes = $report_maxbytes * 1048576;
+			}
+			if ($letter eq 'g')
+			{
+				$report_maxbytes = $report_maxbytes * (2**30);
+			}
+			if ($letter eq 't')
+			{
+				$report_maxbytes = $report_maxbytes * (2**40);
+			}
+		}
+
+		if ($size > $report_maxbytes)
+		{
+			return 0;
+		}
+	}
+	return 1;
+}
+
+sub check_uri
+{
+	my $uri = URI->new($_[0]);
+	if (!defined($uri) ||
+	    !defined($uri->scheme) ||
+	    $uri->opaque eq "")
+	{
+		print STDERR "$progname: can't parse reporting URI for domain $domain\n";
+		return "";
+	}
+	# ensure a scheme is present
+	elsif (!defined($uri->scheme))
+	{
+		if ($verbose >= 2)
+		{
+			print STDERR "$progname: unknown URI scheme in '$repuri' for domain $domain\n";
+		}
+		return "";
+	}
+	elsif ($uri->scheme eq "mailto")
+	{
+		return $uri->opaque;
+	}
+	return "";
+}
+
 # set locale
 setlocale(LC_ALL, 'C');
 
@@ -798,86 +874,181 @@ foreach (@$domainset)
 		print STDERR "$progname: keeping report file \"$repfile\"\n";
 	}
 
+	if (!open($zipin, $zipfile))
+	{
+		print STDERR "$progname: can't read zipped report for $domain: $!\n";
+		next;
+	}
+	my $encoded_report;
+	while (read($zipin, $buf, 60*57))
+	{
+		$encoded_report .= encode_base64($buf);
+	}
+	close($zipin);
+	my $reportsize = length($encoded_report);
+
+	my $repdest = "";
+	my $repdest_fallback = "";
+
 	# decode the URI
 	@repuris = split(',', $repuri);
 
 	for $repuri (@repuris)
 	{
-		$uri = URI->new($repuri);
-		if (!defined($uri) ||
-		    !defined($uri->scheme) ||
-		    $uri->opaque eq "")
+		my $raw_address = check_uri($repuri);
+		if ($raw_address eq "")
 		{
-			print STDERR "$progname: can't parse reporting URI for domain $domain\n";
 			next;
 		}
-
-		$repdest = $uri->opaque;
-		my $report_maxbytes = $report_maxbytes_global;
-
-		# check for max report size
-		if ($repdest =~ m/^(\S+)!(\d{1,15})([kmgt])?$/i)
+		else
 		{
-			$repdest = $1;
-			$report_maxbytes = $2;
-			if ($3)
+			my $domain_orgdom = $suffix->get_root_domain(lc($domain));
+			my $address = $raw_address;
+			$address =~ s/!\d{1,15}([kmgt])?$//i;
+			my $repdestdomain = $address;
+			$repdestdomain =~ s/.*@//;
+			my $repdest_orgdom = $suffix->get_root_domain(lc($repdestdomain));
+
+			if (defined($domain_orgdom) && defined($repdest_orgdom) && $domain_orgdom eq $repdest_orgdom)
+			{
+				if (check_size_restriction($raw_address, $reportsize))
+				{
+					$repdest .= $address . ", ";
+				}
+				else
+				{
+					$repdest_fallback .= $address . ", ";
+				}
+			}
+			else
 			{
-				my $letter = lc($3);
-				if ($letter eq 'k')
+				# validate external report destinations:
+				my $replaced = 0;	# external address replaced
+				my $authorized = 0;	# external address authorized
+				my $temprepuri;
+				my $res = Net::DNS::Resolver->new(udp_timeout => 15);
+				my $reply = $res->query("$domain._report._dmarc.$repdestdomain", "TXT");
+				if ($reply)
 				{
-					$report_maxbytes = $report_maxbytes * 1024;
+					foreach my $txt ($reply->answer)
+					{
+						next unless $txt->type eq "TXT";
+						my @parts = split(';', $txt->txtdata);
+						my $type = shift @parts;
+						next unless $type =~ m/^\s*v\s*=\s*DMARC1\s*/;
+						$authorized = 1;
+						# just for debugging:
+						if ($txt->txtdata ne "v=DMARC1")
+						{
+							print STDERR "$progname: DEBUG: $domain._report._dmarc.$repdestdomain: query answer: ", $txt->txtdata, "\n";
+						}
+						foreach my $parts (@parts)
+						{
+							if ($parts =~ m/^\s*rua\s*=/)
+							{
+								$replaced = 1;
+								$parts =~ s/^\s*rua\s*=\s*//;
+								foreach my $tempuri (split(',', $parts))
+								{
+									$raw_address = check_uri($tempuri);
+									if ($raw_address eq "")
+									{
+										next;
+									}
+									my $uridomain = lc($raw_address);
+									$uridomain =~ s/.*@//;
+									$uridomain =~ s/!\d{15}([kmgt])?$//;
+									if ($repdestdomain eq $uridomain)
+									{
+										$address =~ s/!\d([kmgt])?$//i;
+										if ($verbose)
+										{
+											print STDERR "$progname: adding new reporting URI for domain $domain: $address\n";
+										}
+										if (check_size_restriction($raw_address, $reportsize))
+										{
+											$repdest .= $address . ", ";
+										}
+										else
+										{
+											$repdest_fallback .= $address . ", ";
+										}
+									}
+									else
+									{
+										if ($verbose)
+										{
+											print STDERR "$progname: ignoring new reporting URI due to differing host parts: $repdestdomain != $uridomain!\n";
+										}
+									}
+								}
+								# there should be only one part with "rua=", so stop here
+								last;
+							}
+						}
+						# there should be only one TXT record starting with "v=DMARC1", so stop here
+						last;
+					}
 				}
-				if ($letter eq 'm')
+				else
 				{
-					$report_maxbytes = $report_maxbytes * 1048576;
+					switch ($res->errorstring)
+					{
+						case "NXDOMAIN" { }				# definitely not authorized
+						case "SERVFAIL" { $authorized = 1; }		# not a definite answer, so be kind
+						case "query timed out" { $authorized = 1; }	# not a definite answer, so be kind
+						else { $authorized = 1; }			# for now we authorize anything else
+					}
 				}
-				if ($letter eq 'g')
+
+				if ($authorized && !$replaced)
 				{
-					$report_maxbytes = $report_maxbytes * (2**30);
+
+					$repdest .= $address . ", ";
 				}
-				if ($letter eq 't')
+				elsif (!$authorized)
 				{
-					$report_maxbytes = $report_maxbytes * (2**40);
+					if ($verbose)
+					{
+						print STDERR "$progname: $domain is NOT authorized to send reports to $address, dropping address! (" . $res->errorstring . ")\n";
+					}
+					next;
 				}
 			}
 		}
+	}
+	$repdest =~ s/, $//;
+	$repdest_fallback =~ s/, $//;
 
-		# Test mode, just report what would have been done
-		if ($testmode)
+	# Test mode, just report what would have been done
+	if ($testmode)
+	{
+		if ($repdest ne "")
 		{
 			print STDERR "$progname: would email $domain report for " .
-			             "$rowcount records to " .  $uri->opaque . "\n";
+				     "$rowcount records to $repdest\n";
 		}
-		# ensure a scheme is present
-		elsif (!defined($uri->scheme))
+		elsif ($repdest_fallback ne "")
 		{
-			if ($verbose >= 2)
-			{
-				print STDERR "$progname: unknown URI scheme in '$repuri' for domain $domain\n";
-			}
-			next;
+			print STDERR "$progname: would email an error report for " .
+				     "$domain to $repdest_fallback\n";
 		}
-		# send/post report
-		elsif ($uri->scheme eq "mailto")
+	}
+	else
+	{
+		if ($repdest ne "")
 		{
-			my $datestr;
-			my $report_id;
-
-			if (!open($zipin, $zipfile))
-			{
-				print STDERR "$progname: can't read zipped report for $domain: $!\n";
-				next;
-			}
+			# send out the report:
+			$boundary = hostfqdn() . "/" . time();
 
-			$boundary = "report_section";
-
- 			$report_id = $domain . "-" . $now . "@" . $repdom;
-			$datestr = strftime("%a, %e %b %Y %H:%M:%S %z (%Z)",
-			                    localtime);
+			my $report_id = $domain . "-" . $now . "@" . $repdom;
+			my $datestr = strftime("%a, %e %b %Y %H:%M:%S %z (%Z)", localtime);
 
 			$mailout  = "To: $repdest\n";
 			$mailout .= "From: $repemail\n";
-			$mailout .= "Subject: Report Domain: " . $domain . " Submitter: " . $repdom . " Report-ID: " . $report_id . "\n";
+			$mailout .= "Subject: Report Domain: " . $domain . "\n";
+			$mailout .= "    Submitter: " . $repdom . "\n";
+			$mailout .= "    Report-ID: " . $report_id . "\n";
 			$mailout .= "X-Mailer: " . $progname . " v" . $version ."\n";
 			$mailout .= "Date: " . $datestr . "\n";
 			$mailout .= "Message-ID: <$report_id>\n";
@@ -898,52 +1069,100 @@ foreach (@$domainset)
 			$mailout .= "Content-Disposition: attachment; filename=\"$zipfile\"\n";
 			$mailout .= "Content-Transfer-Encoding: base64\n";
 			$mailout .= "\n";
+			$mailout .= $encoded_report;
+			$mailout .= "\n";
+			$mailout .= "--$boundary--\n";
+			$smtpstatus = "sent";
+			$smtpfail = 0;
+			if (!$smtp->mail($repemail) ||
+			    !$smtp->to(split(', ', $repdest), {SkipBad => 1 }) ||
+			    !$smtp->data() ||
+			    !$smtp->datasend($mailout) ||
+			    !$smtp->dataend())
+			{
+				$smtpfail = 1;
+				$smtpstatus = "failed to send";
+			}
 
-			while (read($zipin, $buf, 60*57))
+			if ($verbose || $smtpfail)
 			{
-				$mailout .= encode_base64($buf);
+				# now perl voodoo:
+				$answer = ${${*$smtp}{'net_cmd_resp'}}[1] || $smtp->message() || 'unknown error';
+				chomp($answer);
+				print STDERR "$progname: $smtpstatus report for $domain to $repdest ($answer)\n";
 			}
 
+			$smtp->reset();
+		}
+		elsif ($repdest_fallback ne "")
+		{
+			# send error report to $repdest_fallback:
+			if ($verbose)
+			{
+				print STDERR "$progname: emailing an error report for $domain to $repdest_fallback\n";
+			}
+			$boundary = hostfqdn() . "/" . time();
+
+			my $report_id = $domain . "-" . $now . "@" . $repdom;
+			my $datestr = strftime("%a, %e %b %Y %H:%M:%S %z (%Z)", localtime);
+
+			$mailout  = "To: $repdest_fallback\n";
+			$mailout .= "From: $repemail\n";
+			$mailout .= "Subject: Error Report Domain: " . $domain . " Submitter: " . $repdom . " Report-ID: " . $report_id . "\n";
+			$mailout .= "X-Mailer: " . $progname . " v" . $version ."\n";
+			$mailout .= "Date: " . $datestr . "\n";
+			$mailout .= "Message-ID: <$report_id>\n";
+			$mailout .= "Auto-Submitted: auto-generated\n";
+			$mailout .= "MIME-Version: 1.0\n";
+			$mailout .= "Content-Type: multipart/report;\n";
+		        $mailout .= "    report-type=delivery-status;\n";
+		        $mailout .= "    boundary=\"$boundary\"\n";
+			$mailout .= "\n";
+			$mailout .= "This is a MIME-encapsulated message.\n";
+			$mailout .= "\n";
+			$mailout .= "--$boundary\n";
+			$mailout .= "Content-Description: DMARC Notification\n";
+			$mailout .= "Content-Type: text/plain\n";
+			$mailout .= "\n";
+			$mailout .= "This is a DMARC error report from host " . hostfqdn() . ".\n";
+			$mailout .= "\n";
+			$mailout .= "I'm sorry to have to inform you that a DMARC aggregate report\n";
+			$mailout .= "could not be delivered to any of your URIs mentioned in your DMARC\n";
+		        $mailout .= "DNS resource records because of size limitations.\n";
+			$mailout .= "\n";
+			$mailout .= "--$boundary\n";
+			$mailout .= "Content-Description: DMARC Error Report\n";
+			$mailout .= "Content-Type: text/plain\n";
+			$mailout .= "\n";
+			$mailout .= "Report-Date: " . strftime("%a, %b %e %Y %H:%M:%S %z (%Z)", localtime()) . "\n";
+			$mailout .= "Report-Domain: $domain\n";
+			$mailout .= "Report-ID: $report_id\n";
+			$mailout .= "Report-Size: $reportsize\n";
+			$mailout .= "Submitter: $repdom\n";
+			$mailout .= "Submitting-URI: $repdest_fallback\n";
 			$mailout .= "\n";
 			$mailout .= "--$boundary--\n";
-			my $reportsize = length($mailout);
-
-			if ($reportsize > $report_maxbytes)
+			$smtpstatus = "sent";
+			$smtpfail = 0;
+			if (!$smtp->mail($repemail) ||
+			    !$smtp->to(split(', ', $repdest_fallback), { SkipBad => 1 }) ||
+			    !$smtp->data() ||
+			    !$smtp->datasend($mailout) ||
+			    !$smtp->dataend())
 			{
-				# XXX -- generate an error report here
-				print STDERR "$progname: report was too large ($reportsize bytes) per limitation of URI " . $uri->opaque . " for domain $domain\n";
+				$smtpfail = 1;
+				$smtpstatus = "failed to send";
 			}
-			else
-			{
-				$smtpstatus = "sent";
-				$smtpfail = 0;
-				if (!$smtp->mail($repemail) ||
-				    !$smtp->to($repdest) ||
-				    !$smtp->data() ||
-				    !$smtp->datasend($mailout) ||
-				    !$smtp->dataend())
-				{
-					$smtpfail = 1;
-					$smtpstatus = "failed to send";
-				}
 
-				if ($verbose || $smtpfail)
-				{
-					# now perl voodoo:
-					$answer = ${${*$smtp}{'net_cmd_resp'}}[1] || $smtp->message() || 'unknown error';
-					chomp($answer);
-					print STDERR "$progname: $smtpstatus report for $domain to $repdest ($answer)\n";
-				}
+			if ($verbose || $smtpfail)
+			{
+				# now perl voodoo:
+				$answer = ${${*$smtp}{'net_cmd_resp'}}[1] || $smtp->message() || 'unknown error';
+				chomp($answer);
+				print STDERR "$progname: $smtpstatus failure notice for report for $domain to $repdest ($answer)\n";
 			}
 
 			$smtp->reset();
-
-			close($zipin);
-		}
-		else
-		{
-			print STDERR "$progname: unsupported reporting URI scheme " . $uri->scheme . " for domain $domain\n";
-			next;
 		}
 	}