URL の各部分と、そこに書ける文字

URL の各部分を実例の上でラベル付けし、どの文字をどこでパーセントエンコードする必要があるか、クエリ文字列が実際にどう振る舞うか、ホストを偽ったリンクをどう見抜くかを解説します。

URL は、どのプロトコルで話すか、どのマシンに向かって話すか、そして何を要求するかを 1 本の文字列で表したものです。正しく読めるかどうかは、ほぼ各部分がどこで終わるかを知っているかどうかで決まります。区切り文字はいずれも 1 文字で、最初に現れたものが優先されるからです。

https://alex:s3cret@www.example.co.uk:8443/docs/intro?lang=en&page=2#notes
│       │           │                 │   │           │              └─ fragment
│       │           │                 │   │           └──────────────── query
│       │           │                 │   └──────────────────────────── path
│       │           │                 └──────────────────────────────── port
│       │           └────────────────────────────────────────────────── host
│       └────────────────────────────────────────────────────────────── userinfo
└────────────────────────────────────────────────────────────────────── scheme

各部分

部分区切り役割サーバーに届くか
スキーム最初の : で終わるどのプロトコルを使い、以降にどの規則が適用されるか接続方法を決める
ユーザー情報// の後に始まり @ で終わる認証情報。httphttps では長らく非推奨クライアントが送ると決めた場合のみ
ホスト:/?# のいずれかで終わる名前解決して接続するドメイン名または IP アドレス届く。Host ヘッダーに入る
ポート: の後に始まり /?# のいずれかで終わるTCP ポート。既定値は http が 80、https が 443接続に使われる
パス/ で始まり ? または # で終わるそのホスト上のどのリソースか届く。リクエスト行に入る
クエリ? で始まり # で終わるそのリソースに渡すパラメーター届く。リクエスト行に入る
フラグメント# で始まり末尾までリソース内の位置届かない

ホストとポートを合わせたものがオーソリティです。フラグメントだけは例外で、リクエストが組み立てられる前に取り除かれるため、サーバーが目にすることはありません。ブラウザーはこれをアンカーやクライアント側のルーティングに使います。認証フローの中には、トークンがサーバーのログに残らないよう、意図的にフラグメントでトークンを返すものもあります。ただしブラウザーの履歴には残るので、秘密が守られているわけではなく、単に送信されないだけです。

予約文字と非予約文字

パーセントエンコーディングは、その文字の UTF-8 バイト列を使い、1 バイトを % と 16 進 2 桁で書き表します。é%C3%A9 になり、2 バイトなのでエスケープも 2 つです。

英数字に加えて 4 つの文字が 非予約 であり、どの位置でもエンコードは不要です。- . _ ~ の 4 つです。それ以外の文字は、URL のどこかで構造上の役割を持つ 予約 文字であるか、さもなければエンコードが必要です。予約文字の集合は : / ? # [ ] @! $ & ' ( ) * + , ; = です。

「予約」という言葉は「常に禁止」という意味ではありません。その文字がどこかの部分で区切りとして働くという意味であり、区切りとして読まれてしまう部分でだけエンコードすればよい、ということです。

文字パスセグメント内クエリ値の中
空白常に %20%20 または +
/%2F。そのままではセグメントが分かれるそのままで妥当
?%3F。そのままではパスがそこで終わるそのままで妥当
#常に %23常に %23
&=そのままで妥当%26%3D。そのままでは対が分かれる
+妥当であり、プラス記号を意味する%2B。そのままでは空白としてデコードされうる

実務上の規則はこうです。自分がいる部分を終わらせてしまう文字をエンコードする、ということです。パス内の空白は %20 にしなければなりません。生の空白はほとんどのパーサーで URL の終わりとみなされるからです。一方、クエリ値の空白は %20 でも古い + でもかまいません。これを確実に行うには URL Encoder を使います。URL 全体をエンコードするのと構成要素を 1 つエンコードするのは別の操作であり、必要なのはほぼ常に後者だからです。

クエリ文字列は慣習にすぎない

仕様が定めているのは、クエリとは ?# の間にある、許可された文字から成る部分すべてだ、ということだけです。key=value&key=value という形は HTML のフォームエンコーディングに由来するもので、URI の標準に由来するものではありません。サーバーが ?a:1;b:2 を好きなように解釈しても、それを妨げるものは何もありません。

この形が慣習にすぎないため、同じキーが繰り返された場合の意味は定義されておらず、各スタックがそれぞれの答えを選びました。

?id=1&id=2 の扱い該当する実装
両方の値をリストとして返すPython の parse_qs、Node の querystring、Express
最初の値だけGo の Query().Get、Java の getParameter
最後の値だけPHP、Rails
1,2 に連結するASP.NET のリクエストコレクション

配列にも同じ問題がついて回ります。ids=1&ids=2ids[]=1&ids[]=2ids[0]=1&ids[1]=2ids=1,2 はいずれも広く使われており、どれを理解するかを決めるのはサーバーだけです。ブラケット記法は厳密に妥当であるためにはエンコードが必要ですが (ids%5B%5D=1)、ほとんどのサーバーは生のブラケットも受け付けます。受信側がドキュメントに書いている形を選び、それを一貫して使ってください。

プラス記号には個別の注意が必要です。フォームエンコードされたリクエストボディである application/x-www-form-urlencoded のデータでは、+ は空白を意味します。URL のパスではリテラルのプラス記号を意味します。クエリ文字列ではパーサー次第で、ほとんどの Web フレームワークはそこでフォームのデコードを適用するため、+ は黙って空白に変わります。標準的な Base64 の出力や電話番号のように、正当にプラス記号を含みうる値は %2B として送らなければなりません。

