1 ポイント 投稿者 GN⁺ 2024-05-22 | 2件のコメント | WhatsAppで共有
  • ドキュメントテンプレートからメール・SMS・PDFを生成していたシステムで、特定の顧客に送ったメール本文だけ ピリオドが消える 問題が発生
  • テンプレート原本・プレビュー・PDFにはピリオドがあったが、顧客ごとの placeholder 値 を実データに置換したあと、特定の行長でのみ再現した
  • カスタム SMTP クライアントが行長を制限する際、ピリオドを新しい行の先頭文字へ押し出してしまい、SMTP の dot transparency 規則によってサーバーがそのピリオドを削除した
  • 修正は、ピリオドで始まり、その後ろに別の文字が続く行の前に、クライアントが ピリオドを1つ追加する 方式で行われた
  • 同じ SMTP クライアントコードを共有していた別チームがパッチを適用しておらず、一部の顧客に月額保険料が $27.00 ではなく $2700 と見えるメールが送信されたが、バグは直ちに修正された

中央テンプレートシステムで始まった問題

  • 約7年前、あるクライアントは複数の Microsoft Word テンプレートに散在していた文書を単一のシステムに統合しようとしていた
  • 既存方式は、社員が文書内の placeholder を氏名などに手作業で置き換える形で、古い約款・以前の会社ロゴ・誤ったフォントが入ったテンプレートが一緒に出回り、管理が難しくなっていた
  • 新システムでは文書テンプレートを中央で管理し、それをもとに PDF 文書、SMS、メール本文を生成する構成になった
  • 新規顧客向けのウェルカムメッセージも、配信手段ごとに異なるテンプレートを持てた
    • メール版では HTML テーブルと基本的なスタイルを使えた
    • 郵送版ではインフォグラフィックを含められた
    • SMS 版では短い歓迎メッセージだけを載せられた

特定の顧客にだけ消えたピリオド

  • システム稼働から数か月、あるいは1年以上経った後、ある管理者から、特定の顧客に送ったメール本文で ピリオドが1つ欠けている と報告があった
  • 同じメールを別の顧客に送るとピリオドは消えず、単なるテンプレートの誤りとは考えにくかった
  • テンプレートのソースコードには、そのピリオドが実際に存在していた
  • ローカル環境で本番テンプレートをコピーしてメール本文のプレビューを生成したときもピリオドは表示され、同じテンプレートで作った出力用バージョンでも正常に表示された
  • ローカル環境では特定ポートの localhost にメールを送り、SMTP4dev のようなダミー SMTP サーバーと Outlook でメールを確認する方式だった
  • 最初にローカルから送ったメールを Outlook で確認したときも、ピリオドは正常に表示されていた

顧客ごとのデータが作った再現条件

  • テンプレートはメール、PDF、SMS を作る際、顧客の氏名などの placeholder 値 を実データに置換していた
  • 同じテンプレートでも、各顧客に送られるメール本文は内容の長さが変わることがあった
  • 問題の顧客に実際に使われた placeholder 値を見つけてローカル環境で同じ値でメールを再送したところ、Outlook でピリオドが消える現象を確認できた
  • 問題はその顧客メールの 具体的な本文長と内容 に依存していた
  • 当初はテンプレート内のピリオド文字がエンコードされているのか、実際のピリオドではない別の文字なのかなどを確認したが、原因ではなかった
  • テンプレート内でピリオドの位置を1文字ずらすと Outlook で再びピリオドが見えるようになり、行内の位置が再現の手がかりになった

