Skip to main content

JavaでURLを安全にエンコード・デコードする

著者
Headshot of Jura Gorohovsky

Jura Gorohovsky

security champions guide

2023年8月14日

0 分で読めます

URLエンコードとは、URLに有効な文字だけが含まれるようにして、受信側のサーバーが正しく解釈できるようにする方法です。RFC 3986標準によると、URI(URLの上位概念)に含められるのは限られた文字セットのみで、ASCII文字セット内の数字、英字、および少数の記号で構成されます。

URLにこの限られた文字セット以外の文字が含まれる場合、その文字はパーセントエンコードする必要があります。パーセントエンコードでは、文字を8ビットの2桁の16進数表記に変換し、その前にエスケープ文字%を付けます。同じ処理は、ASCII文字であってもURL内で本来の構造上の位置以外に使われる区切り文字(例:&、/、?、#)にも適用します。

一方、URLデコードとは、パーセントエンコードされたURLを元の形式に戻し、途中で標準外の文字を復元する方法です。

エンコードは暗号化ではないことを理解しておくことが重要です。暗号化とは、秘密鍵を使って情報を変換し、送信先以外の人が元の情報を利用できないようにすることです。これに対し、URLエンコードの目的は、外部の人からURLの一部を隠すことではありません。受信側のサーバーがURLを簡単かつ一意に解釈できるようにし、URLを作成して送信するクライアントのユーザーによる改ざんを防ぐことが目的です。

URLをエンコードしないと、さまざまな問題が発生する可能性があります。たとえば、アプリケーションでサーバーに送信するURLを組み立てられない場合があります。また、URLを受信したサーバーが正しく解析できず、エラーレスポンスを返すこともあります。さらに、エンコードされていないURLは改ざんされるおそれがあり、アプリケーションが潜在的なセキュリティ脅威にさらされる可能性があります。

どのプログラミング言語にも、URLのエンコードとデコードを行うAPIが1つ以上用意されています。この記事では、JavaとURLのエンコード・デコードが重要な理由、そして適切な方法について解説します。

JavaにおけるURLのエンコードとデコードとは?

JavaにおけるURLのエンコードとデコードは、特に次のような用途で重要です。

  • 検索フォームなど、HTMLフォームに訪問者が入力する自由形式のデータを処理する。

  • ベースURLにクエリパラメーターを追加して、コードから外部APIへの呼び出しを構築する。

  • 内部サービスへのリクエストをさらにルーティングするために使われるAPIゲートウェイへの呼び出しを構築する。

URLは次のような構造になっています。

サンプルURLのスキーム、ホスト、パス、クエリ、フラグメントの各要素を示す図。

通常、URL全体をエンコードする必要はありません。もちろん、ユーザーがアップロードしたファイル名に含まれるスペースがパス部分に入るケースもあります。ホスト名にはPunycodeという仕組みもあります。しかし、ほとんどの場合、ホスト部分とパス部分は自分で管理しているため、エンコードが必要なのはURLのうち可変データを表す部分(クエリ文字列のパラメーター値など)だけです。これらを1つずつエンコードしましょう。

URL全体をエンコードする必要があるのは、そのURLを別のURLのクエリ文字列内のパラメーターとして渡す場合です。

Javaで安全なURLエンコードとデコードを実装する

JavaでのURLエンコードとデコードをよりよく理解するために、クエリ文字列のパラメーターのエンコードとデコードにJavaアプリケーションでよく使われる2つのクラスを見てみましょう。

JavaでURLをエンコードする方法

Javaアプリケーションでクエリ文字列パラメーターの値にパーセントエンコードを適用するには、通常java.net.URLEncoderクラスとそのencode()メソッドを使います。

encode()メソッドの具体的な動作は次のとおりです。

  • aからz、AからZ、0から9,までの英数字、および特殊文字.、-、*、_は、そのまま保持されます。

  • スペース文字はプラス記号+に変換されます。

  • その他の文字はすべてパーセントエンコードされます。

このメソッドは、HTMLフォームのデータを送信できるようにapplication/x-www-form-urlencoded MIME形式に変換するために作られました。この形式は、URLクエリパラメーターの値のエンコードに適しています。

encode()メソッドには3つのオーバーロードがあります。

1. encode(String s, String enc)では、エンコード方式を文字列として明示的に指定できます(UTF-8を推奨)。このオーバーロードは使用できますが、チェック例外UnsupportedEncodingExceptionをスローする点に注意してください。そのため、@throws宣言またはtry/catchブロックで処理する必要があります。また、文字列リテラルを使うとタイプミスが発生するおそれがあります。

 String url;
  try {
      url = "https://example.com/search?q=" +
            URLEncoder.encode(parameterValue, "UTF-8");
  } catch (UnsupportedEncodingException e) {
      throw new RuntimeException(e);
  }

2. encode(String s, Charset charset)はJava 10から利用でき、現時点で最も適したオーバーロードです。UTF-8の定数定義(StandardCharsets.UTF_8)を使うため、エンコード方式の指定におけるタイプミスを防ぎ、チェック例外もスローされません。そのため、コードをコンパイルするために例外を処理する必要はありません。

String url = "https://example.com/search?q=" +
              URLEncoder.encode(parameterValue, StandardCharsets.UTF_8);

3. encode(String s)は最も古いオーバーロードで、OpenJDK 17では非推奨となっています。Java仮想マシン(JVM)が実行されているプラットフォームのデフォルトエンコードを使い、UTF-8である保証がないため、このオーバーロードは使わないでください。

URLEncoder.encode()には、スペースを%20ではなくプラス文字としてデコードするという特徴があります。これはおそらく、クエリ文字列について記述した古い標準に従っているためです。そのため、開発者はencode()の出力に手を加えて、スペースを表すプラス文字を%20に置き換えることがあります。

return URLEncoder.encode(parameter, StandardCharsets.UTF_8).replaceAll("\\+", "%20");

たとえば、リポジトリを検索するためにGitHub REST APIを使うとします。GitHubには、言語、リポジトリのサイズ、公開範囲で検索結果を絞り込める検索修飾子が豊富に用意されています。たとえば"user:defunkt forks:>100"で検索すると、ユーザーdefunktのリポジトリのうち、フォーク数が100以上のものがすべて返されます。これらのフィルターはAPI呼び出しで使用できますが、qクエリパラメーターで囲む必要があります。

String searchQuery = "language:Java stars:100..1000 pushed:>2018-01-01 is:public";
String url = "https://api.github.com/search/repositories?q=" +
           URLEncoder.encode(searchQuery, StandardCharsets.UTF_8) +
           "&per_page=10&sort=stars&order=desc";
HttpResponse<String> response = sendGetRequest(url);

このコードでは、searchQueryに検索修飾子のセットが格納されています。これにより、2018年以降に更新された、スター数が100~1,000の公開Javaリポジトリをすべて見つけられます。この例では値をハードコードしていますが、ファイルやデータベースから読み込んだり、Webアプリやモバイルアプリを通じてユーザーから直接入力させたりすることもできます。

urlを構築する際、このコードでは3つの文字列を連結します。

  1. APIのベースURL、クエリ文字列の区切り文字?、qパラメーター、および代入区切り文字=: "https://api.github.com/search/repositories?q="。

  2. パラメーターqの値であるsearchQueryは、URLEncode()を使ってパーセントエンコードされます。

  3. 残りのクエリパラメーターはハードコードされており、不正な文字も含まれていないため、エンコードする必要はありません。

このコードを実行すると、https://api.github.com/search/repositories?q=language%3AJava+stars%3A100..1000+pushed%3A%3E2018-01-01+is%3Apublic&per_page=10&sort=stars&order=descというURLが生成されます。qの値はla nguage%3AJava+stars%3A100..1000+pushed%3A%3E2018-01-01+is%3Apublicにエンコードされ、:は%3Aに、スペースは+に、>は%3Eに置き換えられます。

JavaでURLをデコードする方法

Spring Bootをはじめ、多くのフレームワークがデコードを自動で処理するため、URLクエリパラメーターを明示的にデコードする場面はそれほど多くありません。

フレームワークに頼らない場合は、次に何をするかに応じて処理方法を決めましょう。

URLを受け取って、データをクエリしたり、リクエストを別のサービスに再ルーティングしたりするなど、実行する処理を判断するケースが多いでしょう。その場合、処理ロジックでは各クエリパラメーターを個別に解析することになるはずです。まずURLを解析してクエリ文字列を抽出し、パラメーターの値を個別にデコードするとよいでしょう。

デコードには、java.net.URLDecoder.decode()を使ってパーセントエンコードされた文字をデコードできます。

String encodedUrl = "https://www.google.com/search?q=it%27s+my+party&newwindow=1&sxsrf=APwXEdeEqrxGIrZCgLpZFvGUSzgPweokog%3A1682563238731";
URI uri = URI.create(encodedUrl);
List<Map.Entry<String, String>> queryParamsAndValues = Arrays.stream(uri.getRawQuery().split("&"))
      .map(param -> Map.entry(param.split("=")[0], URLDecoder.decode(param.split("=")[1], StandardCharsets.UTF_8)))
      .toList();

ここでは、encodedUrlに、ブラウザーによってクエリパラメーターがパーセントエンコードされたGoogle検索URLが格納されています。このコードはURI型の新しいuriオブジェクトを作成し、クエリ文字列全体を抽出します。このオブジェクトのgetRawQuery()メソッドを使うと、パラメーター値がエンコードされたままのクエリ文字列だけを取得できます。

q=it%27s+my+party&newwindow=1&sxsrf=APwXEdeEqrxGIrZCgLpZFvGUSzgPweokog%3A1682563238731

次に、コードは生のクエリ文字列を区切り文字&で分割し、個々のパラメーターと値のペアを配列にします。各ペアでは、パラメーターはそのままにして値をデコードします。最後に、変換したすべてのペアをリストにまとめます。

q -> it's my party
newwindow -> 1
sxsrf -> APwXEdeEaxbGIrZCzLpZFvGUSzgPweokog:1682563238731

各パラメーターを分離し、対応する値と関連付けたら、それらを検証するためのロジックを適用し、アプリケーションの次の処理を決定できます。

JavaでURLを扱う際のベストプラクティス

JavaでURLを扱う際は、適切に処理し、潜在的な問題を避けるために、いくつかのベストプラクティスを覚えておきましょう。

URLエンコードを省略しない

URLのエンコードを省略すると、実行時例外が発生したり、接続先サーバーから予期しない応答が返ってきたりするなど、何が起きてもおかしくありません。

たとえば、先ほどのGitHub API呼び出しの例をもう一度見て、検索パラメーターをエンコードしなかった場合にどうなるか確認しましょう。

String searchQuery = "language:Java stars:100..1000 pushed:>2018-01-01 is:public";
String url = "https://api.github.com/search/repositories?q=" +
           searchQuery +
           "&per_page=10&sort=stars&order=desc";
HttpResponse<String> response = sendGetRequest(url);

このコード例では、sendGetRequest(url)がJava 11のリクエストビルダーAPIを使ったHTTPリクエストの構築をラップしている点に注意してください。

String auth = getAuthToken();
HttpRequest request = HttpRequest.newBuilder()
       .uri(new URI(url))
       .version(HttpClient.Version.HTTP_2)
       .header("Content-Type", "application/json")
       .header("Authorization", auth)
       .timeout(Duration.of(30, SECONDS))
       .GET()
       .build();

HttpClient client = HttpClient.newHttpClient();
return client.send(request, HttpResponse.BodyHandlers.ofString());

searchQueryをエンコードせずにこのコードを実行すると、実行時に失敗します。URIコンストラクターは、エンコードされていないスペースを含む文字列からURIオブジェクトを作成できないためです。

java.net.URISyntaxException: Illegal character in query at index 58: https://api.github.com/search/repositories?q=language:Java stars:100..1000 pushed:>2018-01-01 is:public&per_page=10&sort=stars&order=desc
   at java.base/java.net.URI$Parser.fail(URI.java:2974)
   at java.base/java.net.URI$Parser.checkChars(URI.java:3145)
   at java.base/java.net.URI$Parser.parseHierarchical(URI.java:3233)
   at java.base/java.net.URI$Parser.parse(URI.java:3175)
   at java.base/java.net.URI.<init>(URI.java:623)
   at org.example.CallGitHubAPI.sendGetRequest(CallGitHubAPI.java:67)

それでもこのリクエストを送信したい場合はどうでしょうか。URIではなくURLオブジェクトを受け取り、それを使って入力ストリームを読み込むScanner APIを試す方法があります。

String searchQuery = "language:Java stars:100..1000 pushed:>2018-01-01 is:public";
String url = "https://api.github.com/search/repositories?q=" +
           searchQuery +
           "&per_page=10&sort=stars&order=desc";

URL urlFromNonEncodedString;
Scanner inputStream = null;
try {
  urlFromNonEncodedString = new URL(url);
  inputStream = new Scanner(urlFromNonEncodedString.openConnection().getInputStream());
  System.out.println(inputStream.useDelimiter("\\A").next());
} catch (IOException e) {
  throw new RuntimeException(e);
}
finally {
  if (inputStream != null) inputStream.close();
}

実際、これは動作します。APIリクエストは宛先に届きますが、クライアント側のリクエスト構文の不備などが疑われることを示す400 Bad Requestエラーが返されます。

Exception in thread "main" java.lang.RuntimeException: java.io.IOException: Server returned HTTP response code: 400 for URL: https://api.github.com/search/repositories?q=language:Java stars:100..1000 pushed:>2018-01-01

Rotem Barは、RESTを使ったサービス間通信において、エンコードされていないURLがどのように改ざんされるかを見事に解説しています。

Rotemが説明する、URLの改ざんが可能なシナリオの1つでは、サービスが次の処理を行います。

  1. クライアントから届いたURLのパラメーターを受け取る。

  2. 別のサービスを呼び出すための新しいURLを作成する。

このシナリオを再現するコード例を見てみましょう。

is:public&per_page=10&sort=stars&order=desc
   at org.example.CallGitHubAPI.callWithoutEncoding(CallGitHubAPI.java:65)
   at org.example.CallGitHubAPI.callGitHubAPI(CallGitHubAPI.java:37)
   at org.example.Main.main(Main.java:16)
Caused by: java.io.IOException: Server returned HTTP response code: 400 for URL: https://api.github.com/search/repositories?q=language:Java stars:100..1000 pushed:>2018-01-01 is:public&per_page=10&sort=stars&order=desc
   at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1997)
   at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1589)
   at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:224)
   at org.example.CallGitHubAPI.callWithoutEncoding(CallGitHubAPI.java:62)