相対参照

相対参照は、ベース URL の最後の / 以降をすべて置き換えることで解決されます。この最後のスラッシュがすべての規則であり、末尾のスラッシュが重要になる理由でもあります。

ベース参照結果
https://ex.com/docs/introguidehttps://ex.com/docs/guide
https://ex.com/docs/intro/guidehttps://ex.com/docs/intro/guide
https://ex.com/docs/intro/guidehttps://ex.com/guide
https://ex.com/docs/intro/../guidehttps://ex.com/docs/guide
https://ex.com/docs/intro?a=1?b=2https://ex.com/docs/intro?b=2
https://ex.com/docs/intro?a=1#tophttps://ex.com/docs/intro?a=1#top
https://ex.com/docs/intro//cdn.ex.com/x.jshttps://cdn.ex.com/x.js

最後の行はスキーム相対参照です。先頭の 2 本のスラッシュはベースのスキームを保ったままオーソリティを置き換えます。? で始まる参照はパスを保ったまま元のクエリを捨て、# で始まる参照は両方を保ちます。

2 つの URL が同じリソースを指すとき

スキームとホストは大文字と小文字を区別しないため、HTTPS://Example.COMhttps://example.com は同じアドレスです。ホストより後ろは、標準の上では大文字と小文字を区別します。個々のサーバーが区別しないことを選んでいたとしても同じです。

次の組は等価です。

  • https://example.com:443/ahttps://example.com/a。ポートが既定値だからです
  • https://example.comhttps://example.com/。空のパスはルートを意味するからです
  • /a/%7Euser/a/~user~ は非予約文字であり、パーセントエンコードしても何も変わらないからです

次の組は等価ではありません。

  • /docs/docs/。ほとんどのサーバーは一方を他方にリダイレクトしますが、これらは別のリソースです
  • /p?a=1&b=2/p?b=2&a=1。パラメーターの順序も文字列の一部だからです
  • /index.html/。サーバーがそう扱うと明示していない限り別物です

キャッシュや CDN はバイト列そのものをキーにするため、トラッキングパラメーターが 1 つ加わったり、クエリの順序が入れ替わったりするだけで、1 つのキャッシュオブジェクトが 2 つに分かれ、ヒット率は半減します。検索エンジンは、canonical リンクが 1 つの形を指していない限り、これらの派生形を重複ページとして扱います。対処法は、形を 1 つに決め、残りを 301 でそこへリダイレクトし、レスポンスを変えないパラメーターを取り除くことです。

長さ、ログ、プライバシー

仕様に長さの上限はありませんが、それ以外のあらゆる場所には上限があります。よくあるサーバーの既定値はリクエスト行全体を約 8 KB に制限し、一部のプロキシやアプライアンスは 4 KB、古いクライアントは 2 KB 程度に制限します。メールクライアント、QR コード、URL 短縮サービスを通り抜ける必要があるものは、およそ 2000 文字以内に収めておくのが安全です。

URL を短く保つべきより強い理由は、クエリ文字列が秘密ではないという点にあります。クエリ文字列はアクセスログに書き込まれ、Referer ヘッダーで第三者に渡され、ブラウザーの履歴に残り、ブックマークとともに保存され、誰かがリンクを共有するたびにコピーされます。したがって、セッショントークン、パスワードリセット用のコード、API キー、個人情報をそこに置くべきではありません。大きなデータや機微なデータはリクエストボディに入れます。ボディには実用上のサイズ上限がなく、既定ではログにも記録されません。

クリックする前にリンクを読む

オーソリティは :// の直後から始まり、最初に現れる /?# で終わります。その範囲を読み、さらに末尾の 2 つか 3 つのラベルを読んでください。それが本当のホストであり、その左側にあるものはホストを何も変えません。

https://www.paypal.com@198.51.100.7/secure/login
        ^^^^^^^^^^^^^^ ^^^^^^^^^^^^
        userinfo       real host

よくある見せかけの手口です。

  • @ より前のテキストはユーザー情報であってホストではありません。どんなブランド名でも書けてしまいます。
  • paypal.com.secure-login.examplesecure-login.example のサブドメインです。ラベルは右から左に読みます。
  • https://short.example/https://www.bank.com/login は、もっともらしい URL 全体をパスに入れています。
  • xn-- で始まるホストは Punycode であり、IDNA が生成する国際化ドメイン名の ASCII 表現です。münchen.de は通信上は xn--mnchen-3ya.de になります。これ自体は正当な仕組みですが、同じ仕組みがホモグラフも可能にします。キリル文字の а (U+0430) はラテン文字の a と見た目で区別できず、これを使ったドメインは xn--pple-43d.com のような形にエンコードされます。ラベル内で複数の文字体系が混在する場合、ブラウザーは Punycode 形式を表示しますが、その規則は実装によって異なり、保証にはなりません。
  • ホストの中にある %2F%40 のようなエンコードされた区切り文字は、パーサーを混乱させるための意図的な仕掛けです。

リンクを URL Parser に貼り付ければ一度で決着します。本物のパーサーはブラウザーと同じ「最初の区切りが勝つ」規則を適用し、ホストだけを取り出して見せてくれるからです。多数のリンクをまとめて確認するなら、Regex Tester を使えば、どれが期待どおりのオーソリティを持っているかをすばやく確かめられます。