2006年06月30日
届いたメールの料理の仕方 (3) 〜qmail編〜
mobile-valve
携帯電話からのメールをあるプログラム(process-ktai-mailとします)で処理したいとします。
携帯メールのenvelope fromのドメインパートはキャリア毎に決まっているので、ドメインパートを見れば携帯メールかどうかを判断することができます。
しかし、この判断ロジックを同じようなプログラムのそれぞれに実装するのは面倒ですしDRY (Don't Repeat Yourself)の原則からも外れるので、こんな風にしてみましょう。
- mobile-valveというプログラムが、携帯メールかどうかの判断を行う。
- mobile-valveは、携帯メールの場合はexit 0(後続の配送命令を実行する)し、そうでない場合はexit 99(後続の配送命令を実行しない)する。
- process-ktai-mailは、dot-qmailでmobile-valuveの次の行に書く。
例えばこんなdot-qmailにした場合、
| mobile-valve
| process-ktai-mail
携帯メールの場合はmobile-valveがexit 0するので次のprocess-ktai-mailが実行され、携帯メールではない場合はmobile-valveがexit 99するのでそこで配送命令は終わり、process-ktai-mailは実行されません。
このようにすれば、mobile-valveは他のdot-qmailでも使い回せますし、process-ktai-mailに渡るのは携帯メールだけに限定できます。
実はひとつ抜け道があるのですが、それは後半で紹介します。
spf-valve
次の例はSPFの判定をするspf-valveです。
SPFについては末尾の参考資料を参照して欲しいのですが、簡単にいうと、メールの差出人の詐称を防ぐための枠組みです。SPFパッチを当てたqmailの場合、「Received-SPF」というヘッダが追加され、そこにSPFの判定結果が記されています。
SPFの判定結果にはいくつか種類があるのですが、「fail」の場合は詐称されているとみなしてよいでしょう。また、「softfail」の場合も状況によっては詐称とみなせます。
というわけで、spf-valveは以下のようにしてみましょう。
- SPFを使って差出人が詐称されているかどうかの判断を行う。
- SPFの判定結果が「fail」か「softfail」の場合はexit 99(後続の配送命令を実行しない)し、そうでない場合はexit 0(後続の配送命令を実行する)する。
- オプションによって、「softfail」を詐称されていない(exit 0)とみなすこともできる。
例えばこんなdot-qmailにした場合、
| spf-valve
| process-mail
差出人を詐称したメールの場合は、spf-valveがexit 99するので次のprocess-mailは実行されません。
最近の迷惑メールはきちんとSPFをpassするように送ってくるため、迷惑メールの排除にSPFを使うのはあまり効果がないでしょう。あくまでSPFは詐称を防ぐものです。
mobile-valve + spf-valve
さて、mobile-valveのところで「抜け道がある」と書きました。
それはこんな抜け道です。
mobile-valveはenvelope fromのドメインパートを元に携帯メールかどうかを判断するのですが、このenvelope fromは詐称することができます。つまり、envelope fromがXXX@docomo.ne.jpとなるようにPCから送ったメールは、mobile-valveで「携帯メール」と判定されてしまいます。
これを回避するには詐称を検出できればよいわけで、ここでSPFの登場です。
幸い、各携帯キャリア(DoCoMo、au、Vodafone)はSPFに対応しています。
$ dig +noall +answer -t txt docomo.ne.jp
docomo.ne.jp. 85570 IN TXT "v=spf1 +ip4:203.138.203.0/24 ~all"
$ dig +noall +answer -t txt ezweb.ne.jp
ezweb.ne.jp. 3600 IN TXT "v=spf1 include:spf-im1.ezweb.ne.jp include:spf-im2.ezweb.ne.jp include:spf-im3.ezweb.ne.jp include:spf-im4.ezweb.ne.jp include:spf-im5.ezweb.ne.jp include:spf-im6.ezweb.ne.jp include:spf-nm.ezweb.ne.jp include:spf-az.ezweb.ne.jp ~all"
$ dig +noall +answer -t txt t.vodafone.ne.jp
t.vodafone.ne.jp. 1800 IN TXT "v=spf1 include:spf01.vodafone.ne.jp include:spf02.vodafone.ne.jp include:spf99.vodafone.ne.jp ~all"
つまり、mobile-valveとspf-valveを組み合わせれば、携帯メール以外を完全に排除することができるわけです。
なお、各キャリアのSPFの設定を見ると、「~all」となっているのでキャリアゲートウェイ以外からのメールはSPFの判定結果がsoftfailとなります。ですので携帯メールに限った用途では「softfail」の場合もspf-valveで詐称とみなした方がよいでしょう。
さて、例えばこんなdot-qmailの場合、
| mobile-valve
| spf-valve
| process-ktai-mail
非携帯メールならば、mobile-valveで配送処理が終わります。次に携帯メールでenvelope fromを詐称している場合はspf-valveで配送処理が終わります。結果的に、process-ktai-mailに渡るのは、詐称されていない携帯メールのみ、となります。
参考資料
- SPF: A Sender Policy Framework to Prevent Email Forgery
- qmail SPF (Sender Policy Framework) patch [日本語訳]
- Sender ID:送信者側の設定作業 - @IT
- 携帯各社、なりすましメール対策で送信ドメイン認証に対応
- 迷惑メール対策を強化|ボーダフォン
ソースコード
mobile-valve
#!/bin/bash
# Copyright (c) 2006-2008 KLab Inc. All rights reserved.
usage() {
echo 'usage:'
echo ' mobile-valve [ -d ] [ -e ] [ -v ] [ -l LOGLABEL ]'
exit 111
}
LABEL=
AT_DOCOMO='*@docomo.ne.jp'
PAT_VODAFONE='*@?.vodafone.ne.jp *@softbank.ne.jp *@disney.ne.jp'
PAT_EZWEB='*@ezweb.ne.jp *@ido.ne.jp'
PAT_WILLCOM='*@pdx.ne.jp'
PATTERN=
while getopts "devl:" opt; do
case "$opt" in
l)
LABEL=$OPTARG
;;
d)
PATTERN="$PATTERN $PAT_DOCOMO"
;;
v)
PATTERN="$PATTERN $PAT_VODAFONE"
;;
e)
PATTERN="$PATTERN $PAT_EZWEB"
;;
*)
usage
;;
esac
done
shift $(($OPTIND - 1))
if [ -z "$PATTERN" ]; then
PATTERN="$PAT_DOCOMO $PAT_VODAFONE $PAT_EZWEB $PAT_WILLCOM"
fi
# echo ">$PATTERN<"
EUSER=$(id -n -u)
shopt -s extglob
case "$LABEL" in
+([a-zA-Z0-9_-]))
TAG="mobile/${EUSER}/${LABEL}"
;;
*)
TAG="mobile/${EUSER}/${EUSER}"
;;
esac
log() {
logger -i -p mail.info -t "$TAG" -- "sender=$SENDER $@"
}
if [ -z "$SENDER" -o "$SENDER" = '#@[]' ]; then
log "empty SENDER"
exit 99
fi
for p in $PATTERN; do
case "$SENDER" in
$p)
exit 0
;;
esac
done
log "result=reject"
exit 99
# Local Variables:
# sh-basic-offset: 2
# tab-width: 2
# coding: euc-jp
# End:
# vi: set ts=2 sw=2 sts=0 :
spv-valve
#!/usr/bin/env perl
#
# Copyright (c) 2005-2008 KLab Inc. All rights reserved.
#
use strict;
use warnings;
use Carp;
use Getopt::Long;
use Sys::Syslog qw(:DEFAULT setlogsock);
use POSIX qw(strftime setlocale LC_TIME);
use Data::Dumper;
$Data::Dumper::Indent = 1;
$Data::Dumper::Deepcopy = 1;
my $RCSID = q$Id: spf-valve,v 1.8 2008/02/13 11:22:32 KBoraKun Exp $;
my $REVISION = $RCSID =~ /,v ([\d.]+)/ ? $1 : 'unknown';
my $PROG = substr($0, rindex($0, '/')+1);
my $Verbose = 0;
my $Logger_Label = '';
# exit code
my $OK = 0;
my $OK_IGNORE_NEXT_DELIVER = 99;
my $FAIL_RETRY = 111;
sub usage(@) {
my $mesg = shift;
print "[ERROR] $mesg\n" if $mesg;
print "usage:\n";
print " $PROG [ -v ] [ -s ] [ -k ] [ -d DOMAIN ] [ -l LOGLABEL ]\n";
print "
v$REVISION
";
exit $FAIL_RETRY;
}
# --------------------------------------------------------------------
# M A I N
# --------------------------------------------------------------------
MAIN: {
my %opt;
Getopt::Long::Configure("bundling");
GetOptions(\%opt,
'verbose|v+' => \$Verbose,
'ktai|k',
'domain|d=s@',
'label|l=s',
'allow_softfail|s',
'help|h|?') or &usage();
&usage() if exists $opt{'help'};
my $euser = (getpwuid($<) or 'unknown');
my $label = "spf/$euser";
if ($opt{'label'} and $opt{'label'} =~ /^[a-zA-Z0-9_-]+$/) {
$label .= "/$opt{'label'}";
} else {
$label .= "/$euser";
}
$Logger_Label = $label;
if ($Verbose >= 1) {
setlogmask &Sys::Syslog::LOG_UPTO(&Sys::Syslog::LOG_INFO);
} else {
setlogmask &Sys::Syslog::LOG_UPTO(&Sys::Syslog::LOG_NOTICE);
}
my @domain;
if ($opt{'ktai'}) {
push @domain, 'docomo.ne.jp', 'ezweb.ne.jp', 'vodafone.ne.jp', 'softbank.ne.jp', 'disney.ne.jp';
} elsif ($opt{'domain'}) {
push @domain, @{ $opt{'domain'} };
}
logger2('domain='.join(',',@domain));
my %reject = ('fail' => 1, 'softfail' => 1);
$reject{'softfail'} = 0 if $opt{'allow_softfail'};
my $envelope_from = '';
my $spf = ''; # pass none fail unknown neutral softfail
my $spf_mesg = '';
if (exists $ENV{'SENDER'}) {
$envelope_from = $ENV{'SENDER'} if $ENV{'SENDER'};
} else {
logger("missing environment variable 'SENDER'.");
exit $OK_IGNORE_NEXT_DELIVER;
}
while (<>) {
s/[\r\n]+$//;
if (/^$/) {
last;
} elsif (/^Received-SPF:\s*(\S+)\s*(.+)/i) {
$spf = $1;
$spf_mesg = $2;
last;
}
}
if (@domain and $envelope_from) {
my $need_check = 0;
foreach my $d (@domain) {
if ($envelope_from =~ /${d}$/i) {
$need_check = 1;
last;
}
}
unless ($need_check) {
logger2("sender=$envelope_from spf=$spf spf_mesg=$spf_mesg result=$OK (no check)");
exit $OK; # no check.
}
}
if ($reject{lc($spf)}) {
logger("sender=$envelope_from spf=$spf spf_mesg=$spf_mesg result=$OK_IGNORE_NEXT_DELIVER (fail or softfail)");
exit $OK_IGNORE_NEXT_DELIVER;
} else {
logger2("sender=$envelope_from spf=$spf spf_mesg=$spf_mesg result=$OK (checked)");
exit $OK;
}
}
# --------------------------------------------------------------------
# S U B R O U T I N E
# --------------------------------------------------------------------
sub _logger {
my $prio = shift;
if ($Sys::Syslog::VERSION <= 0.10) {
my $oldlocale = setlocale(LC_TIME);
setlocale(LC_TIME, 'C');
my $timestamp = strftime "%b %e %T", localtime;
setlocale(LC_TIME, $oldlocale);
openlog "$timestamp $Logger_Label", 'pid', 'mail';
} else {
openlog "$Logger_Label", 'pid', 'mail';
}
syslog $prio, '%s', join(' ', @_);
closelog;
}
sub logger {
_logger('notice', @_);
}
sub logger2 {
_logger('info', @_);
}
__END__
# for Emacsen
# Local Variables:
# tab-width: 4
# cperl-indent-level: 4
# coding: euc-jp
# End:
# vi: set ts=4 sw=4 sts=0 :
# Copyright (c) 2006-2008 KLab Inc. All rights reserved.
usage() {
echo 'usage:'
echo ' mobile-valve [ -d ] [ -e ] [ -v ] [ -l LOGLABEL ]'
exit 111
}
LABEL=
AT_DOCOMO='*@docomo.ne.jp'
PAT_VODAFONE='*@?.vodafone.ne.jp *@softbank.ne.jp *@disney.ne.jp'
PAT_EZWEB='*@ezweb.ne.jp *@ido.ne.jp'
PAT_WILLCOM='*@pdx.ne.jp'
PATTERN=
while getopts "devl:" opt; do
case "$opt" in
l)
LABEL=$OPTARG
;;
d)
PATTERN="$PATTERN $PAT_DOCOMO"
;;
v)
PATTERN="$PATTERN $PAT_VODAFONE"
;;
e)
PATTERN="$PATTERN $PAT_EZWEB"
;;
*)
usage
;;
esac
done
shift $(($OPTIND - 1))
if [ -z "$PATTERN" ]; then
PATTERN="$PAT_DOCOMO $PAT_VODAFONE $PAT_EZWEB $PAT_WILLCOM"
fi
# echo ">$PATTERN<"
EUSER=$(id -n -u)
shopt -s extglob
case "$LABEL" in
+([a-zA-Z0-9_-]))
TAG="mobile/${EUSER}/${LABEL}"
;;
*)
TAG="mobile/${EUSER}/${EUSER}"
;;
esac
log() {
logger -i -p mail.info -t "$TAG" -- "sender=$SENDER $@"
}
if [ -z "$SENDER" -o "$SENDER" = '#@[]' ]; then
log "empty SENDER"
exit 99
fi
for p in $PATTERN; do
case "$SENDER" in
$p)
exit 0
;;
esac
done
log "result=reject"
exit 99
# Local Variables:
# sh-basic-offset: 2
# tab-width: 2
# coding: euc-jp
# End:
# vi: set ts=2 sw=2 sts=0 :
#!/usr/bin/env perl
#
# Copyright (c) 2005-2008 KLab Inc. All rights reserved.
#
use strict;
use warnings;
use Carp;
use Getopt::Long;
use Sys::Syslog qw(:DEFAULT setlogsock);
use POSIX qw(strftime setlocale LC_TIME);
use Data::Dumper;
$Data::Dumper::Indent = 1;
$Data::Dumper::Deepcopy = 1;
my $RCSID = q$Id: spf-valve,v 1.8 2008/02/13 11:22:32 KBoraKun Exp $;
my $REVISION = $RCSID =~ /,v ([\d.]+)/ ? $1 : 'unknown';
my $PROG = substr($0, rindex($0, '/')+1);
my $Verbose = 0;
my $Logger_Label = '';
# exit code
my $OK = 0;
my $OK_IGNORE_NEXT_DELIVER = 99;
my $FAIL_RETRY = 111;
sub usage(@) {
my $mesg = shift;
print "[ERROR] $mesg\n" if $mesg;
print "usage:\n";
print " $PROG [ -v ] [ -s ] [ -k ] [ -d DOMAIN ] [ -l LOGLABEL ]\n";
print "
v$REVISION
";
exit $FAIL_RETRY;
}
# --------------------------------------------------------------------
# M A I N
# --------------------------------------------------------------------
MAIN: {
my %opt;
Getopt::Long::Configure("bundling");
GetOptions(\%opt,
'verbose|v+' => \$Verbose,
'ktai|k',
'domain|d=s@',
'label|l=s',
'allow_softfail|s',
'help|h|?') or &usage();
&usage() if exists $opt{'help'};
my $euser = (getpwuid($<) or 'unknown');
my $label = "spf/$euser";
if ($opt{'label'} and $opt{'label'} =~ /^[a-zA-Z0-9_-]+$/) {
$label .= "/$opt{'label'}";
} else {
$label .= "/$euser";
}
$Logger_Label = $label;
if ($Verbose >= 1) {
setlogmask &Sys::Syslog::LOG_UPTO(&Sys::Syslog::LOG_INFO);
} else {
setlogmask &Sys::Syslog::LOG_UPTO(&Sys::Syslog::LOG_NOTICE);
}
my @domain;
if ($opt{'ktai'}) {
push @domain, 'docomo.ne.jp', 'ezweb.ne.jp', 'vodafone.ne.jp', 'softbank.ne.jp', 'disney.ne.jp';
} elsif ($opt{'domain'}) {
push @domain, @{ $opt{'domain'} };
}
logger2('domain='.join(',',@domain));
my %reject = ('fail' => 1, 'softfail' => 1);
$reject{'softfail'} = 0 if $opt{'allow_softfail'};
my $envelope_from = '';
my $spf = ''; # pass none fail unknown neutral softfail
my $spf_mesg = '';
if (exists $ENV{'SENDER'}) {
$envelope_from = $ENV{'SENDER'} if $ENV{'SENDER'};
} else {
logger("missing environment variable 'SENDER'.");
exit $OK_IGNORE_NEXT_DELIVER;
}
while (<>) {
s/[\r\n]+$//;
if (/^$/) {
last;
} elsif (/^Received-SPF:\s*(\S+)\s*(.+)/i) {
$spf = $1;
$spf_mesg = $2;
last;
}
}
if (@domain and $envelope_from) {
my $need_check = 0;
foreach my $d (@domain) {
if ($envelope_from =~ /${d}$/i) {
$need_check = 1;
last;
}
}
unless ($need_check) {
logger2("sender=$envelope_from spf=$spf spf_mesg=$spf_mesg result=$OK (no check)");
exit $OK; # no check.
}
}
if ($reject{lc($spf)}) {
logger("sender=$envelope_from spf=$spf spf_mesg=$spf_mesg result=$OK_IGNORE_NEXT_DELIVER (fail or softfail)");
exit $OK_IGNORE_NEXT_DELIVER;
} else {
logger2("sender=$envelope_from spf=$spf spf_mesg=$spf_mesg result=$OK (checked)");
exit $OK;
}
}
# --------------------------------------------------------------------
# S U B R O U T I N E
# --------------------------------------------------------------------
sub _logger {
my $prio = shift;
if ($Sys::Syslog::VERSION <= 0.10) {
my $oldlocale = setlocale(LC_TIME);
setlocale(LC_TIME, 'C');
my $timestamp = strftime "%b %e %T", localtime;
setlocale(LC_TIME, $oldlocale);
openlog "$timestamp $Logger_Label", 'pid', 'mail';
} else {
openlog "$Logger_Label", 'pid', 'mail';
}
syslog $prio, '%s', join(' ', @_);
closelog;
}
sub logger {
_logger('notice', @_);
}
sub logger2 {
_logger('info', @_);
}
__END__
# for Emacsen
# Local Variables:
# tab-width: 4
# cperl-indent-level: 4
# coding: euc-jp
# End:
# vi: set ts=4 sw=4 sts=0 :