qパラメーターのように複雑な値を扱う場合、URLエンコードを省略すると、遅かれ早かれ問題を招きます。運よくサーバーから正常な応答が返ってきたとしても、期待どおりの内容が含まれているのか、それともサーバーの脆弱性を偶然悪用してしまっただけなのかは判断できません。

脆弱性といえば、URLパラメーターをエンコードしないと、簡単に脆弱性を引き起こす可能性があります。詳しくは、記事「開発者の皆さん、URLをエンコードしてください」で解説しています。

private static URI forwardRequestToAnotherService(String key, String user) {
    if (!validateUser(user)) return null;
    String newUrl = new StringBuilder()
            .append("https://my.internal.api.com/info?key=")
            .append(key)
            .append("&user=")
            .append(user)
            .toString();
    return URI.create(newUrl);
}

このサービスはkeyとuserの2つのパラメーターを受け取り、ユーザーを適切に検証します。しかしkeyは検証されず、新しいURLを作成する際にそのまま渡されます。この新しいURLのクエリパラメーターは、どれもエンコードされません。

このサービスが正規の入力を受け取ると、想定どおりに新しいURLを作成します。

forwardRequestToAnotherService("55s72502010a", "legituser")
// Output: https://my.internal.api.com/info?key=55s72502010a&user=legituser

