はじめに
リソースサーバがリバースプロキシの後ろで動いている場合、クライアントが送ったリクエストとリソースサーバが受け取るリクエストに違いが出てきてしまいます。
例えば、クライアントが次のリクエストを送ったとしても、
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 の情報が得られなければ署名検証に失敗するので、リクエスト自体が拒否されてしまいます。
この状況を踏まえ、本記事では、オリジナルリクエストのターゲット URI を知る方法を見ていこうと思います。
ターゲットURI解決に使えるHTTPフィールド群
リバースプロキシが HTTP リクエストを転送する際、オリジナルの HTTP リクエストに関する情報を含む HTTP フィールド群を追加するのは一般的に行われています。
この用途のため、RFC 7239 Forwarded HTTP Extension により Forwarded HTTP フィールドが定義されました。例えば、オリジナルリクエストのスキームが https、 ホスト名が rs.example.com であることを伝える場合、次のような Forwarded HTTP フィールドが追加されるでしょう。
Forwarded: proto=https;host=rs.example.com
Forwarded HTTP フィールドは、それまで X-Forwarded-For、X-Forwarded-By、X-Forwarded-Proto などの非標準 HTTP フィールドを使って実現していたことを標準化したものです。ですので、Forwarded HTTP フィールドの利用が推奨されます。
しかしながら、Forwarded HTTP フィールドの値の構文は意外と複雑で、そのパース処理を (不可能ではないにしても) 正規表現でおこなうのは難しく、また、RFC 8941 で定義される汎用構文とも異なるので汎用ライブラリを用いることもできません。 結局は、Forwarded HTTP フィールド専用のパース処理を書かなければなりません (参考:Forwarded HTTPフィールドの構文と解析)。
そのため、処理しやすいカスタム 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 リクエストの情報にアクセスするためのインターフェースとして次のものがあると想定します。
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 の解決に利用します。
private String resolveByUrlField(HttpRequest request)
{
return request.header("X-Forwarded-URL");
}
Forwarded HTTPフィールドで解決する
オリジナルリクエストの絶対 URL を表すカスタム HTTP フィールドがなければ、Forwarded HTTP フィールドをターゲット URI の解決に利用します。下記のコードでは、Authlete 社がオープンソースで公開している authlete/http-field-parser ライブラリを用いています。
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);
}
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 の解決を試みます。
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);
}
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;
}
private static String resolveHostByFields(HttpRequest request)
{
// X-Forwarded-Host
String host = request.header("X-Forwarded-Host");
if (host != null)
{
return host;
}
return null;
}
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 を利用することにします。
private static String resolveByRequest(HttpRequest request)
{
return request.full();
}
ターゲット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 を用いて次のように実装できます。
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 Services の 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();
}
}
HttpServletRequest や ContainerRequestContext を引数として取る 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 保護
