ASP.NET Core でマルチパートのリクエストを読む

経緯

お仕事で作っている Web APImultipart/mixed なリクエストを読むっていう仕様の機能を作ったんですけど、何故かリクエストを読もうとすると謎のエラーになる。なぜ? というわけで調べたメモです。

TL; DR

  • ASP.NET Core でマルチパートなリクエストを読むときのバグ回避方法について
  • boundary パラメーターの値を囲むダブル クォーテーションを削れ

マルチパートの基礎知識

普通の HTTP メッセージは、ヘッダーとボディから成りますよね。こんな風に。
これは UTF-8 テキストの hello という文字列を http://examplePOST するだけのリクエストです。ヘッダとボディは空行で区切られます。

POST http://example HTTP/1.1
Content-Type: text/plain; charset=utf-8

hello

マルチパート メッセージは、ボディの中に、入れ子になったヘッダーとボディがいくつか入った構造をしています。それぞれのパートはバウンダリという文字列で区切られ、バウンダリは親の Content-Type ヘッダーの boundary パラメーターで示されます。
以下の例は、UTF-8 テキストの helloworld という文字列からなるマルチパート メッセージを http://examplePOST するリクエストの例です。各パートのヘッダとボディも、通常のメッセージと同じように空行で区切られます。

POST http://example HTTP/1.1
Content-Type: multipart/mixed; boundary=cf79c84f-c19c-4964-8f24-c22542458e8b

--cf79c84f-c19c-4964-8f24-c22542458e8b
Content-Type: text/plain; charset=utf-8

hello
--cf79c84f-c19c-4964-8f24-c22542458e8b
Content-Type: text/plain; charset=utf-8

world
--cf79c84f-c19c-4964-8f24-c22542458e8b--

ここでは一階層しか入れ子にしていませんが、子パートの Content-Type ヘッダーにも multipart/* を指定することで、複数階層の入れ子にすることもできます。

マルチパート メッセージを Web API で使う例はあまり見ないかもしれませんが、HTML メール等では日常的に使われる形式です*1

仕様は RFC 2045 / RFC 2046 等で規定されています。

マルチパート リクエストの読み方

ASP.NET Core のサーバー サイドでマルチパートなリクエストを読むには、MultipartReader クラスを使います。
MultipartReader クラスのコンストラクタにはバウンダリを渡す必要があります。バウンダリContent-Type ヘッダの一部なので MediaTypeHeaderValue クラスでパースすることが出来ます。
なお、このクラスは、.NET BCL と ASP.NET Core のそれぞれに名前空間違いで同名のものが存在する(メンバーも異なる)ので紛らわしいです。

どちらを使ってもできますが、コードは微妙に異なるものになります。
以下のサンプルでは .NET BCL の方を使います*2

var request = /* HttpContext */ context.Request;
var mediaType = MediaTypeHeaderValue.Parse(request.ContentType);

var boundary = mediaType.Parameters.SingleOrDefault(x =>
    string.Equals(x.Name, "boundary", StringComparison.OrdinalIgnoreCase));

var reader = new MultipartReader(boundary, request.Body);

var section = await reader.ReadNextSectionAsync().ConfigureAwait(false);

sectionMultipartSection オブジェクトです。
あとは sectionBody プロパティを参照して自前で読むなり、文字列にしたいなら ReadAsStringAsync メソッドを使うなり。
リクエスト中のすべてのパートを列挙したいなら、戻り値が null になるまで ReadNextSectionAsync メソッドを繰り返し呼べばよいです。

マルチパート リクエストの書き方

クライアント サイドで HttpClient クラスを使ってマルチパート リクエストを送出するには、MultipartContent クラスを使えばよいです。

以下のようなコードで、text/plain なパート 1 つだけを持つ multipart/mixedPOST リクエストを送出することができます。
MultipartContent クラスのコンストラクタでバウンダリを指定することもできますが、特に指定しなければ GUID 形式のバウンダリが自動生成されます。

using var part = new StringContent("hello");
part.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain; charset=utf-8");

using var requestContent = new MultipartContent("mixed");
requestContent.Add(part);

using var request = new HttpRequestMessage(HttpMethod.Post, "http://example");
request.Content = requestContent;

using var response = await /* HttpClient */ client
    .SendAsync(request).ConfigureAwait(false);

組み合わせよう

上記のクライアント サイドとサーバー サイドのコードを結合すると、動きません。具体的に言うと ReadNextSectionAsync メソッドが不可解な例外を吐いて死にます。

ところが、VSCodeREST Client なんかを使ってリクエストすると動いちゃうんですよね。
冒頭の「基礎知識」のところで書いたリクエストを、VSCode を使って送出すると、これは普通に読めちゃいます。

原因

動くリクエストと動かないリクエストをよくよく見比べると、一つだけ差異がありました。エラーが出る方は、Content-Type ヘッダーの boundary パラメーターの値がダブル クォーテーションで囲まれていたのです。

つまり、実際に送信されていたリクエストはこんな感じだったわけです。

POST http://example HTTP/1.1
Content-Type: multipart/mixed; boundary="cf79c84f-c19c-4964-8f24-c22542458e8b"

--cf79c84f-c19c-4964-8f24-c22542458e8b
Content-Type: text/plain; charset=utf-8