では、悪意のある攻撃者が改ざんしたkeyパラメーターを含むURLを送信したらどうなるでしょうか。

forwardRequestToAnotherService("55s72502010a&user=admin#", "legituser")
// Output: https://my.internal.api.com/info?key=55s72502010a&user=admin#&user=legituser

次のようなことが起こります。

  1. クライアントから渡されたkeyの値は55s72502010a&user=admin#です。サービスはこのキーをエンコードせずにURLに追加します。

  2. サービスは通常どおりuserパラメーターを検証します。legituserは正当なユーザーなので、当然ながら正当なユーザーとして扱われます。

  3. 元のkeyパラメーターには、URLクエリ文字列内でパラメーターの区切り文字として機能する&が含まれているため、生成されるURLにはkey=55s72502010aとuser=adminという2つのパラメーターが含まれます。どちらもクライアントから送信されたkeyの一部です。ご覧のとおり、サービスはlegituserを認証済みであるにもかかわらず、生成されるURLでは権限が昇格している可能性のある別のユーザーになりすましており、そのユーザーは認証を回避しています。

  4. 元のkeyパラメーターの末尾に#が付いています。これはURL内でフラグメントの区切り文字として機能するため、クライアントから送信された元のURLの&user=legituserの部分はフラグメント部分に移動し、実質的に無視されます。