SMTP の行長と dot transparency

  • デバッグの結果、メールをデータベースに保存するコードは placeholder を顧客情報に置き換える以外、テンプレートを変更していなかった
  • その後、定期的に送信対象を取得してメールを送る cron ジョブ のコードへと調査範囲が絞られた
  • cron ジョブが呼び出すコードの一部は以前のプロジェクトから持ち込まれたもので、その中にカスタム SMTP クライアント実装が含まれていた
  • そのコードには、メール本文の各行が特定文字数より長くならないよう改行する関数があった
  • この動作は SMTP 仕様の行長制限に関係している
    • テキスト行の最大総長は <CRLF> を含めて 1000 octets
    • 透明性のために複製される先頭のピリオドはこの計算に含まれない
    • SMTP Service Extensions によってこの値は拡張されることがある
  • 問題のメール本文では、行長制限のためにピリオドが次の行の先頭文字へ移動した
  • SMTP 仕様では、メールデータの終了表示 はピリオド1文字だけの行として扱われる
  • SMTP の dot transparency 規則では、ピリオドで始まる本文行を別扱いする
    • SMTP クライアントはメールテキスト行を送る前に、先頭文字がピリオドか確認し、ピリオドなら行頭にもう1つピリオドを挿入しなければならない
    • SMTP サーバーは、行がピリオド1文字だけならメールデータ終了として扱う
    • 先頭文字がピリオドで同じ行に他の文字がある場合、サーバーは先頭のピリオドを削除する
  • カスタム SMTP クライアントは、行がピリオドで始まるとき ピリオドを1つ追加する処理 をしておらず、受信 SMTP サーバーが最初のピリオドを削除したことで、本文中の本来のピリオドが消えていた

修正と後から判明した影響

  • 修正は、行がピリオドで始まり、同じ行に他の文字があるとき、クライアントがピリオドを1つ追加する方式だった
  • こうして送れば、受信 SMTP サーバーが最初のピリオドを削除しても、本文には元のピリオドが残る
  • 問題となった顧客の同じ受信者情報でローカルから元のメールを再送したところ、ピリオドはもう消えなかった
  • 修正がデプロイされた後、同じ SMTP クライアントコードを以前のプロジェクトから流用していたため、別チームにもこのバグが通知された
  • 数か月後、その別チームのシステムはまだパッチ未適用の状態で、顧客へ新しい月額保険料を知らせる重要なメールを複数送信した
  • 一部のメールでは、月額保険料の 小数点のピリオド がちょうど行頭文字の位置に置かれ、消えてしまった
  • その結果、一部の顧客は新しい保険料が $27.00 ではなく $2700 であるかのように見えるメールを受け取った
  • このバグはメール本文の各行の長さに依存しており、氏名の長さがちょうど条件に合う一部の顧客にだけ発生した
  • 原因はすでに特定されていたため、そのチームのコードは直ちにパッチされた

2件のコメント

 
surfindia 2024-05-22

