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
各部分
| 部分 | 区切り | 役割 | サーバーに届くか |
|---|---|---|---|
| スキーム | 最初の : で終わる | どのプロトコルを使い、以降にどの規則が適用されるか | 接続方法を決める |
| ユーザー情報 | // の後に始まり @ で終わる | 認証情報。http と https では長らく非推奨 | クライアントが送ると決めた場合のみ |
| ホスト | :、/、?、# のいずれかで終わる | 名前解決して接続するドメイン名または 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=2、ids[]=1&ids[]=2、ids[0]=1&ids[1]=2、ids=1,2 はいずれも広く使われており、どれを理解するかを決めるのはサーバーだけです。ブラケット記法は厳密に妥当であるためにはエンコードが必要ですが (ids%5B%5D=1)、ほとんどのサーバーは生のブラケットも受け付けます。受信側がドキュメントに書いている形を選び、それを一貫して使ってください。
プラス記号には個別の注意が必要です。フォームエンコードされたリクエストボディである application/x-www-form-urlencoded のデータでは、+ は空白を意味します。URL のパスではリテラルのプラス記号を意味します。クエリ文字列ではパーサー次第で、ほとんどの Web フレームワークはそこでフォームのデコードを適用するため、+ は黙って空白に変わります。標準的な Base64 の出力や電話番号のように、正当にプラス記号を含みうる値は %2B として送らなければなりません。
相対参照
相対参照は、ベース URL の最後の / 以降をすべて置き換えることで解決されます。この最後のスラッシュがすべての規則であり、末尾のスラッシュが重要になる理由でもあります。
| ベース | 参照 | 結果 |
|---|---|---|
https://ex.com/docs/intro | guide | https://ex.com/docs/guide |
https://ex.com/docs/intro/ | guide | https://ex.com/docs/intro/guide |
https://ex.com/docs/intro | /guide | https://ex.com/guide |
https://ex.com/docs/intro/ | ../guide | https://ex.com/docs/guide |
https://ex.com/docs/intro?a=1 | ?b=2 | https://ex.com/docs/intro?b=2 |
https://ex.com/docs/intro?a=1 | #top | https://ex.com/docs/intro?a=1#top |
https://ex.com/docs/intro | //cdn.ex.com/x.js | https://cdn.ex.com/x.js |
最後の行はスキーム相対参照です。先頭の 2 本のスラッシュはベースのスキームを保ったままオーソリティを置き換えます。? で始まる参照はパスを保ったまま元のクエリを捨て、# で始まる参照は両方を保ちます。
2 つの URL が同じリソースを指すとき
スキームとホストは大文字と小文字を区別しないため、HTTPS://Example.COM と https://example.com は同じアドレスです。ホストより後ろは、標準の上では大文字と小文字を区別します。個々のサーバーが区別しないことを選んでいたとしても同じです。
次の組は等価です。
https://example.com:443/aとhttps://example.com/a。ポートが既定値だからですhttps://example.comとhttps://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.exampleはsecure-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 を使えば、どれが期待どおりのオーソリティを持っているかをすばやく確かめられます。