執筆 9 分で読めます

winmail.dat をブラウザだけで開くために TNEF を実装した話

winmail.dat の変換サービスは、見つかるものがどれも「まずファイルをアップロードしてください」だった。それなら自分で書こうと TNEF を TypeScript でフルスクラッチ実装した。フォーマットの中身と、素直に実装すると必ず壊れる3箇所の話。

以前、届いたメールに winmail.dat という添付が付いていた。使える拡張子でもなく、 開く方法もなく、中身が何かも分からない。当然のように変換ツールを検索して、そこで本当の問題にぶつかった。

見つかるサービスがどれも同じだったのだ。ファイルをサーバーにアップロードしてください、中身をお返しします。 よく考えると奇妙な要求で、winmail.dat は定義上「誰かが自分に送ってきたメール」そのものだ。 契約書かもしれないし、請求書かもしれないし、社内資料かもしれない。 中身を知りたいというだけの理由で、それを見ず知らずのサイトに渡すのは順序が逆だと思う。 実際、日本語の解説記事はどれも 機密情報を含むファイルはアップロードしないよう注意 と書き添えている。 割に合わないことは皆わかっていて、ただ代わりが無かっただけだ。

TNEF は自力で実装できる程度には仕様が公開されている。そして JavaScript で動かしてしまえば、 ファイルがどこかへ送られる理由は1つも残らない。というわけで書いた。以下はその記録。

winmail.dat の正体

Outlook が「リッチテキスト形式」でメールを送るとき、普通の MIME メールに普通の添付を付けて送るのではなく、 件名・書式付き本文・添付ファイル全部・Outlook 固有のメタデータをまとめて1つのバイナリに詰め込む。 その符号化方式が TNEF(Transport Neutral Encapsulation Format)で、 出来上がった塊が winmail.dat という名前で添付される。

Outlook が件名・書式付き本文・添付ファイル・MAPI プロパティを 1 つの TNEF 形式 winmail.dat にまとめて送信し、Outlook では自動展開されるが他のメールソフトでは開けない添付として表示されることを示した図。
Diagram 受信側が Outlook なら自動で展開されるので、送信者は問題が起きていることに気づかない。それ以外の全員が塊を受け取る。

名前は今となっては皮肉でしかない。1社のクライアントしか読めない独自形式のどこが transport neutral なのか。 そして重要なのは、これが送信側の設定で生成されるという点だ。受信側で何をしても防げない。

コンテナ構造は思ったより素直

TNEF ファイルは 32bit のシグネチャ 0x223e9f78 で始まり、 続いて特に意味のない 16bit のキーが入る。その後はファイル終端まで属性レコードが平坦に並ぶだけだ。 ツリーも索引も、追いかけるオフセットも無い。

signature   uint32   0x223e9f78
key         uint16

EOF まで繰り返し:
  level     uint8    1 = メッセージ, 2 = 添付
  attribute uint32   上位16bit = データ型, 下位16bit = 属性ID
  length    uint32
  payload   byte[length]
  checksum  uint16   payload のバイト和を16bitに切り詰めたもの

読むだけならループで済む。level がそのレコードはメッセージの話か、 いま組み立て中の添付の話かを示し、attribute の下位半分が種類を示す。 メッセージ側で必要なのは件名(0x8004)、差出人(0x8000)、 プレーン本文(0x800c)、そして MAPI プロパティブロック(0x9003)。 添付側は 0x9002 が添付1件の開始、0x8010 が旧来のファイル名、 0x800f が実データ、0x9005 がもう1つの MAPI ブロックだ。

ここまでは半日で動いた。面白くなるのはこの先だった。

素直に書くと必ず壊れる3箇所

1. MAPI ブロックの中身は4バイト境界に整列している

MAPI プロパティブロックは個数で始まり、以降「プロパティ型・プロパティID・値」を繰り返す。 文字列とバイナリの値には長さが前置されるが、その長さは意味のあるバイト数であって、 実際に書かれているバイト数ではない。値は次の4バイト境界までパディングされている。

const len = d.readUint32LE();
const bytes = d.readBytes(len);
d.skip(pad4(len) - len);   // この行を忘れると以降が全部ゴミになる

パディングを飛ばし忘れても、ファイル名が少し変になる、では済まない。 ストリームが1〜3バイトずれた状態になり、以降読み取るプロパティ型は全部でたらめになって、 パーサは例外を投げるか、堂々とゴミを返すかのどちらかになる。 失敗が静かで全面的なので、「自分の読み取りが2バイトずれている」ではなく 「このファイルが壊れている」と結論しがちなのが厄介なところだ。

2. 名前付きプロパティには可変長ヘッダが付く

ここで一番時間を溶かした。MAPI のプロパティ ID は 0x8000 未満なら既知の定数だが、 0x8000 以上は名前付きプロパティで、レコード構造の説明のどこにも書かれていない 追加ヘッダが前置される。16バイトの GUID、4バイトの kind、そして 4バイトの ID か、長さ前置+4バイト整列された文字列のどちらかだ。