その結果、無関係で未検証のユーザーを表すURLが生成され、権限昇格攻撃につながります。

では、この攻撃を防ぐにはどうすればよいでしょうか。keyの値もuserとあわせて検証する方法があります。ただし、その検証をリクエストチェーン内の別のサービスに委任したい場合もあるでしょう。その場合は、新しいURLを構築するときにパラメーター値をエンコードするのが簡単です。

private static URI forwardRequestToAnotherService(String key, String user) {
    if (!validateUser(user)) return null;
    String newUrl = new StringBuilder()
            .append("https://my.internal.api.com/info?key=")
            .append(URLEncoder.encode(key, StandardCharsets.UTF_8))
            .append("&user=")
            .append(URLEncoder.encode(user, StandardCharsets.UTF_8))
            .toString();
    return URI.create(newUrl);
}

この変更を加えたうえで、悪意のあるkeyの値が送信された場合にどうなるか見てみましょう。

forwardRequestToAnotherService("55s72502010a&user=admin#", "legituser")
// Output: https://my.internal.api.com/info?key=55s72502010a%26user%3Dadmin%23&user=legituser

エンコードされたkeyの値(key=55s72502010a%26user%3Dadmin%23)は、&を%26に、#を%23にパーセントエンコードすることで、権限昇格の試みを防ぎます。生成されたURLは、正しいユーザー名とともに次のサービスへ渡されます。次のサービスは、改ざんされたkeyパラメーターを解釈できずエラーを返すかもしれませんが、エラー応答が返るほうが攻撃を許すよりはるかに安全です。