タイトルの period をピリオドではなく期間として解釈したみたいですね(笑)

 
GN⁺ 2024-05-22
Hacker News のコメント
  • このコードは一部で SMTP クライアントを直接実装していて、根本原因はそこにあるように見える
    プロトコルを正確に実装するのは難しく、記事に出てきたようなバグはよくある
    きちんと実装された SMTP クライアントライブラリなら、入力テキスト内のピリオドの位置に関係なく SMTP の規約に従ってエンコードしたはずで、テンプレート層が SMTP を気にする必要はない

    • 本当の問題はプロトコルそのものより、そのプロトコルを扱う カウボーイ式のやり方にある
      SMTP の規約に従ってテキストを受け取りエンコードすること自体は難しくないが、そもそもその処理が必要だと知っていなければならない
      SQL インジェクションや XSS など、数多くのエラーやセキュリティ脆弱性も同じミス、つまり 文字列の連結から生じる
      SQL クエリでは、値をクエリテンプレートにバインドする作業は型のない文字列空間ではなく「SQL 空間」で行われるべきで、SELECT * FROM foo WHERE foo.bar = + $userData のようにシリアライズ済みの SQL 文字列を直接作るのは間違ったやり方だ
      HTML テンプレートも同様に、文字列表現ではなくドキュメントツリーのレベルで扱えば、愚かな脆弱性を避けられる
      メールでピリオドが消えるのを避けるには、SMTP パイプラインの途中に構造のないテキストを注入せず、作業している抽象化レベルを尊重すべきだ
      関連して langsec も見る価値がある
    • 本当の問題は、SMTP が インバンド信号を含む「平文」プロトコルだという点にある
      「ピリオド 1 つだけの行」を文字通りの行ではなく制御シーケンスとして定義したため、こういうことが起きる
      SMTP は不必要に複雑な設計の例であり、実装バグもその複雑さを反映している
      直接実装することを勧めるわけではないが、SMTP は単独でも正しく実装するのが難しいレベルであるべきではない
    • いい考えのように聞こえるが、SMTP ライブラリを 1 つ持ってきたら、そのライブラリが依存関係を 5 個引き込み、それぞれがさらに推移的依存関係を 3 個ずつ引き込み、そのうち 1 つは実際には 1% しか使わない 万能ツール箱プロジェクトかもしれない
      残りは死荷重なのに、呼ばれもしない関数のために依存関係をさらに 20 個引き込み、いつの間にかコードベースは何 MB も膨れ上がり、使っていることすら知らなかったライブラリの CVE 警告を受け取ることになる
    • https://en.wikipedia.org/wiki/Jamie_Zawinski#Zawinski's_Law
  • 技術的な部分はすでに他の人たちがもっとうまく扱っているが、文末の ピリオド 1 つのような些細なものがどれほど重要かを思い出させる逸話がある
    私が働いているドイツでは、退職時に担当業務と社員評価を書いた推薦状である「Zeugnis」を求めるのが一般的で、求職時にも通常要求される重要な書類だ
    当然、社員が「この人は怠け者なので採用しないでください」のような文言を受け入れるはずがないので、称賛のように見せかけた「Zeugnissprache」という一種の暗号が生まれた
    その暗号の 1 つが、最後の文にピリオドがなければ「ここに書かれていることはすべて無視してください。この人はひどいです」という意味だというものだ
    前職の後、自分の Zeugnis を弁護士にチェックしてもらったところ、評価はすべて肯定的だったにもかかわらず、不注意のせいか最後の文にピリオドが抜けていた

    • 推薦状に 秘密の暗号が使われているというのは都市伝説だ
      人事担当者には潜在的な競合相手と共有する秘密コードを作る動機がないし、新しい人事担当者に教えながら秘密に保つというのも筋が通らない
      こうした伝説は、人事担当者が誰かが厄介者だったという事実をあまりに露骨に書けないために生まれる
      肯定的に書くことがなければ、時間厳守のような平凡な長所を褒めるわけで、秘密の暗号というよりは人事用語における “bless their heart” に近い
      訴訟を起こされても「裁判長、良いことを書いたんです!いつも時間どおりに来ていましたから!」と言い逃れできる余地を残しているわけだ
    • 「最後の文にピリオドがなければ、ここに書かれていることはすべて無視してください。この人はひどいです」だなんて、いったい何がまずいことになり得るというのか、と思う
    • 業績評価の文書にもピリオドが抜けていたか確認してみたのか気になる
  • メールを送る cron ジョブが、なぜ独自の SMTP クライアントを実装する必要があるのか分からない
    単に mailutils の mail のようなプログラムを使えばいい
    到達性の観点から見ても、ソケット上に骨組みだけの SMTP のやり取りを自前で作るのは最初から無理がある
    今では任意のメール交換ホストに直接接続してメールを送ることはできず、通常は ISP が提供する特定の SMTP リレーホストに接続する必要がある
    そのためには TLS 接続や認証などをすべて実装しなければならない
    少し皮肉なのは、cron 自体もすでにメールを送れるという点だ
    cron ジョブの出力は所有者にメールで送られ、一部の cron では crontab の MAILTO 変数などで受信先アドレスを変更できる

    • メールを送るときはホストの MTAを使うべきなのに、独自の SMTP クライアントを組み込んでいるものは実際に多い
      理由は、「その物」を使いたいユーザーは SMTP サーバーのアドレスくらいなら入力できるが、sendmail のような送信 MTA が正しく設定されているとプログラム側が信じる方法はないからだ
      企業にはシステムメールを送れないサーバーが大規模に存在し、その状態がプログラム作者やユーザーの管理外であることも多い
      メール設定は DNS、暗号化、ポリシーといった前提条件が必要な専門領域なので、ウォレットアプリのようなものにメールを送らせるだけの目的には大げさすぎる
      「システム管理者に問い合わせてください」は、大規模でも小規模でも現実的な選択肢ではない場合が多い
      良い理由ではないが、実際の理由ではある
    • システムにすでにメールを送ってくれるバイナリがあると信じられる世界はないが、だからといって自作はしない
      たいていの言語なら、標準ライブラリになくても使える SMTP クライアントライブラリはたくさんある
    • これも Zawinski の法則の事例ではないかと思う
  • ここには大きな悪い習慣が2つ見える
    1つ目は、多くの人が指摘しているように、標準をいい加減に実装してはいけないということ
    自分で実装しなければならないなら、必要な注意と手間をかけるか、既存のライブラリを使うべき
    2つ目は、依存関係をベンダリングしてはいけないということ
    使っているライブラリは定期的に、タイミングよく更新すべきで、「必要なときだけ」ではだめ
    更新を遅らせたり、そもそも避けたりすると、アップストリームではすでに修正済みのバグでも、直接問題が見えたときだけ更新すればいいと考えていた人たちにとって大きな問題になり得る

    • 依存関係のベンダリングをしない代替案のほうが悪く見える
      そうするとアプリケーションの安定性がアップストリームの変更にさらされ、その変更でコードが壊れる可能性がある
      修正をすぐに受け取れないことはあるかもしれないが、望まない不安定さや追加リスクを持ち込むより、自分に必要な修正のために変更するのだと分かっているほうがよいと思う
      「壊れていないなら直すな」派だ
  • 最近は、多くの人がターミナルで直接やり取りしながら基本プロトコルを学ばないように思う
    SMTPはもともとそうした手動のやり取りを念頭に置いていたようで、実際しばらくそう使っていた身としては、メッセージを終わらせる「1行にピリオド1つ」が記憶に永久に刻み込まれている
    関連して、エスケープ処理も多くのプログラマーには馴染みの薄い概念に見える
    上の状況を見て「ピリオド1つだけが入った行を含むメールを送りたい場合はどうするの?」と聞かない人も多いが、別の大きな集団はその質問を非常に論理的で分かりやすいものだと感じるだろう

    • この30年の間に、ソフトウェア開発者のうち意味のある割合がそういうふうに学んでいたとしたら、むしろ驚くと思う
  • ドットスタッフィングが必要
    SMTP https://www.rfc-editor.org/rfc/rfc5321#section-4.5.2
    POP3にもある https://www.rfc-editor.org/rfc/rfc1939#page-8

  • ネットワークプロトコル実装をデバッグした経験を思い出す。具体的には、古い人向けに言うと AppleTalk NBP だった
    全部コードを書いたのに、自分のパケットは拒否、つまり黙ってドロップされ、実際のApple実装のパケットは通った
    良いパケットと悪いパケットをコンピューター画面に表示してバイト単位で比較したが問題を見つけられず、チェックサムまで含めて最初から最後まで正確に同じだった
    退勤時間になったので、あとで見ようとその馬鹿げたものを印刷することにしたら、印刷した途端にエラーが見えた
    自分の版は2ページで、正常な実装は1ページだった
    データを送る前にバッファを正しく空にしていなかったのだ。mbufとはそういうものだ
    今でも思い出すと笑える

  • “We are happy to welcome you to our family.”という行は、行長制限の近くにも行っていない
    何か別のことが起きているようで、たとえば全体が実際にはHTML MIME添付だったのかもしれない
    このように ... lots of text ... の後に We are happy to welcome you to our family. があった可能性がある
    ただしHTMLをむやみに行で切るとタグが壊れる

    • これは単なる例で、正確な文字数を持つ実例を提示できなくて申し訳ない
    • 壊れたHTMLメールを受け取った人のほとんどは、メールの形式がおかしいことに気づき、読みやすいメールすらまともに書けない会社だと見て会社の能力評価を下げ、そのまま次のメールに移るだろう
      会社はその些細な問題をまったく知らないまま過ぎてしまう可能性が高い
    • quoted-printableエンコーディングのソフト改行を使えば、HTMLタグを壊さずに行を機械的に分割できる
      quoted-printableはCRLFを含めて最大78文字制限であるべきだが、メールクライアントは概して寛容だ
  • ドットスタッフィングを初めて聞いた人なら、メールの世界の中にどんな他の恐怖があるのか信じがたいだろう
    ヘッダー折り返し、ローカルパートの引用符処理、IPv6リテラルなどがある

    • IPv6リテラルに何の問題があるのか気になる
  • 以前のプロジェクトから借りてきたSMTPクライアントコードだったというくだりを読んだ瞬間、別チームがまだこのバグをパッチしていなかったという結末になると思った