hello
--cf79c84f-c19c-4964-8f24-c22542458e8b--

MultipartContent クラスは、(暗黙的に生成される場合も含め)boundary パラメーターの値がダブル クォーテーションで囲まれていない場合、自動的に値の前後にダブル クォーテーションを付加します。

RFC 2045 §5.1 では、パラメーターの値はダブル クォーテーションがある場合とない場合とで等価であると書かれています。

Note that the value of a quoted string parameter does not include the quotes. That is, the quotation marks in a quoted-string are not a part of the value of the parameter, but are merely used to delimit that parameter value. In addition, comments are allowed in accordance with RFC 822 rules for structured header fields. Thus the following two forms

Content-type: text/plain; charset=us-ascii (Plain text)

Content-type: text/plain; charset="us-ascii"

are completely equivalent.

また、RFC 2046 §5.1.1 では、boundary パラメーターの値はダブル クォーテーションで囲んでおいて損はないと書かれています。

The grammar for parameters on the Content-type field is such that it is often necessary to enclose the boundary parameter values in quotes on the Content-type line. This is not always necessary, but never hurts.

バウンダリとして採用する文字によっては、ダブル クォーテーションで囲まなければならない場合もあります*3が、少なくとも GUID 形式の場合は、その必要はありません。MultipartContent クラスは、必要か否かにかかわらず、常に boundary パラメーターの値をダブル クォーテーションで囲みますが、そのこと自体は悪いことではありません。

問題は、MultipartReader クラスは、boundary パラメーターの値がダブル クォーテーションで囲まれているとパートの検出に失敗するということです。

パート間のバウンダリはダブル クォーテーションで囲まれていないのに、Content-Type ヘッダーの boundary パラメーターの値はダブル クォーテーションで囲まれているので、比較したときに不一致になってしまって、パートの区切り位置を検出できていないんだと思います。

回避策

原因がわかれば回避は簡単です。MultipartReader クラスのコンストラクタ引数には、boundary パラメーターの値からダブル クォーテーションを除去してから与えればよいのです。

修正したサーバー サイドのコードは以下のようになります。

var request = /* HttpContext */ context.Request;
var mediaType = MediaTypeHeaderValue.Parse(request.ContentType);

var boundary = mediaType.Parameters.SingleOrDefault(x =>
    string.Equals(x.Name, "boundary", StringComparison.OrdinalIgnoreCase));

// boundary を囲むダブル クォーテーションを除去する
var reader = new MultipartReader(boundary.Trim('"'), request.Body);

var section = await reader.ReadNextSectionAsync().ConfigureAwait(false);

Issue 書いた

github.com

えいごつらたん。

追記

仕様だから自分でカットしてねって言われますた。じゃあ MultipartReader のページにそういう風に書いといてよ!

付録

検証用に書いたコードを載せておきます。これだけでサーバー サイドとクライアント サイドの両方を兼ねています。
いちいち Web アプリを実行するのが面倒だったので、テスト用のサーバーをインプロセスで立てました。

using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.WebUtilities;

using Xunit;

namespace TestProject1
{
    public class UnitTest1
    {
        [Fact]
        public async Task Test1()
        {
            var builder = new WebHostBuilder()
                .Configure(appBuilder => {
                    appBuilder.Run(async context => {

                        // この中がサーバー サイド
                        var body = context.Request.Body;

                        var mediaType = MediaTypeHeaderValue.Parse(context.Request.ContentType);

                        var boundary = mediaType.Parameters
                            .SingleOrDefault(x =>
                                string.Equals(x.Name, "boundary", StringComparison.OrdinalIgnoreCase));

                        // boundary を囲むダブル クォーテーションを除去する
                        var reader = new MultipartReader(boundary.Value.Trim('"'), body);

                        var section = await reader.ReadNextSectionAsync().ConfigureAwait(false);

                        var content = await section.ReadAsStringAsync().ConfigureAwait(false);

                        var response = context.Response;

                        response.StatusCode = 200;
                        response.ContentType = "text/plain; charset=utf-8";

                        await response
                            .WriteAsync(content + content)
                            .ConfigureAwait(false);
                    });
                });

            using var server = new TestServer(builder);

            // ここからクライアント サイド
            using var client = server.CreateClient();

            using var part = new StringContent("hello");
            part.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain; charset=utf-8");

            using var requestContent = new MultipartContent("mixed");
            requestContent.Add(part);

            using var request = new HttpRequestMessage(HttpMethod.Post, "http://example");
            request.Content = requestContent;

            using var response = await client.SendAsync(request).ConfigureAwait(false);

            Assert.Equal(200, (int)response.StatusCode);

            var responseContent = await response.Content
                .ReadAsStringAsync()
                .ConfigureAwait(false);

            Assert.Equal("hellohello", responseContent);
        }
    }
}

*1:HTML メールを送る場合、HTML メール未対応のメーラー(いまどきあるのか?)でも表示できるよう、プレーン テキスト形式の本文も付けるのが慣例です。こういう場合は multipart/alternative を使います。また、HTML メールにインライン画像を含める場合は、multipart/related を使います。

*2:サーバー サイドでは ASP.NET Core の方を使うのが楽なのですが、記事末尾に掲載しているサンプル コードでは使いにくいため。

*3:詳しくは RFC 2045 §5.1 を参照