if (propId >= 0x8000) {
  d.skip(16);                       // プロパティセットの GUID
  const kind = d.readUint32LE();
  if (kind === 0) {
    d.skip(4);                      // 数値 ID で命名
  } else {
    const nameLen = d.readUint32LE();
    d.skip(pad4(nameLen));          // 文字列で命名
  }
}

ここを固定バイト数で読み飛ばす実装にすると、手元のテストファイルでは動いて、 誰かの実メールで壊れる。ヘッダ長が名前に依存するからだ。 失敗の仕方は整列バグと同じで、全面的にずれて、エラーは出ない。

3. ファイル名は4箇所にあり、優先順位がある

実務で効いてくるのはこれで、多くの winmail.dat ツールが日本語ファイル名を文字化けさせる原因でもある。

同じ添付が最大4箇所に名前を持っていて、しかも一致しない。 attAttachTitle は旧来のフィールドで ANSI 文字列、 日本語環境の Outlook なら Shift_JIS、しばしば 8.3 形式に切り詰められている。 MAPI ブロック側にはもっとまともな版がある — PidTagAttachLongFilename0x3707)、 PidTagAttachFilename0x3704)、 PidTagDisplayName0x3001)で、通常は UTF-16LE だ。

const name =
  a.mapiLongFilename ||   // 0x3707、UTF-16LE、フルネーム
  a.mapiFilename ||       // 0x3704
  a.mapiDisplayName ||    // 0x3001
  a.legacyName ||         // attAttachTitle、ANSI、多くは 8.3
  'attachment';

旧来フィールドだけを読む実装 — 添付データのすぐ隣にあるので一番見つけやすい — は、まさにユーザーが不満を言うあの症状を生む。送信者が付けた名前ではなく 8E9095948E.PDF のようなファイルが出てくるやつだ。

文字コードは推測するものではなく、宣言されている

TNEF の ANSI 文字列は特定の固定エンコーディングではない。 属性 0x9007 が OEM コードページ番号を持っていて、 それがストリーム中でそれ以降に現れる ANSI 文字列すべてを支配する。 932 が Shift_JIS、936 が GBK、949 が EUC-KR、950 が Big5、 125x 系が Windows の Latin ファミリ、65001 が UTF-8。

以降を支配するので、パースは順序依存になる。件名をデコードする前に コードページを読んで適用しておく必要がある。 属性を都合のいい順に処理する実装を書いてしまうと、 日本語の件名を Windows-1252 として静かにデコードすることになる。

HTML 本文は敵性入力

TNEF には HTML 本文が入りうる。そしてその本文は、知らない相手から届いたメールに入っていたものだ。 それを描画するのがツールの目的であり、同時に一番わかりやすくやられる経路でもある。

対策は2層にした。まず DOMPurify で、拒否リストではなく許可リストで タグと属性を明示してサニタイズする。そのうえで結果をサンドボックス化した <iframe> の中に書き込む。 サニタイズだけだと DOMPurify に回避策が見つからないことに賭けることになるが、 iframe があれば仮に何かすり抜けても周囲のページには手が届かない。

ブラウザ内でやることの見返り

アップロード型の変換サービスではファイルがインターネットを経由して事業者のサーバーで復号・保存されるのに対し、ブラウザ内処理ではファイルが端末から一度も出ないことを比較した図。
Diagram この主張は検証できる。開発者ツールの Network タブを開いて変換してみればいい。

デコーダが JavaScript になってしまえば、ファイルが動く理由は無くなる。 アップロードも、漏洩しうるサーバーも、読むべき保持ポリシーも無い。 そして私の言葉を信じる必要も無い。ブラウザの Network タブを開いて何か変換してみてほしい。 ページが読み込まれて、そのあと何も起きないのが見えるはずだ。

正直に書いておくと、代償もある。メモリの少ない端末で巨大なファイルを扱う場合の上限は サーバーではなくその端末で決まるし、TNEF コンテナ内の暗号化・署名済み S/MIME はデコードしない。 どちらも、ファイルを他人に渡さずに済むことの対価としては妥当だと思っている。

試せます

成果物がこのサイトの winmail.dat ビューア。 ファイルをドロップすると件名・差出人・本文が表示され、添付を全部取り出せる。 アップロードなし、アカウント登録なし、こちらが課すサイズ制限もなし。

うまく開けない winmail.dat があったら、ぜひ教えてほしい。 このフォーマットには、一人の受信箱では踏めない角がまだたくさんあるはずなので。 連絡先はこちら

この記事を書いた人

Ren

OpenedFile 開発者

OpenedFile を一人で作って運用しています。きっかけは、届いたメールの winmail.dat がどうやっても開けなかったこと。見つかるオンライン変換サービスはどれも「まずファイルをサーバーにアップロードしてください」というものばかりでした。それなら自分で書こうとTNEF パーサをフルスクラッチで実装し、以来このサイトのツールはすべてブラウザ内で完結しています。

OpenedFile について

この記事をシェア