株式会社microCMS
🔖

URL文字列のエスケープに御用心

2024/03/12に公開

はじめに

新しい話題ではありませんが、知っていると役に立つかもしれない小話です。

エスケープした結果が異なる?

URLのパス文字列やクエリ文字列をエスケープする処理、例えば#%23に変換する処理が実装によって異なるのをご存じでしょうか?技術的に枯れていて差が発生しそうには思えませんが、その思い込みが罠でした。

以降はASCII文字のエスケープに限定して話を進めます。また、比較対象としてここではNodeJSとGoの実装を扱います。

それでは0x00から0x7fまでの文字をエスケープして、その差を確認してみましょう。

NodeJSのencodeURIComponent

まずはNodeJSの標準ライブラリからencodeURIComponentの動作を確認しましょう。以下のコードを実行するとASCIIコードの16進数表記とエスケープの結果がタブ区切りで表示されます。

for (let i = 0; i <= 0x7f; i++) {
  const input = `0x${i.toString(16)}`;
  const output = encodeURIComponent(String.fromCharCode(i));

  console.log(`${input}\t${output}`);
}

Goのnet/url.QueryEscape

続いてGoの標準パッケージからnet/url.QueryEscapeの動作を確認しましょう。こちらも実行するとASCIIコードの16進数表記とエスケープの結果がタブ区切りで表示されます。

package main

import (
	"fmt"
	"net/url"
)

func main() {
	for i := rune(0); i <= rune(0x7f); i++ {
		input := fmt.Sprintf("%x", i)
		output := url.QueryEscape(string(i))

		fmt.Printf("0x%s\t%s\n", input, output)
	}
}

なおGoの場合、net/url.PathEscape関数も提供されています。比較対象のencodeURIComponentはURLに含まれる個別のコンポーネントをエスケープするのが目的ですので、ここではQueryEscapeを選びました。

エスケープ結果の比較

NodeJSのencodeURIComponentとGoのurl.QueryEscapeの差を以下の表に示します。

ASCIIコード NodeJS Go
0x20 %20 +
0x21 ! %21
0x27 ' %27
0x28 ( %28
0x29 ) %29
0x2a * %2a

どのような状況で問題が発生するか

エスケープした結果に差が発生することは確認できました。続いて、どのような状況で問題が発生するか検討します。

NodeJS実装とGo実装が同居している

まず考えられる問題はNodeJSで実装されたシステムとGoで実装されたシステムが同居している状況です。

先ほどの比較表について空白文字0x20のエスケープに注目してください。NodeJSは%20、Goは+にエスケープされます。

エスケープの解除について、Goのurl.QueryUnescapeは%20を0x20のASCII文字と解釈して空白文字に変換します。一方、NodeJSのdecodeURIComponentは+をそのままプラス記号として出力します。

従ってシステム間でエスケープされた文字を介して通信する場合、NodeJSからGoは問題ありませんが、GoからNodeJSは意図しない結果が生じる恐れがあります。

とはいえ、この不具合はそれほど深刻な問題ではありません。?value=Hello%20Worldは成功するのに?value=Hello+Worldは失敗する、といった具合に成功か失敗かシステムの振る舞いを観測できるからです。

また、パス文字列やクエリ文字列を扱う処理にユニットテストを実装することで不具合をシステムが本番環境で稼働する前に発見できる可能性が高まります。

エスケープ解除前の文字列をそのまま扱っている

続いて、問題が発生していることに気がつく可能性が低い例を考えます。ぱっと思いつくのは以下2点でしょうか。

  1. CDNのキャッシュに利用するキーの値
  2. リクエストのログ分析でフィルタリングする際の条件

CDNが有効に機能していなくてもオリジンからレスポンスが返却されるため、設定の不備を見落とす恐れがあります。ログ分析する際のフィルタリングについては、多少の取りこぼしが許容される環境やサンプリングして一部のログだけ取り出す運用をする場合、フィルタ条件が機能していないことを見落とすかもしれません。どちらも致命的な問題ではありませんが、実装次第で思いがけない不具合を引き起こすかもしれません。

おわりに

技術的に枯れていそうだからといって、罠がないとは限りません。自分への戒めを込めた記事でした。

参考文献

  1. RFC 1738: Uniform Resource Locators (URL)
  2. RFC 3986: Uniform Resource Identifier (URI): Generic Syntax
  3. encodeURIComponentが世界基準だと誤解してた話 #Python - Qiita
株式会社microCMS
株式会社microCMS

Discussion