SendGrid ingress で不正な envelope を 422 で安全に処理
Action Mailbox の SendGrid ingress が、構文上は正しいが期待するスキーマを満たさない envelope パラメータを受け取った際に NoMethodError でクラッシュしていた問題を修正します。今回の変更により、不正な envelope は既存の JSON パースエラー時と同じ 422 Unprocessable Content で一貫して拒否されます。
背景
#57334 が報告した通り、SendGrid ingress はすでに envelope パラメータが不正な JSON であれば 422 を返す仕組みを持っていました。しかし、JSON として正しくパースできるが to キーを含まないケース(例: {"from":"jason@37signals.com"})では、コントローラが envelope["to"].each を呼び出した際に NoMethodError: undefined method 'each' for nil が発生し、未処理例外としてクラッシュしていました。
この問題は、認証済みリクエストにのみ影響します。ingress の Basic 認証を通過した後の Webhook ペイロードの検証の問題であり、認証バイパスには該当しません。既存の rescue JSON::ParserError ハンドラが不正な JSON を捕捉できていたのに対し、JSON として有効だが構造が不正なケースが考慮されていなかったという設計上のギャップが今回の修正で埋められています。
技術的な変更
MalformedEnvelopeError という専用の例外クラスを導入し、envelope のバリデーションを独立したメソッドに切り出すことで、エラー処理の一貫性を確保しました。
変更前のコードでは、envelope メソッドが JSON のパースのみを担当し、取得した値の構造検証を行っていませんでした。
変更前:
def mail
params.require(:email).tap do |raw_email|
envelope["to"].each { |to| raw_email.prepend("X-Original-To: ", to, "\n") } if params.key?(:envelope)
end
end
def envelope
JSON.parse(params.require(:envelope))
end
変更後:
class MalformedEnvelopeError < StandardError
def initialize(message = "Malformed SendGrid envelope")
super
end
end
def mail
params.require(:email).tap do |raw_email|
envelope_recipients.each { |to| raw_email.prepend("X-Original-To: ", to, "\n") } if params.key?(:envelope)
end
end
def envelope_recipients
envelope = JSON.parse(params.require(:envelope))
raise MalformedEnvelopeError unless envelope.is_a?(Hash)
raise MalformedEnvelopeError unless envelope.key?("to")
envelope["to"].tap do |recipients|
raise MalformedEnvelopeError unless recipients.is_a?(Array) && recipients.all?(String)
end
end
envelope_recipients メソッドは以下の順序でバリデーションを行います。まず envelope が Hash であることを確認し、次に "to" キーの存在を確認します。最後に "to" の値が Array であり、全要素が String であることを all?(String) で検証します。
create アクションの rescue 節には MalformedEnvelopeError が追加されており、バリデーション失敗時も JSON::ParserError と同じ 422 Unprocessable Content パスを通ります。テストでは、以下の4ケースが assert_rejects_malformed_envelope ヘルパーで網羅されています:
-
toキーが存在しない envelope(例:{"from":"jason@37signals.com"}) - Hash ではなく Array の envelope(例:
["replies@example.com"]) -
toが Array でなく String の envelope(例:{"to":"replies@example.com"}) -
toの要素が String でない envelope(例:{"to":[1]})
設計判断
既存の rescue ブロックを拡張するアプローチ が採用されました。新しいカスタム例外 MalformedEnvelopeError を JSON::ParserError と並べて捕捉することで、「envelope が正しく処理できないケースはすべて 422 を返す」という一貫したポリシーを表現しています。
バリデーションロジックを envelope_recipients として独立したメソッドに集約したことで、コントローラの mail メソッドは受信者リストを取得するという責務に集中できます。例外を使ったバリデーションは早期リターンのチェーンに比べて各条件が明示的であり、将来的にバリデーション項目が増えた場合も同パターンで追加できます。
まとめ
本PRは、HTTP 境界での防御的なバリデーションを強化した修正です。カスタム例外クラスの導入と既存 rescue ブロックへの統合という最小限の変更で、envelope の不正な構造に起因するクラッシュを排除し、既存の 422 レスポンスポリシーを一貫して適用できるようにしています。