Action Mailbox: 不正な形式の original recipient パラメータを 422 で拒否
Mailgun・Postmark の Action Mailbox ingress が、不正な形式の original recipient パラメータを受け取った際に TypeError で例外を発生させていた問題を修正しました。バリデーションを追加し、不正なパラメータには 422 Unprocessable Content を返すようになります。
背景
Mailgun と Postmark の Action Mailbox ingress は、オプションの original recipient パラメータをメール本文の先頭に X-Original-To ヘッダとして付与します。具体的には String#prepend を使って "X-Original-To: ", recipient 値, "\n" を raw メールに差し込む処理です。
この処理において、recipient パラメータが配列として渡された場合、String#prepend は TypeError: no implicit conversion of Array into String を発生させていました(#57491)。例えば Mailgun では recipient: ["replies@example.com"]、Postmark では OriginalRecipient: ["thisguy@domain.abcd"] のような配列型のパラメータが来るケースが該当します。
ドキュメント上、不正なリクエストに対しては 422 Unprocessable Content を返すことが期待されているにもかかわらず、実際にはコントローラの外で未捕捉の例外として露出していました。これはセキュリティ上の問題ではなく、エラーハンドリングの不整合です。
技術的な変更
両 ingress コントローラに、専用の例外クラスと型チェックメソッドを追加することで、不正なパラメータをコントローラ層で安全に処理するようにしました。
Mailgun 側では MalformedRecipientError クラスと recipient メソッドを追加しました。recipient メソッドは params.require(:recipient) の値が String でない場合に MalformedRecipientError を raise します。create アクションでこの例外を rescue し、422 を返します。
変更前:
def mail
params.require("body-mime").tap do |raw_email|
raw_email.prepend("X-Original-To: ", params.require(:recipient), "\n") if params.key?(:recipient)
end
end
変更後:
class MalformedRecipientError < StandardError
def initialize(message = "Malformed Mailgun recipient")
super
end
end
def create
ActionMailbox::InboundEmail.create_and_extract_message_id! mail
rescue MalformedRecipientError => error
logger.error error.message
head ActionDispatch::Constants::UNPROCESSABLE_CONTENT
end
def mail
params.require("body-mime").tap do |raw_email|
raw_email.prepend("X-Original-To: ", recipient, "\n") if params.key?(:recipient)
end
end
def recipient
params.require(:recipient).tap do |recipient|
raise MalformedRecipientError unless recipient.is_a?(String)
end
end
Postmark 側では同様に MalformedOriginalRecipientError クラスと original_recipient メソッドを追加しました。Postmark の create アクションはすでに ActionController::ParameterMissing を rescue してログ出力と 422 レスポンスを返す処理を持っていたため、rescue 節に MalformedOriginalRecipientError を追加するだけで既存のエラーハンドリングパスを再利用できました。
変更後(Postmark の rescue 節):
rescue ActionController::ParameterMissing, MalformedOriginalRecipientError => error
logger.error <<~MESSAGE
#{error.message}
...
MESSAGE
head ActionDispatch::Constants::UNPROCESSABLE_CONTENT
end
テストも両コントローラに追加されており、配列型パラメータを送信した際に InboundEmail が作成されず 422 が返ることを assert_no_difference と assert_response で検証しています。
設計判断
各コントローラに独立した専用例外クラスを定義する方針が採られました。共通の基底クラスではなく、MalformedRecipientError と MalformedOriginalRecipientError という別々のクラスが用意されています。これにより、エラーメッセージに ingress 名(Mailgun / Postmark)が明示的に含まれ、ログからの問題特定が容易になります。
バリデーションロジックを mail メソッド内にインラインで書くのではなく、recipient / original_recipient という独立したメソッドに切り出している点も注目に値します。String#prepend を呼ぶ箇所は変更せず、引数として渡す値の取得処理を分離することで、バリデーション追加の影響範囲を最小限に抑えています。
Postmark の既存エラーハンドリングを再利用した点も、コードの一貫性という観点で合理的な判断です。ParameterMissing と MalformedOriginalRecipientError はどちらも「リクエストパラメータの不正」という同じ意味を持ち、同じ 422 レスポンスで処理されます。
まとめ
本 PR は、既存のエラーハンドリングの「抜け穴」を塞ぐ防御的なバリデーション追加です。String#prepend に到達する前に型チェックを行い、専用例外クラスで制御フローを明確化したことで、不正なリクエストに対するレスポンスが TypeError の uncaught exception から仕様通りの 422 Unprocessable Content へと統一されました。