URL vs URI vs URN: 違いは
これら3つの識別子仕様の関係を一度ですっきり整理。
開発者は会話の中で「URL」と「URI」を互換的に使いますが、日常的な用途ならそれで問題ありません。しかし区別は実在し、RFC 3986に明文化されています。そしてその下の層である汎用構文とエンコード規則への誤解こそが、壊れたクエリ文字列からサーバーサイドリクエストフォージェリ(SSRF)まで、実際の本番バグを引き起こすのです。
一段落でわかる階層
URI(Uniform Resource Identifier)は、リソースを識別するあらゆる文字列の包括的な用語です。URL(Uniform Resource Locator)は、リソースへの到達方法まで教えてくれるURIです。スキームやホストのような位置特定メカニズムを持ちます。URN(Uniform Resource Name)は、位置に依存しない永続的な方法でリソースに名前を付けるURIです。すべてのURLはURIであり、すべてのURNもURIであり、URNはURLではありません。
URI ─┬─ URL: https://example.com/spec.html
└─ URN: urn:ietf:rfc:3986
汎用構文 (RFC 3986)
すべてのURIは5つのコンポーネントに分解されます。
https://[email protected]:8443/v2/items?sort=asc#top
\___/ \__________________________/\_______/ \______/ \_/
scheme authority path query fragment
- scheme — 大文字小文字を区別せず、正規形は小文字。残りの解釈方法を定義します。
- authority — 任意のuserinfo、ホスト(登録名、IPv4、
[::1]のような角括弧IPv6)、任意のポート。user:password@形式は資格情報用途としては非推奨で、主にフィッシングとSSRFペイロードの中で生き残っています。 - path — 階層的。
.と..セグメントは正規化中に解決されます。 - query — 慣習としてはアンパサンドで繋いだkey=valueペアですが、RFC 3986は構造を一切強制しません。
- fragment — サーバーには送信されません。クライアント専用であり、
#以降のSPAルートがアクセスログに現れない理由です。
パーセントエンコーディングはコンポーネントごと
最も誤解されている事実: 「エンコードすべき文字」の単一の集合は存在しません。各コンポーネントに固有の予約集合があります。リテラルの ? はパスでは区切り文字ですが、クエリの中では完全に合法なデータです。& はパスセグメントではデータ文字ですが、クエリでは区切り文字です。URL全体に encodeURIComponent を一括で掛けるとURLが壊れる理由であり、正しい実践が組み立てながら各動的部分をエンコードすることである理由です。
const url = new URL('https://api.example.com/search');
url.searchParams.set('q', 'a&b=c?');
url.toString(); // https://api.example.com/search?q=a%26b%3Dc%3F
スペースの2つの綴りも覚えておきましょう。%20 はRFC 3986のエンコーディングで、+ は application/x-www-form-urlencoded データの中でのみスペースを意味します。パス内の + をスペースとしてデコードするのはバグです。
URN: 住所のない名前
URN(RFC 8141)は登録された名前空間の中に存在し、解決可能性ではなく永続性を約束します。
urn:isbn:9780134685991
urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6
urn:ietf:rfc:3986
ISBNはその本を永遠に指し示します。実物を見つけるのは別の解決ステップです。実務ではURNはXML名前空間、UUIDのシリアライズ、標準文書に登場します。内面化すべきパターン: ホスティングが移転しても決して壊れてはならない識別子が必要なら、URNスタイルの名前(あるいは位置に依存しない任意のID)がURLに勝ります。
2つの仕様、1つのWeb
多くの記事が飛ばす部分です。現代のブラウザとJavaScriptはRFC 3986を実装していません。ブラウザの実際の挙動に合わせて書かれたリビングスペックであるWHATWG URL Standardを実装しています。問題になる違い:
- WHATWGのパースはより寛容です。特別なスキームでバックスラッシュをスラッシュとして受け入れ(
https:\\example.comがパースされる)、タブと改行文字を除去し、ホストを小文字化します。 new URL(relative)はbase引数なしでは例外を投げます。WHATWGパーサーには独立した相対参照の概念がない一方、RFC 3986はそれに丸ごと一節を割いています。- バリデーター、プロキシ、古いサーバーフレームワークはRFC 3986に従う傾向があり、同じURLがホップごとに異なって判定され得ます。
この不一致は悪用可能です。URLパーサーの混同は繰り返されるSSRFの根本原因です。あるパーサーを使うバリデーターが https://trusted.com\@evil.com/ を承認し、別のパーサーを使うHTTPクライアントがリクエストをevil.comに送ります。防御はアーキテクチャレベルで行います。一度だけパースし、パース済みコンポーネントを検証し(スキーム許可リスト、ホスト許可リスト)、リクエストを行うコードには生の文字列ではなくパース済みオブジェクトを渡すこと。
実践ルール
- 組み立てにもパースにも、文字列連結ではなく
URLとURLSearchParams(または言語の等価物)を使う。 - 比較の前に正規化: スキームとホストの小文字化、ドットセグメントの解決、パーセントエンコーディング16進数の大文字化、デフォルトポートの除去。
/blogと/blog/は別リソースとして扱い、正規形を一つ決めて他方を301に。- URL全体を一括デコードしてから再分割しない。先に分割し、各コンポーネントをデコードする。
国際化された識別子
RFC 3986はASCIIのみを許可するため、非ASCII識別子はその上に層として載ります。IRI(RFC 3987)はUnicodeを直接許可します。IRIをURIに変換すると、パスとクエリはパーセントエンコードされ、ホストはPunycodeでエンコードされます。bücher.example はIDNAを経て xn--bcher-kva.example になります。2つの運用上の帰結が続きます。第一に、同じ論理アドレスが複数のエンコーディングを持つため、比較は正規化後に行う必要があります。第二に、ホモグラフ攻撃: キリル文字の а はラテン文字の a と見た目が同一で、аpple.com が apple.com になりすませます。ブラウザは混合スクリプトのホストをPunycodeで表示して緩和しており、ユーザー提供のリンクを表示するあらゆるシステムは、字形を信用せず同じポリシーを適用すべきです。
厄介なコンポーネントエンコーディングは sdk.is/url-encoder のURLエンコーダーで実験してみてください。