1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

オリジナルリクエストのターゲットURIの解決

1
Posted at

はじめに

リソースサーバがリバースプロキシの後ろで動いている場合、クライアントが送ったリクエストとリソースサーバが受け取るリクエストに違いが出てきてしまいます。

例えば、クライアントが次のリクエストを送ったとしても、

クライアントがサーバ (リバースプロキシ) に送るリクエスト
GET https://rs.example.com/resource/1 HTTP/1.1

リバースプロキシは、そのリクエストを次のリクエストに変換してリソースサーバに転送するかもしれません。

リバースプロキシがリソースサーバに送るリクエスト
GET http://rs.internal:8000/resource/1 HTTP/1.1

これまで、この違いをあまり気にする必要はありませんでした。しかし、近年、オリジナルリクエストの『ターゲット URI』(RFC 9110, Section 7.1) の正確な情報がどうしても必要となる場面が出てきました。

例えば、リソースサーバは、DPoP proof JWT (RFC 9449) の htu クレームの値がオリジナルリクエストの (クエリー部とフラグメント部を除く) ターゲット URI と一致するかどうかを確認しなければなりません。また、HTTP メッセージ署名 (RFC 9421) で @target-uri 派生コンポーネント (Section 2.2.2) が使われている場合、リソースサーバがシグネチャベースを構築する際、オリジナルリクエストのターゲット URI の情報が必要です。どちらのケースも、オリジナルリクエストのターゲット URI の情報が得られなければ署名検証に失敗するので、リクエスト自体が拒否されてしまいます。

target_uri.png

この状況を踏まえ、本記事では、オリジナルリクエストのターゲット URI を知る方法を見ていこうと思います。

ターゲットURI解決に使えるHTTPフィールド群

リバースプロキシが HTTP リクエストを転送する際、オリジナルの HTTP リクエストに関する情報を含む HTTP フィールド群を追加するのは一般的に行われています。

この用途のため、RFC 7239 Forwarded HTTP Extension により Forwarded HTTP フィールドが定義されました。例えば、オリジナルリクエストのスキームが https、 ホスト名が rs.example.com であることを伝える場合、次のような Forwarded HTTP フィールドが追加されるでしょう。

Forwarded HTTP フィールドの例
Forwarded: proto=https;host=rs.example.com

Forwarded HTTP フィールドは、それまで X-Forwarded-ForX-Forwarded-ByX-Forwarded-Proto などの非標準 HTTP フィールドを使って実現していたことを標準化したものです。ですので、Forwarded HTTP フィールドの利用が推奨されます。

しかしながら、Forwarded HTTP フィールドの値の構文は意外と複雑で、そのパース処理を (不可能ではないにしても) 正規表現でおこなうのは難しく、また、RFC 8941 で定義される汎用構文とも異なるので汎用ライブラリを用いることもできません。 結局は、Forwarded HTTP フィールド専用のパース処理を書かなければなりません (参考:Forwarded HTTPフィールドの構文と解析)。

forwarded_http_field_syntax.png

そのため、処理しやすいカスタム HTTP ヘッダ群は依然として広く使われています。 以下は、そのようなカスタム HTTP ヘッダ群の例です。

ヘッダ名 参照
Front-End-Https [Microsoft] Helping to Secure Communication: Client to Front-End Server
X-Forwarded-For MDN Web Docs / X-Forwarded-For
X-Forwarded-Host MDN Web Docs / X-Forwarded-Host
X-Forwarded-Port [AWS] HTTP headers and Classic Load Balancers # X-Forwarded-Port
X-Forwarded-Proto MDN Web Docs / X-Forwarded-Proto
X-Forwarded-Protocol MDN Web Docs / X-Forwarded-Proto
X-Forwarded-Ssl MDN Web Docs / X-Forwarded-Proto
X-Url-Scheme MDN Web Docs / X-Forwarded-Proto

