まだ重たいCMSをお使いですか?
毎秒1000リクエスト を捌く超高速CMS「adiary

2008/02/08(金)2行に渡るメールヘッダの正しい処理

問題発生

ある方より、メール投稿利用時に長い日本語タイトルを付けると、途中に半角スペースが入ってしまうというバグ報告を受けました。

Subject: =?ISO-2022-JP?B?GyRCPmFHKz5hRys+YUcrPmFHKz5hRys+YUcrPmFHKz5hGyhC?=
 =?ISO-2022-JP?B?GyRCRys+YUcrPmFHKz5hRys+YUcrPmFHKz5hRys+YUcrPmFHKz5hGyhC?=

のようにMIMEエンコードが長くなり空白が入ったときに、この空白がデコードされてもそのまま残ってしまうことが原因です。やっつけならこの空白を除去するだけで良いのですが、やっつけプログラムは最低なのできちんと調査してみました。

長いメールヘッダの規定

RFC 2822「Internet Message Format」には次のように規定されています。

2.1.1. 行の長さの制限

それぞれの行の文字はCRLFを除いて、決して998文字以下でなければならず(MUST)、78文字以下であるべきである(SHOULD)。

2.2.3. 長いヘッダフィールド

それぞれのヘッダフィールドは(略)単一論理行である。しかしながら便宜のため、文字数制限のために、ヘッダフィールドの一部であるフィールドボディは複数行に分割して表現でき、これをfoldingと呼ぶ。どこであれfoldingのホワイトスペース(WSPではない)があるとき、WSP(SPACE/0x20またはTAB/0x09)の前にCRLFを挿入してよい。例えば、ヘッダフィールド:

Subject: This is a test

は以下のように表現できる。

Subject: This
 is a test

(中略)

このfoldingされたヘッダフィールドの複数行表現を1行表現にする過程をunfolding と呼ぶ。unfolding はWSPがすぐ後に続くあらゆるCRLFを単に削除することでなされる。

つまりRFCの規定に従い、最初に例示したメールヘッダは次のように復元されます。

Subject: =?ISO-2022-JP?B?(略)?= =?ISO-2022-JP?B?(略)?=

ここまでは正しい復元過程であり、やっつけ実装として考えた「単純に複数行ヘッダの2行目以降の先頭空白の除去」は誤りであることがわかります。実際、長い英字タイトルに1文字だけ空白を入れた「aa(略)aaa bbb(略)」というメールを送信したところ

Subject: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaa  ←※実際にはここまで1行
 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb

というように78文字の制限を超えてメールヘッダが作られました。単純な先頭空白除去では、このような改行位置が半角の文になっているメールを受信した際に問題が発生します*1。そもそもRFCの規定で「CRLFを単に削除する」となっているのですから、そこは正しく従わねばなりません。

*1 : ちなはにnPOPはこの実装でした。

MIMEの規定

では「長い日本語タイトルの途中に空白が入る問題」はどこに実装の誤りがあるのでしょうか。これについて、RFC 2047「非ASCIIテクストのためのメッセージヘッダ拡張」を参照します。

6.2. 'encoded-word' のディスプレイ

複数の 'encoded-word' を含むある特定のヘッダフィールドを表示する時は、隣接する 'encoded-word' を分離するあらゆる 'linear-white-space' は無視される。

'encoded-word'は、いわゆるMIMEエンコードのことです。linear-white-space はRFC 822により次にように定義されています。

HTAB        =  <ASCII HT, horizontal-tab>   ; (文字コード:9)
LWSP-char   =  SPACE / HTAB                 ; semantics = SPACE
linear-white-space =  1*([CRLF] LWSP-char)  ; semantics = SPACE

[CRLF] は CRLF があってもなくても良い。(0回以上1回未満の繰り返し)
1*(element) はelementの少なくとも1つ以上の繰り返し。

まとめると「=?ISO-2022-JP?B?(略)?=」ブロックと「=?ISO-2022-JP?B?(略)?=」ブロックの間にある、"CR LF SPACE(又はTAB)"の1つ以上の繰り返しは削除すべしということになります。

正しいメールヘッダ復元の実装

Perl正規表現で次にように行います。

$hl = (1つのヘッダフィールド(1行または複数行))
while ($hl =~ /(.*?=\?ISO-2022-JP\?B\?[A-Za-z0-9\+\/=]*\?=)(?:(?:\r\n)?[\t ])+(=\?ISO-2022-JP\?B\?[A-Za-z0-9\+\/=]*\?=.*)/i) {
	$hl="$1$2";
}
$hl =~ s/=\?ISO-2022-JP\?B\?([A-Za-z0-9\+\/=]*)\?=/ &ベース64デコード($1) /ieg;

ベース64デコード関数は別に用意してください。(たとえばこれ

(?:(?:\r\n)?\r\n[\t ])+

上記が、linear-white-spaceに相当します。ただ、メールデータを読み込む状況によっては \n \r \r\n のいずれになってプログラムに渡されるかわかりませんので、若干厳密な実装ではなくなりますが、

$hl =~ s/\r\n|\r/\n/g;

しておいてから、

(?:\n?[\t ])+

の方が良いと思います。さらに言えば "RFC 2822" の規定を先に適用して、先に改行コードを除去してから、MIMEエンコード間の空白除去を行っても動作としては変わりません。それをベースにした adiary の実装を最後に示します。

$hl =~ s/\x00//g;
my @buf;
# MIMEデコードをしてエスケープ表記"\0 num \0"に置き換え
$hl =~ s/=\?[\w\-]+\?B\?([A-Za-z0-9\+\/=]*)\?=/
	push(@buf, $self->base64decode($1));
	"\x00$#buf\x00";
/ieg;
# RFC 2047
$hl =~ s/\x00[\t ]+\x00/\x00\x00/g;
# エスケープ復元
$hl =~ s/\x00(\d+)\x00/$buf[$1]/g;