ASP.NET Coreの入力から制御文字を削除する

WebAPI を開発・保守していると特殊な文字(制御文字)が入力に紛れ込んでいて、その場面では問題にはならないけれども後続処理でエラーが発生する。。。なんてことがよくあります。入力から特殊文字を削除する簡単な方法としては API それぞれに対する特殊文字を削除する専用の処理を実装する、リフレクションで汎用的な処理を実装して個々の API で呼び出す、など考えられますが、中途半端で漏れも発生します。

本記事では ASP.NET Core のミドルウェアを自作し、入力された JSON から一括で制御文字を削除する手法について紹介します。


試した環境

要素 バージョン
Visual Studio 2022 (17.4.3)
.NET SDK 7.0.101

本記事に記載のコードは GitHub 上に置いてあります。

github.com

ASP.NET Core のミドルウェアとは

ASP.NET Core においてリクエストを処理する流れはパイプラインと呼ばれており、ミドルウェアとはパイプラインを構成する要素です。ミドルウェアは次のミドルウェアの処理の前後で各々の処理を実行します。

リクエストのパイプラインとミドルウェア
ASP.NET Core のミドルウェア | Microsoft Learn より

ASP.NET Core では認証、キャッシュ、レスポンス圧縮、静的ファイル配信、 HTTPS リダイレクト、例外ページ表示等の標準機能はミドルウェアとして実装されています。
Visual Studio で新規 Web プロジェクトを作成すると Program.csUseHttpsRedirectionUseAuthorization 等の呼び出しが見られます。これらが要求パイプラインに対して使用するミドルウェアを登録する処理で、リクエストに対してこの呼び出しの順番でミドルウェアが呼び出されます。

ASP.NET Core MVC と Razor Pages アプリの要求処理パイプライン
ASP.NET Core のミドルウェア | Microsoft Learn より

ASP.NET Core のミドルウェアを実装する

ざっくりと ASP.NET Core のミドルウェアについて説明したところで実際にカスタムミドルウェアを実装してみましょう。本記事で作成するミドルウェアの機能はざっくりと紹介すると、 HTTP リクエストのボディ部分の JSON のテキスト値から特殊文字を削除する、となります。手順は以下の四段階です。

  1. ミドルウェアのクラス作成
  2. ミドルウェアのパイプライン登録
  3. JSON のテキスト値から特殊文字を削除
  4. リクエストボディの中身を特殊文字が削除された JSON に差し替え

ミドルウェアのクラスを作成する

まず最初にミドルウェアのクラスを作成します。自作ミドルウェアに関するドキュメントはこちらにあります。

learn.microsoft.com

ドキュメントによるとミドルウェアとなる条件は次の2点だそうです。

  • 第一引数が RequestDelegate のパブリックコンストラクターがあること
  • 名前が Invoke または InvokeAsync で第一引数が HttpContext 、返値の型が Task のパブリックメソッドがあること

この条件さえ満たしていればクラスの名前やコンストラクター・メソッドの第二引数以降は自由で、 DI サービスに登録されている任意のコンポーネントを受け取ることができます。ただしミドルウェアのライフライクルはアプリケーションスコープ(シングルトン扱い)のため、コンストラクターで受け取れる物はアプリケーションスコープのもの、 Invoke メソッドで受け取れるものはリクエストスコープとなります。

本記事では特殊文字を削除するミドルウェアを実装するため、次のクラスを作成しました。

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;

namespace NX.AspNetCore.Middlewares;

/// <summary>
/// リクエストボディ(JSON)に含まれる特殊文字を削除するミドルウェア
/// </summary>
public class RemoveControlCharactersMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RemoveControlCharactersMiddleware> _logger;

    public RemoveControlCharactersMiddleware(
        RequestDelegate next,
        ILogger<RemoveControlCharactersMiddleware> logger)
    {
        _next = next ?? throw new ArgumentNullException(nameof(next));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));

        _logger.LogInformation("Initialized");
    }

    /// <summary>
    /// ミドルウェアの処理を実装します。
    /// </summary>
    public async Task Invoke(HttpContext context)
    {
        // このミドルウェアの処理を記述します(前処理)。
        _logger.LogInformation("Before");

        // 次のミドルウェアの処理を実行します。
        await _next(context);

        // このミドルウェアの処理を記述します(後処理)。
        _logger.LogInformation("After");
    }
}

ミドルウェアをパイプラインに登録する

続いて作成したミドルウェアのクラスをパイプラインに登録します。直接ミドルウェアのクラスを登録せず、拡張メソッドを利用すると良いでしょう。

using Microsoft.AspNetCore.Builder;

namespace NX.AspNetCore.Middlewares;

public static class RemoveControlCharactersMiddlewareExtensions
{
    /// <summary>
    /// 特殊文字をリクエストボディから削除するミドルウェアを使用します。
    /// </summary>
    public static IApplicationBuilder UseRemoveControlCharacters(this IApplicationBuilder app)
    {
        app.UseMiddleware<RemoveControlCharactersMiddleware>();
        return app;
    }
}

作成した拡張メソッドを Program.cs で使用します。この IApplicationBuilder へのミドルウェア登録順が要求パイプラインにおけるミドルウェアの呼び出し順となるため、追加する場所(順番)には注意が必要です。

// 略

app.UseHttpsRedirection();
app.UseAuthorization();

// 追加
app.UseRemoveControlCharacters();

app.MapControllers();

app.Run();

この状態でアプリケーションを実行すると、コンストラクターに仕込んだログはアプリケーション起動時に1度だけ表示されるのに対して Invoke メソッドのログはリクエストの度に表示されるかと思います。

JSON から特殊文字を削除する

ミドルウェアのクラス作成とパイプライン登録が完了したところで、続いて制御文字を削除する処理の中身を実装します。