しかし、標準化された Forwarded HTTP ヘッダや広く使われているカスタム HTTP ヘッダ群は、ターゲット URI を求めるための手段としては不完全です。というのは、パス部やクエリー部の情報を含まないからです

HTTP メッセージ署名の @target-uri 派生コンポーネントを利用するためにはクエリー部の情報も必要なのですが、残念ながら現時点では、パス部やクエリー部も含む絶対 URL の情報を伝えるための標準化されたヘッダや広く使われているカスタムヘッダはありません。絶対 URL を表す何らかのカスタム HTTP ヘッダ (例:X-Forwarded-URL) の普及や、標準 HTTP ヘッダ (例:Target-URI) の定義、新しい HTTP Forwarded パラメータの追加などが望まれます。

ターゲットURIを解決するコード

ここでは、先のセクションで紹介した HTTP フィールド群を用いてオリジナルリクエストのターゲット URI を解決するためのコードを書いていこうと思います。

リソースサーバが受け取るHTTPリクエストの情報

まず、リソースサーバが受け取る HTTP リクエストの情報にアクセスするためのインターフェースとして次のものがあると想定します。

HTTPリクエストの情報にアクセスするためのインターフェース
private static interface HttpRequest
{
    /**
     * 指定された名前で特定される HTTP ヘッダの値を取得する。
     * 同じ名前の HTTP ヘッダが複数存在する場合、最初のものの値を返す。
     */
    String header(String name);

    /**
     * パスの値を取得する。
     * 値が null や空文字列でない場合、スラッシュで始まる文字列が返される。
     */
    String path();

    /**
     * クエリー文字列を取得する。
     * 先頭にクエスチョンマークはない。
     */
    String query();

     /**
      * クエリー文字列を含む完全 URL を取得する。
      */
    String full();
}

絶対URLフィールドで解決する

最初に、オリジナルリクエストの絶対 URL を表す X-Forwarded-URL という HTTP フィールドがあれば、それをターゲット URI の解決に利用します。

絶対URLを表すHTTPフィールドを用いてオリジナルリクエストのターゲットURIを解決する
private String resolveByUrlField(HttpRequest request)
{
    return request.header("X-Forwarded-URL");
}

Forwarded HTTPフィールドで解決する

オリジナルリクエストの絶対 URL を表すカスタム HTTP フィールドがなければ、Forwarded HTTP フィールドをターゲット URI の解決に利用します。下記のコードでは、Authlete 社がオープンソースで公開している authlete/http-field-parser ライブラリを用いています。

Forwarded HTTPフィールドを用いてオリジナルリクエストのターゲットURIを解決する
private String resolveByForwardedField(HttpRequest request)
{
    // Forwarded HTTP フィールドの値
    String fieldValue = request.header("Forwarded");
    if (fieldValue == null)
    {
        return null;
    }

    ForwardedFieldValue ffv;

    try
    {
        // Forwarded HTTP フィールドの値をパースする
        ffv = ForwardedFieldValue.parse(fieldValue);
    }
    catch (RuntimeException cause)
    {
        // Forwarded HTTP フィールドの値のフォーマットが正しくない
        return null;
    }

    // 先頭の "forwarded element"
    ForwardedElement fe = ffv.get(0);

    // スキーム部とホスト部。ただし、これらの情報が含まれていない可能性はある。
    String scheme = fe.getProto();
    String host   = fe.getHost();

    // オリジナルリクエストのリクエスト URL を再構築する
    return reconstructOriginalRequestUrl(request, scheme, host, 0);
}
オリジナルリクエストのURLを再構築する
private static String reconstructOriginalRequestUrl(
        HttpRequest request, String scheme, String host, int port)
{
    if (scheme == null || host == null)
    {
        return null;
    }

    // リクエストのパス。もしもリバースプロキシがパスを変更していると、
    // このロジックではオリジナルのリクエスト URL を再構築できない。
    String path = request.path();

    // クエリー文字列。もしもリバースプロキシがクエリー文字列を変更していると、
    // このロジックではオリジナルのリクエスト URL を再構築できない。
    String qs = request.query();

    // "{スキーム}://{ホスト}"
    StringBuffer sb = new StringBuffer()
            .append(scheme).append("://").append(host);

    // ポート番号が指定されていて、かつ、それがデフォルトポートではない場合
    if (0 < port && !isDefaultPort(port, scheme))
    {
        // ":{port}" を追加する
        sb.append(':').append(port);
    }

    // パスがあれば
    if (path != null)
    {
        // "{path}" を追加する
        sb.append(path);
    }

    // クエリー文字列がある場合、"?{クエリー文字列}" を追加する
    return appendQueryStringIfAvailable(sb, qs);
}
デフォルトポートかどうか判定する
private static boolean isDefaultPort(int port, String scheme)
{
    return (port == 443 && scheme.equalsIgnoreCase("https")) ||
           (port ==  80 && scheme.equalsIgnoreCase("http" )) ;
}
クエリー文字列があれば "?{クエリー文字列}" を追加する
private static String appendQueryStringIfAvailable(StringBuffer sb, String queryString)
{
    if (queryString != null)
    {
        sb.append('?').append(queryString);
    }

    return sb.toString();
}