URLのエンコードとデコードには標準ライブラリを使う

特にJavaでは、クエリパラメーターのパーセントエンコードにURLEncoder.encode()とURLDecoder.decode()を使うことが多いでしょう。信頼性の高いツールで、どのJavaプロジェクトでもすぐに利用できます。

もう1つの選択肢は、Open Web Application Security Project (OWASP) Java Encoderライブラリです。主な目的はさまざまなコンテキストでの入力検証ですが、URLパラメーターの値やRESTパスをエンコードするEncode.forUriComponent()メソッドも含まれています。クライアントから送信されたURLやその一部を、さまざまなUIコントロールに安全に表示する必要があるJavaアプリケーションに適しています。

ただし、使用しているフレームワークによっては、明示的なデコードが不要な場合や、別の方法でデコードする必要がある場合があります。

  • SpringまたはSpring Bootでは、コントローラーパラメーターに@RequestParamアノテーションを適用すると、そのパラメーターの値が自動的にデコードされます。

  • GWTアプリケーションでは、GWT独自の`com.google.gwt.http.client.URL`クラスで定義されているメソッドを使うことが多いでしょう。

  • Androidには、クエリパラメーターのエンコード機能を備えた独自のURLビルダーAPIがあります。

サードパーティ製ライブラリを使ってURLをエンコード、デコードしている場合は、既知の脆弱性がないかスキャンし、脆弱性が見つかったら、セキュリティ修正が利用可能になり次第、ライブラリを更新しましょう。

アプリでURLを扱う前に、検証してサニタイズする