リクエストボディには JSON が文字列(の UTF-8 バイト配列)として含まれています。 JSON のテキスト値に含まれる制御文字はテキスト値として表現するためにエスケープ処理がされていますので、リクエストボディから単純に制御文字を削除するのは不適切です。また値ではなくキー等に含まれる制御文字は削除したくありません。これらを実現するためには JSON 文字列を JSON オブジェクトとして処理せねばなりません。本記事では .NET 3.0から導入された System.Text.Json 名前空間を使用して JSON のテキスト値部分のみを差し替えます。
System.Text.Json 名前空間には UTF-8 バイト配列の JSON を PullParser の様に読み込む Utf8JsonReader とその逆を行う Utf8JsonWriter が実装されています。これらを使用するととても簡単に JSON 文字列から一部のテキスト値を変更した新しい JSON 文字列を作成することができます。

それでは実際に実装してみましょう。 Utf8JsonReader の使い方は「 Read 呼び出し ⇒ JSONの部品を表すトークンに対して何らかの処理」の繰り返しです。 Utf8JsonWriter には Utf8JsonReader に対応するメソッドが実装されています。今回はテキスト値のみ差し替えたいため、それ以外のトークンに対しては対応する Writer のメソッドを呼び出すだけとなります。

/// <summary>
/// JSONバイト配列から制御文字を削除したJSONバイト配列を返す
/// </summary>
private static byte[] RemoveControlCharactersInJsonString(byte[] source)
{
    var reader = new Utf8JsonReader(source, new JsonReaderOptions
    {
        AllowTrailingCommas = true,
        CommentHandling = JsonCommentHandling.Disallow,
        MaxDepth = 0
    });
    using var ms = new MemoryStream();
    using var writer = new Utf8JsonWriter(ms);

    while (reader.Read())
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.None:
                // Do nothing
                break;
            case JsonTokenType.StartObject:
                writer.WriteStartObject();
                break;
            case JsonTokenType.EndObject:
                writer.WriteEndObject();
                break;
            case JsonTokenType.StartArray:
                writer.WriteStartArray();
                break;
            case JsonTokenType.EndArray:
                writer.WriteEndArray();
                break;
            case JsonTokenType.PropertyName:
                writer.WritePropertyName(reader.ValueSpan);
                break;
            case JsonTokenType.Comment:
                // Do nothing
                break;
            case JsonTokenType.String:
                // ここだけ特殊な処理を差し込みたい
                writer.WriteStringValue(reader.GetString());
                break;
            case JsonTokenType.Number:
                writer.WriteNumberValue(reader.GetDecimal());
                break;
            case JsonTokenType.True:
            case JsonTokenType.False:
                writer.WriteBooleanValue(reader.GetBoolean());
                break;
            case JsonTokenType.Null:
                writer.WriteNullValue();
                break;
            default:
                throw new InvalidOperationException("Unknown JsonTokenType");
        }
    }

    writer.Flush();
    return ms.ToArray();
}

そして文字列から制御文字(改行と横タブを除く)を削除する処理はこんな感じでしょうか。

// \r, \n, \t 以外の制御文字
private static readonly Regex ControlCharactersRegex =
    new ("[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u0080-\u009f]", RegexOptions.Multiline);

/// <summary>
/// 文字列から改行と横タブを除いた制御文字を削除した文字列にする
/// </summary>
private static string? RemoveControlCharacters(string? value)
{
    return value == null ? null : ControlCharactersRegex.Replace(value, "");
}

この制御文字を削除するメソッドを JSON のテキスト値読み込み・書き込みの箇所に挟み込めば、 JSON のテキスト値から制御文字を削除した新しい JSON 文字列の UTF-8 バイト配列が生成されます。

リクエストボディを差し替える

最後に先に作成した JSON の中身を差し替えた結果で HTTP リクエストのボディを差し替えれば完成です。リクエストボディを一旦全て読み込んだ上で、新しい MemoryStream に変更しましょう。
注)リクエストボディが UTF-8 でエンコードされている前提で実装していますので、他のエンコードにも対応する場合はよしなにしてください。

/// <summary>
/// リクエストボディを全て読み込んで制御文字を削除したJSONバイト配列とし、中身を差し替える
/// </summary>
private static async Task RemoveControlCharactersInRequestBodyAsync(HttpContext context)
{
    using var ms = new MemoryStream();
    await context.Request.Body.CopyToAsync(ms, context.RequestAborted);

    var bytes = RemoveControlCharactersInJsonString(ms.ToArray());
    context.Request.Body = new MemoryStream(bytes);
}

作成したこのメソッドをミドルウェアの Invoke で呼び出せば完成です。

/// <summary>
/// ミドルウェアの処理を実装します。
/// </summary>
public async Task Invoke(HttpContext context)
{
    // リクエストのContentTypeがJSON以外の場合は何もしない
    if (!context.Request.HasJsonContentType())
    {
        await _next(context);
        return;
    }

    try
    {
        // 特殊文字を削除したJSONデータを作成してリクエストボディのStreamを差し替える
        await RemoveControlCharactersInRequestBodyAsync(context);
    }
    catch (Exception e)
    {
        _logger.LogError(e, "RemoveControlCharacter failed.");
        throw;
    }

    // 次のミドルウェアの処理を実行します。
    await _next(context);
}

おわりに

入力にヒッソリと含まれている制御文字を削除するためのミドルウェアを自作してみました。 ASP.NET Core はこのような機能もサクッと実装・導入できるのも1つの利点かと思います。

執筆担当者プロフィール
中谷 大造

中谷 大造(日本ビジネスシステムズ株式会社)

情報システム部の中谷です。社内用スクラッチアプリの開発をしています。

担当記事一覧