カスタムHTTPフィールド群で解決する

Forwarded HTTP フィールドがない場合は、カスタム HTTP フィールド群を用いてターゲット URI の解決を試みます。

カスタムHTTPフィールド群を用いてオリジナルリクエストのターゲットURIを解決する
private static String resolveByCustomFields(HttpRequest request)
{
    // X-Forwarded-Proto などの HTTP フィールドを用いてスキーム部を解決する
    String scheme = resolveSchemeByFields(request);

    // X-Forwarded-Host などの HTTP フィールドを用いてホスト部を解決する
    String host = resolveHostByFields(request);

    // X-Forwarded-Port などの HTTP フィールドを用いてポート部を解決する
    int port = resolvePortByFields(request);

    // オリジナルリクエストのリクエスト URL を再構築する
    return reconstructOriginalRequestUrl(request, scheme, host, port);
}
カスタムHTTPフィールドを用いてスキーム部を解決する
private static String resolveSchemeByFields(HttpRequest request)
{
    // X-Forwarded-Proto
    String scheme = request.header("X-Forwarded-Proto");
    if (scheme != null)
    {
        return scheme;
    }

    // X-Forwarded-Protocol
    scheme = request.header("X-Forwarded-Protocol");
    if (scheme != null)
    {
        return scheme;
    }

    // X-Url-Scheme
    scheme = request.header("X-Url-Scheme");
    if (scheme != null)
    {
        return scheme;
    }

    // X-Forwarded-Ssl
    String mode = request.header("X-Forwarded-Ssl");
    if (mode != null)
    {
        return mode.equalsIgnoreCase("on") ? "https" : "http";
    }

    // Front-End-Https
    mode = request.header("Front-End-Https");
    if (mode != null)
    {
        return mode.equalsIgnoreCase("on") ? "https" : "http";
    }

    return null;
}
カスタムHTTPフィールドを用いてホスト部を解決する
private static String resolveHostByFields(HttpRequest request)
{
    // X-Forwarded-Host
    String host = request.header("X-Forwarded-Host");
    if (host != null)
    {
        return host;
    }

    return null;
}
カスタムHTTPフィールドを用いてポート部を解決する
private static int resolvePortByFields(HttpRequest request)
{
    // X-Forwarded-Port
    String port = request.header("X-Forwarded-Port");
    if (port != null)
    {
        try
        {
            return Integer.parseInt(port);
        }
        catch (NumberFormatException cause)
        {
        }
    }

    return 0;
}

リクエスト自体の完全URLで解決する

カスタム HTTP フィールドも利用できなければ、フォールバックとして、リソースサーバから見えているリクエスト自体の完全 URL を利用することにします。