URLエンコードは対策の一部にすぎません。WebアプリケーションがURLパラメーターを介してユーザーデータを受け取り、そのデータをWebアプリケーションに戻す場合は、ブラウザーのコンテキストに戻す前に、そのデータが無害であることを確認する必要があります。

つまり、URL経由でサーバー側に届くデータも含めて、ユーザーデータの構文と意味の両方を検証する必要があります。また、ブラックリスト方式は漏れや誤りが起きやすいため、許可リストを使用しましょう。

さらに、アプリケーションでユーザーがHTMLコードを送信できる場合は、HTMLサニタイズを実装する必要があります。Javaでこの目的によく使われるライブラリの1つがOWASP Java HTML Sanitizerです。

Snyk Security Extension for IntelliJ IDEAで安全でないURLを検出する

URLのエンコードとデコードは、データのエンコードとエスケープ、入力検証など、より幅広いセキュリティ対策の一部として行うべきです。安全なコードを書くことは不可欠です。開発サイクルのできるだけ早い段階でセキュリティ上の欠陥を見つければ、修正コストとセキュリティインシデントの影響を抑えられます。

IntelliJ IDEAを使うJava開発者は、Snyk Security拡張機能をインストールすると役立つでしょう。

Snyk Securityは問題を検出してコードエディター内で強調表示し、次の項目に含まれる既知の脆弱性の修正を支援します。

  • 独自のコード

  • プロジェクトに取り込んでいる直接および推移的なオープンソース依存関係

  • Dockerイメージ

  • Infrastructure as Code(IaC)テンプレート

Snyk Security拡張機能は、URLパラメーターからの未サニタイズ入力など、クロスサイトスクリプティング(XSS)、コマンドインジェクション、サーバーサイドリクエストフォージェリ(SSRF)、オープンリダイレクトの脆弱性につながる可能性のある問題の特定を支援します。検出された脆弱性ごとに、さまざまなオープンソースJavaプロジェクトで過去に同様の問題がどのように修正されたかを確認できます。

IntelliJ IDEAで、Snykがクロスサイトスクリプティング(XSS)の脆弱性として検出したJavaコードが表示されています。データフローと修正例も確認できます。

Snyk SecurityがJavaコードに対して実行するセキュリティ検査の全リストはこちらです。

まとめ

URLパラメーターをエンコードしないことは、些細な見落としに思えるかもしれません。しかし、この記事で見てきたように、Javaアプリケーションの信頼性とセキュリティに深刻な影響を及ぼす可能性があります。URLパラメーターをパーセントエンコードすれば、正規ユーザーに期待どおりの結果を提供し、悪意ある攻撃者によるアクセス制御の回避や攻撃を防げます。

URLのエンコードとデコードは、幅広いセキュリティのベストプラクティスの一部にすぎません。それでも、安定したAPIとIntelliJ IDEA用Snyk Security拡張機能のような開発者向けツールを活用すれば、確実に安全なコードをリリースし、本番環境でのセキュリティインシデントの対応や事後処理に時間を費やす事態を避けられます。

Capture the Flagを始めよう

オンデマンドのバーチャル入門ワークショップを見て、Capture the Flagの課題の解き方を学びましょう。

続きを読む

blog feature snyk container custom base recommendations
Blog

Evo Agentic AppSecのご紹介:AI開発のスピードに対応するセキュリティ

AI駆動型開発のスピードに、従来のセキュリティワークフローは追いついていません。SnykのAgentic AppSecは、セキュリティエージェントが脆弱性を継続的に検出、修正、検証し、人が基準を定められるようにします。

feature insights context
Blog

自律型攻撃はすでに始まっている。防御もそのスピードに追いつかなければならない。

自律型攻撃者によって、防御に使える時間は短くなっています。継続的な検出、修復、検証、予防で、セキュリティチームが攻撃に歩調を合わせる方法をご紹介します。

illustration hero ai
Blog

AIハリケーンが到来

AIはソフトウェア開発とサイバー攻撃の双方を加速させています。リーダーは、エージェントとコードを開発の初期段階から保護し、実行時に制御を徹底するとともに、防御策を独立して検証しなければなりません。