# Copyright(C) 2006 David Muir Sharnoff <muir@idiom.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# This software is available without the GPL: please write if you need
# a non-GPL license. All submissions of patches must come with a
# copyright grant so that David Sharnoff remains able to change the
# license at will.
package Qpsmtpd::Plugin::Quarantine::CGI;
use CGI qw();
use CGI::Cookie;
use Carp qw(longmess);
use OOPS;
use File::Slurp;
use Data::Dumper;
use strict;
use Template;
use Net::SMTP;
use Scalar::Util qw(refaddr);
use Digest::MD5 qw(md5_hex);
use Time::CTime;
use Time::ParseDate;
use Qpsmtpd::Plugin::Quarantine::Common;
use Qpsmtpd::Plugin::Quarantine::Sendmail;
use Mail::SendVarious;
our @ISA = qw(Exporter);
our @EXPORT = qw(main);
require Exporter;
use strict;
my $template;
my $random_token;
my $cgi = new CGI;
my $md5rx = qr/[a-f0-9]{32}/;
my $pi;
my $debug = 1;
my $filtered_domains;
my $cookie_time_fmt = "%A, %d-%b-%Y %X GMT";
sub error {
local($Carp::CarpLevel) = 1;
print STDERR longmess(@_);
die "\n";
}
sub main
{
$filtered_domains = "$defaults{qpsmtpd_dir}/filter_domains";
$cgi = new CGI;
$template = Template->new({
INCLUDE_PATH => $defaults{templates},
INTERPOLATE => 1,
POST_CHOMP => 0,
EVAL_PERL => 1,
RECURSION => 1,
}) || error Template->error();
$pi = $cgi->path_info();
transaction(sub {
my $oops = get_oops();
$random_token = $oops->{quarantine}{random_token};
if ($pi =~ m{^/message/($md5rx)$}) {
handle_message($oops,$1);
} elsif ($pi =~ m{^/sender/($md5rx)/(.*)$}i) {
handle_sender($oops, $2, $1);
} elsif ($pi =~ m{^/recipient/($md5rx)/(.+)$}i) {
handle_recipient($oops, $2, $1);
} elsif ($pi =~ m{^/recipient/(.*)$}i) {
handle_unauthorized_recipient($oops, $1);
} elsif ($pi =~ m{^/admin}) {
handle_admin($oops);
} else {
print $cgi->header();
$template->process('main-menu.tt2', { cgi => $cgi, config => \%defaults}) || error $Template::ERROR;
}
});
print STDERR "E=$@\n" if $@;
error $@ if $@;
send_postponed();
print STDERR "Done\n";
}
sub handle_unauthorized_sender
{
my ($oops, $sender_encoded) = @_;
print $cgi->header();
my $qd = $oops->{quarantine} || error;
my $sender = CGI::unescape($sender_encoded) || $cgi->param('sender');
my $action = $cgi->param('action') || '';
my $psender = $qd->{senders}{$sender};
my $token = $psender && $psender->{token};
if ($action eq $defaults{button_sender_url} && $sender =~ /^([^\@]+)\@([^\@]+)$/) {
my $buf;
# sender may not exist yet
if ($psender) {
$token = md5_hex($$ . Time::HiRes::time() . $qd->{random_token} . $sender);
$psender->{token} = $token;
print STDERR "New token for $sender: $token\n";
} else {
$psender = new_sender($oops, $1, $sender);
$token = $psender->{token};
print STDERR "First token for $sender: $token\n";
}
$oops->commit();
$template->process('sender-notification.mail', {
config => \%defaults,
sender => $sender,
request_ip => $ENV{REMOTE_ADDR} || $ENV{REMOTE_HOST},
sender_url => $psender->url(),
now => scalar(localtime(time)),
}, \$buf) || error $Template::ERROR;
print STDERR "Message to send to $sender:\n$buf" if $debug;
sendmail_or_postpone(from => $defaults{send_from}, to => $sender, message => $buf);
$template->process('sender-access-url-sent.tt2', {
config => \%defaults,
sender => $sender,
}) || error $Template::ERROR;
} else {
$template->process('unauthorized-sender.tt2', {
config => \%defaults,
sender => $sender
}) || error $Template::ERROR;
}
}
sub handle_sender
{
my ($oops, $sender_encoded, $sender_checksum) = @_;
print $cgi->header();
my $qd = $oops->{quarantine} || error;
my $sender = CGI::unescape($sender_encoded);
my $action = $cgi->param('action') || '';
my $psender = $qd->{senders}{$sender};
my $docommit = 0;
unless ($psender) {
$psender = new_sender($oops, $sender, $sender);
$docommit = 1;
}
my $correct_checksum = $psender->{token};
if ($action eq $defaults{button_sender_url}) {
return handle_unauthorized_sender($oops, $sender_encoded);
}
if ($sender_checksum ne $correct_checksum) {
my %error = ();
%error = (
error => 'Your URL is invalid',
error_detail => 'The URL you used does not contain the correct authentication tokens for your email address. Please ask for a new authentication URL.',
) if $sender;
$template->process('unauthorized-sender.tt2', {
config => \%defaults,
sender => $sender,
%error,
}) || error $Template::ERROR;
print STDERR "CHECKSUM MISMATCH $sender_checksum vs $correct_checksum\n";
return;
}
my %extra;
my %args = (
sender => $sender,
psender => $psender,
sender_url => $psender->url(),
config => \%defaults,
otherheaders => [ grep(! $_->{done}, values %{$psender->{headers}}) ],
);
my ($acommit, $doform, @message) = sender_action($oops, $sender, $psender, $action, \%args, 1);
$docommit ||= $acommit;
$oops->commit() if $docommit;
$extra{message} = message('Sender', @message);
if ($doform) {
$template->process('sender-menu.tt2', {
%extra,
%args,
}) || error $Template::ERROR;
}
}
sub sender_action
{
my ($oops, $sender, $psender, $action, $args, $canshow) = @_;
my $qd = $oops->{quarantine} || error;
my $docommit = 0;
my $doform = 1;
my @message;
if ($action eq $defaults{button_sender_update}) {
my $na = $cgi->param('new_action');
my $bf = $cgi->param('renotify_days');
if ($na ne ($psender->{action} || 'quarantine')) {
$psender->{action} = $na;
if ($na eq 'discard') {
push(@message, "When we think a message from you might be SPAM, we'll just drop it.");
} elsif ($na eq 'quarantine') {
push(@message, "When we think a message from you might be SPAM, we'll quarantine so that you can verify that it isn't spam.");
} elsif ($na eq 'bounce') {
push(@message, "When we think a message from you might be SPAM, we'll bounce it.");
} else {
error;
}
}
if ($bf != ($psender->{renotify_days} || $defaults{renotify_sender_ip})) {
$psender->{renotify_days} = $bf;
push(@message, "You will only be notified every $bf days (per IP address) if we think a message might be spam");
}
$docommit = 1;
} elsif ($action eq $defaults{button_sender_reset_timer}) {
if (%{$psender->{send_ip_used}}) {
$psender->{sender}{send_ip_used} = {};
$docommit = 1;
push(@message, "Notification timers reset");
}
} elsif ($action eq $defaults{button_sender_replace_token}) {
require Time::HiRes;
$psender->{token} = md5_hex($qd->{random_token} . $sender . Time::HiRes::time() . $$ . $ENV{REMOTE_HOST} . $ENV{REMOTE_ADDR});
$oops->commit;
$docommit = 0;
my $buf;
$template->process('sender-notification.mail', {
%$args,
sender_url => $psender->url(),
request_ip => $ENV{REMOTE_ADDR} || $ENV{REMOTE_HOST},
now => scalar(localtime(time)),
}, \$buf) || error $Template::ERROR;
sendmail_or_postpone(from => $defaults{send_from}, to => $sender, message => $buf);
if ($canshow) {
$template->process('access-url-sent.tt2', {
config => \%defaults,
recipient => $sender,
}) || error $Template::ERROR;
print STDERR "## New sender token sent to $sender\n";
$doform = 0;
} else {
push(@message, "New sender token sent to $sender");
}
} elsif ((undef, $docommit, @message) = handle_other_messages($oops, $psender, $action, $args, undef)) {
# nada
} elsif (! $action) {
# nada
} else {
error "action=$action";
}
return ($docommit, $doform, @message);
}
sub handle_other_messages
{
my ($oops, $psender, $action, $templateargs, $tt2) = @_;
my $do;
my $set;
if ($action eq $defaults{button_sender_delete_checked}) {
$do = 'delete';
$set = 'checked';
} elsif ($action eq $defaults{button_sender_delete_all}) {
$do = 'delete';
$set = 'all';
} elsif ($action eq $defaults{button_sender_send_checked}) {
$do = 'send';
$set = 'checked';
} else {
return 0;
}
my @set;
for my $hsum (keys %{$psender->{headers}}) {
next if $psender->{headers}{$hsum}{done};
push(@set, $psender->{headers}{$hsum}) if $set eq 'all' || $cgi->param("cb-".$hsum);
}
my @message;
if ($do eq 'delete') {
for my $h (@set) {
message_handled($oops, $h, 'deleted');
push(@message, "Deleted message w/Subject $h->{subject}");
}
} else {
for my $h (@set) {
message_handled($oops, $h, 'sent');
sendmail_or_postpone(from => ($h->{sender}{address} || '<>'), to => $h->{recipients}, header => $h->{header}, body => $h->{body}{body});
push(@message, "Sent message w/Subject $h->{subject}");
}
}
return (1, 1, @message) unless $tt2;
$oops->commit();
my $message = message('Sender', @message);
$template->process($tt2, {
%$templateargs,
message => $message,
}) || error $Template::ERROR;
return 1;
}
sub message_handled
{
my ($oops, $h, $how) = @_;
error if $h->{done};
$h->{done} = $how;
my $qd = $oops->{quarantine} || error;
for my $r (@{$h->{recipients}}) {
my $rd = $qd->{recipients}{$r};
if ($rd->{headers}{$h->{cksum}}) {
$rd->{mcount}--;
}
}
}
sub handle_recipient
{
my ($oops, $recipient_encoded, $recipient_checksum) = @_;
print $cgi->header();
my $qd = $oops->{quarantine} || error;
my $recipient = CGI::unescape($recipient_encoded);
my $action = $cgi->param('action') || '';
my $rd = $qd->{recipients}{$recipient};
$rd = new_recipient($oops, $recipient)
unless $rd;
my $correct_checksum = $rd->{token} || md5_hex($qd->{random_token} . $recipient);
if ($action eq $defaults{button_recipient_url}) {
return handle_unauthorized_recipient($oops, $recipient_encoded);
}
if ($recipient_checksum ne $correct_checksum) {
$template->process('unauthorized-recipient.tt2', {
config => \%defaults,
recipient => $recipient,
error => 'Your URL is invalid',
error_detail => 'The URL you used does not contain the correct authentication tokens for your email address. Please ask for a new authentication URL.',
}) || error $Template::ERROR;
print STDERR "CHECKSUM MISMATCH $recipient_checksum vs $correct_checksum\n";
return;
}
my %args = (
config => \%defaults,
recipient => $recipient,
rd => $rd,
);
(my $showform, my @message) = recipient_action($oops, $recipient, $rd, \%args, 1, $action);
my $message = message('Sender', @message);
if ($showform) {
$template->process('recipient-menu.tt2', {
message => $message,
%args,
}) || error $Template::ERROR;
}
}
sub recipient_action
{
my ($oops, $recipient, $rd, $args, $canshow, $action) = @_;
my $qd = $oops->{quarantine} || error;
my @message;
my $docommit;
my $showform = 1;
if ($action eq $defaults{button_recipient_update}) {
my $na = $cgi->param('new_action');
if ($na eq 'drop') {
$rd->{action} = 'drop';
$oops->commit();
print STDERR "We will now drop messages for $recipient\n";
push(@message, 'Settings changed: spammy messages for you will now be dropped');
} elsif ($na eq 'quarantine') {
delete $rd->{action};
$oops->commit();
print STDERR "We will now quarantine messages for $recipient\n";
push(@message, 'Settings changed: spammy messages for you will now be quarantined');
} elsif ($na eq 'forward') {
require Mail::Address;
my ($new, @junk) = Mail::Address->parse($cgi->param('new_address'));
if (! $new or @junk or ! $new->host) {
push(@message, 'Please enter a simple address (user@host) for forwarding');
} else {
if (domain_is_filtered($new->host)) {
push(@message, 'You cannot forward to @'.$new->host.' addresses because they suffer from the same problem that your current address has');
} else {
my @tosend;
for my $hsum (keys %{$rd->{headers}}) {
my $h = $rd->{headers}{$hsum};
next if $h->{done};
delete $rd->{headers}{$hsum};
push(@tosend, {
to => $recipient,
sender => $h->{sender}{address},
header => $h->{header},
body => $h->{body},
});
my @newrlist = grep( $_ ne $recipient, @{$h->{recipients}});
message_handled($oops, $h, 'done') unless @newrlist;
$h->{recipients} = bless [ @newrlist ], 'Quarantine::RecipientList';
}
send_queued($new->format, @tosend);
$rd->{action} = 'forward';
$rd->{new_address} = $new->format;
$oops->commit();
push(@message, sprintf("Settings changed: spammy messages for you will now be forwarded. Quarantined messages released: %d", scalar(@tosend)));
my $buf;
$template->process('recipient-forwarding.mail', {
%$args,
new_address => $new->format,
request_ip => $ENV{REMOTE_ADDR} || $ENV{REMOTE_HOST},
recipient_url => $rd->url($qd),
now => scalar(localtime(time)),
}, \$buf) || error $Template::ERROR;
print STDERR "Message to send to $recipient:\n$buf" if $debug;
sendmail_or_postpone(from => $defaults{send_from}, to => $new->address, message => $buf);
}
}
} else {
error;
}
} elsif ($action eq $defaults{button_recipient_replace_token}) {
require Time::HiRes;
$rd->{token} = md5_hex($qd->{random_token} . $recipient . $rd->{new_address} . Time::HiRes::time() . $$ . $ENV{REMOTE_HOST} . $ENV{REMOTE_ADDR});
$oops->commit;
my $buf;
$template->process('recipient-notification.mail', {
%$args,
request_ip => $ENV{REMOTE_ADDR} || $ENV{REMOTE_HOST},
recipient_url => $rd->url($qd),
now => scalar(localtime(time)),
}, \$buf) || error $Template::ERROR;
print STDERR "Message to send to $recipient:\n$buf" if $debug;
sendmail_or_postpone(from => $defaults{send_from}, to => $recipient, message => $buf);
if ($rd->{action} eq 'forward') {
print STDERR "ALSO Message to send to $rd->{new_address}\n" if $debug;
sendmail_or_postpone(from => $defaults{send_from}, to => $rd->{new_address}, message => $buf);
}
if ($canshow) {
$template->process('access-url-sent.tt2', {
%$args,
}) || error $Template::ERROR;
$showform = 0;
} else {
push(@message, "Recipient access URL sent");
}
}
return($showform, @message);
}
sub handle_unauthorized_recipient
{
my ($oops, $recipient_encoded) = @_;
print $cgi->header();
my $qd = $oops->{quarantine} || error;
my $recipient = CGI::unescape($recipient_encoded) || $cgi->param('recipient');
my $action = $cgi->param('action') || '';
my $rd = $qd->{recipients}{$recipient};
my $token = ($rd && $rd->{token}) || md5_hex($qd->{random_token} . $recipient);
if ($action eq $defaults{button_recipient_url} && $recipient =~ /^[^\@]+\@([^\@]+)$/) {
my $buf;
# recipient may not exist yet
$template->process('recipient-notification.mail', {
config => \%defaults,
recipient => $recipient,
request_ip => $ENV{REMOTE_ADDR} || $ENV{REMOTE_HOST},
recipient_url => "$defaults{baseurl}/recipient/$token/$escape{$recipient}",
now => scalar(localtime(time)),
}, \$buf) || error $Template::ERROR;
print STDERR "Message to send to $recipient:\n$buf" if $debug;
sendmail_or_postpone(from => $defaults{send_from}, to => $recipient, message => $buf);
if ($rd && ($rd->{action} eq 'forward')) {
print STDERR "ALSO Message to send to $rd->{new_address}\n" if $debug;
sendmail_or_postpone(from => $defaults{send_from}, to => $rd->{new_address}, message => $buf);
}
$template->process('access-url-sent.tt2', {
config => \%defaults,
recipient => $recipient,
}) || error $Template::ERROR;
} else {
$template->process('unauthorized-recipient.tt2', {
config => \%defaults,
recipient => $recipient
}) || error $Template::ERROR;
}
}
sub handle_message
{
my ($oops,$hdr_sum) = @_;
print $cgi->header();
my $qd = $oops->{quarantine} || error;
my $h = $qd->{headers}{$hdr_sum};
unless ($h) {
$template->process('error.tt2', {
config => \%defaults,
error => "Message not found",
verbose => "Your message was not found in our database. We expire messages fairly quickly so it may be that you waited too long. Please re-send your original message to start the process over again."
}) || error $Template::ERROR;
return;
};
my $otherheaders = [ grep(refaddr($_) != refaddr($h) && ! $_->{done}, values %{$h->{sender}{headers}}) ];
my $repeat = ($cgi->referer() =~ /\Q$defaults{baseurl}\E/);
my (%args) = (
header => $h,
sender => $h->{sender}{canonical},
psender => $h->{sender},
recipients => join(', ', @{$h->{recipients}}),
otherheaders => $otherheaders,
repeat => $repeat,
config => \%defaults,
baseurl => $defaults{baseurl},
sender_url => $h->{sender}->url(),
);
my $action = $cgi->param('action') || '';
print STDERR "Action = '$action'\n" if $debug;
if ($action eq $defaults{button_sender_delete}) {
message_handled($oops, $h, 'deleted');
$oops->commit();
$template->process('sender-action-taken.tt2', {
%args,
code => 'DELETE',
message => "Your message was deleted. Thank you.",
}) || error $Template::ERROR;
} elsif ($action eq $defaults{button_sender_send}) {
sendmail_or_postpone(from => $h->{sender}{address}, to => $h->{recipients}, header => $h->{header}, body => $h->{body}{body});
message_handled($oops, $h, 'sent');
$oops->commit();
$template->process('sender-action-taken.tt2', {
%args,
code => 'SENT',
message => 'Your message was released from quarantine and is now on its way to its destination',
}) || error $Template::ERROR;
} elsif ($action eq 'Discard My Mail') {
$h->{sender}{silentely_discard} = {
host => $cgi->remote_host(),
agent => $cgi->user_agent(),
date => time,
};
$oops->commit();
$template->process('sender-action-taken.tt2', {
%args,
code => 'DISCARD_ALL',
message => 'Mail from you that we think is spam will be silently discarded. This is not reversable. Do not ask.',
}) || error $Template::ERROR;
} elsif ($action eq '') {
if ($h->{sender}{send_ip_used}) {
$h->{sender}{send_ip_used} = {};
$oops->commit();
}
my $x;
$template->process('message-menu.tt2', \%args, \$x) or error $Template::ERROR;
# print STDERR "X=$x\n";
print $x;
} elsif (handle_other_messages($oops, $h->{sender}, $action, \%args, 'message-menu.tt2')) {
# nada
} else {
error "action=$action";
}
}
sub handle_admin
{
my ($oops) = @_;
my $qd = $oops->{quarantine} || error;
my (%cookies) = CGI::Cookie->fetch();
my %args = ( config => \%defaults );
my $authorized = 0;
my $action = $cgi->param('action') || '';
my $setcookie;
if ($action eq $defaults{button_login}) {
if (authorized_admin($cgi->param('user'), $cgi->param('pass'))) {
my $expire = strftime($cookie_time_fmt, gmtime(time + 86400*30));
$setcookie = CGI::Cookie->new(
-name => 'admin',
-value => $cgi->param('user')
. ':'
. md5_hex($qd->{random_token} . $cgi->param('user') . $ENV{REMOTE_ADDR}),
-expires => $expire,
);
$authorized = $cgi->param('user');
print $cgi->header(-cookie => $setcookie);
} else {
$args{error} = "Invalid login";
$args{message} = "We're logging your IP address";
print STDERR "Bad admin password guess for ".$cgi->param('user')." from $ENV{REMOTE_ADDR}\n";
}
} elsif ($action eq $defaults{button_logout}) {
print $cgi->header(-cookie => CGI::Cookie->new(
-name => 'admin',
-value => 'nope',
));
%cookies = ();
}
unless ($setcookie) {
print $cgi->header();
if ($cookies{admin}) {
my $v = $cookies{admin}->value();
$v =~ m/^([^:]+):(.*)/;
my $u = $1;
my $md5 = $2;
my $verify = md5_hex($qd->{random_token} . $u . $ENV{REMOTE_ADDR});
if ($md5 eq $verify) {
$authorized = $u;
} elsif ($v ne 'nope') {
$args{error} = "Invalid login cookie";
$args{message} = "Please log in again";
print STDERR "Invalid admin cookie for $u from $ENV{REMOTE_ADDR}\n";
}
}
}
my $email = $cgi->param('lookupemail') || CGI::unescape($cgi->param('adminemail'));
if ($authorized && $email) {
$args{email} = $email;
$args{hiddenstate} = qq{<input type="hidden" name="adminemail" value="$escape{$email}">};
$args{adminemail} = $escape{$email};
my $psender = $qd->{senders}{$email};
if ($psender) {
$args{psender} = $psender;
$args{sender} = $psender;
$args{otherheaders} = [ grep(! $_->{done}, values %{$psender->{headers}}) ];
$args{sender_url} = $psender->url;
}
my $rd = $qd->{recipients}{$email};
if ($rd) {
$args{rd} = $rd;
$args{recipient} = $email;
$args{recipient_url} = $rd->url($qd);
}
if ($action eq $defaults{button_lookup_email}) {
# nothing
} else {
for my $button (keys %defaults) {
next unless $button =~ /^button_/;
next unless $action eq $defaults{$button};
print STDERR "Button $button pressed\n";
if ($button =~ /sender/) {
(my $docommit, undef, my @message) = sender_action($oops, $email, $psender, $action, \%args, 0);
$oops->commit() if $docommit;
$args{smessage} = message('Sender', @message);
last;
} elsif ($button =~ /recipient/) {
(undef, my @message) = recipient_action($oops, $email, $rd, \%args, 0, $action);
$args{rmessage} = message('Recipient', @message);
last;
}
}
}
}
$args{authorized} = $authorized;
$template->process('admin.tt2', \%args) || error $Template::ERROR;
return;
}
sub message
{
my ($role, @message) = @_;
return "" unless @message;
print STDERR "## $role: @message\n";
return "<p>\n".join("\n</p><p>\n", @message)."\n</p>\n";
}
sub send_queued
{
my ($to, @list) = @_;
for my $m (@list) {
sendmail_or_postpone(
from => ($m->{sender} || '<>'),
to => $m->{to},
header => $m->{header},
body => $m->{body},
);
}
}
sub authorized_admin
{
my ($user, $pass) = @_;
return 0 unless $user =~ /^\w/;
open(PWFILE, "<$defaults{admin_passwd_file}") || error;
while(<PWFILE>) {
next if /^$/;
next if /^#/;
chomp;
my ($u, $p) = split(':', $_);
next if $u ne $user;
return 1 if crypt($pass, $p) eq $p;
printf STDERR "Attempted login: %s '%s' ne '%s'\n", $pass, $p, crypt($pass, $p);
return 0;
}
close(PWFILE);
print STDERR "User $user not found in password file\n";
return 0;
}
my %filter_domains;
sub domain_is_filtered
{
my ($domain) = @_;
unless (%filter_domains) {
open(DOMS, "<$filtered_domains") || error "open $filtered_domains: $!";
while(<DOMS>) {
next if /^#/;
next if /^$/;
chomp;
$filter_domains{$_} = 1;
}
close(DOMS);
}
return match_domain($domain, \%filter_domains);
}
sub match_domain
{
my ($domain, $hashref) = @_;
while ($domain) {
if ($hashref->{$domain}) {
return 1;
}
$domain =~ s/^[^\.]+// or last;
$domain =~ s/^\.//;
}
return 0;
}
# for the benifit of the Template module...
sub Quarantine::Sender::cookie
{
my ($sender) = @_;
return "foo";
}
1;