リクエスト自体の完全URLでターゲットURIを解決する
private static String resolveByRequest(HttpRequest request)
{
    return request.full();
}

ターゲットURI解決手段を統合する

ここまでに紹介したターゲット URI 解決手段を統合します。

オリジナルリクエストのターゲットURIを解決する
private String resolve(HttpRequest request)
{
    // 絶対 URL フィールドで解決する
    url = resolveByUrlField(request);
    if (url != null)
    {
        return url;
    }

    // Forwarded HTTP フィールドで解決する
    url = resolveByForwardedField(request);
    if (url != null)
    {
        return url;
    }

    // カスタム HTTP フィールド群で解決する
    url = resolveByCustomFields(request);
    if (url != null)
    {
        return url;
    }

    // リクエスト自体の完全 URL で解決する
    return resolveByRequest(request);
}

HttpRequestインターフェースの実装

Jakarta EE 環境であれば、HttpRequest インターフェースを HttpServletRequest を用いて次のように実装できます。

HttpServletRequestを用いるHttpRequestインターフェースの実装
private static final class HttpServletRequestHttpRequest implements HttpRequest
{
    private final HttpServletRequest request;

    HttpServletRequestHttpRequest(HttpServletRequest request)
    {
        this.request = request;
    }

    @Override
    public String header(String name)
    {
        return request.getHeader(name);
    }

    @Override
    public String path()
    {
        return request.getRequestURI();
    }

    @Override
    public String query()
    {
        return request.getQueryString();
    }

    @Override
    public String full()
    {
        // The request URL without parameters.
        StringBuffer sb = request.getRequestURL();

        // The query string. This may be null.
        String qs = request.getQueryString();

        // Append "?{query-string}" if a query string is available,
        // and convert the buffer to a string.
        return appendQueryStringIfAvailable(sb, qs);
    }
}

Jakarata RESTful Web ServicesContainerRequestContext を使う HttpRequest インターフェースの実装は次のようになります。

ContainerRequestContextを用いるHttpRequestインターフェースの実装
private static final class ContainerRequestContextHttpRequest implements HttpRequest
{
    private final ContainerRequestContext context;

    ContainerRequestContextHttpRequest(ContainerRequestContext context)
    {
        this.context = context;
    }

    @Override
    public String header(String name)
    {
        return context.getHeaders().getFirst(name);
    }

    @Override
    public String path()
    {
        String path = context.getUriInfo().getRequestUri().getPath();

        if (path != null && 0 < path.length() && path.charAt(0) != '/')
        {
            path = "/" + path;
        }

        return path;
    }

    @Override
    public String query()
    {
        return context.getUriInfo().getRequestUri().getQuery();
    }

    @Override
    public String full()
    {
        return context.getUriInfo().getRequestUri().toString();
    }
}

HttpServletRequestContainerRequestContext を引数として取る resolve メソッドを実装するなら、次のようになるでしょう。

resolveメソッドのオーバーロード
public String resolve(HttpServletRequest request)
{
    return resolve(new HttpServletRequestHttpRequest(request));
}

public String resolve(ContainerRequestContext context)
{
    return resolve(new ContainerRequestContextHttpRequest(context));
}

おわりに

DPoP (RFC 9449) や HTTP メッセージ署名 (RFC 9421) を実際に商用実装しようとすると、本記事で取り上げた「オリジナルリクエストのターゲット URI の解決」が必要となります。

Authlete 社のウェブサイトで公開している『標準仕様による徹底的な API 保護』という文書では、API 保護に関するその他のトピックも扱っていますので、ご興味があればご参照ください。また、2025 年 10 月 29 日に開催したオンライン勉強会『OAuth・OpenID 標準仕様による徹底的な API 保護』では、当文書の要点を紹介しております。併せてご視聴ください。

OAuth・OpenID 標準仕様による徹底的な API 保護
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?