URL APIは読んで書き戻すと別物になる
https://example.com/a%2FbをURL APIでパースして、取り出した構成要素をそのまま組み立て直す。返ってくるのはhttps://example.com/a/bだ。
1本だったパスセグメントが2本になっている。サーバーから見れば別のリクエストになる。
URLのゲッターとセッターは同じプロパティ名で並んでいるので、読んだものをそのまま書けば元に戻ると思ってしまう。戻らない。URLを分解して行ごとに編集して組み立て直すパーサを書いたら、非対称が6か所出てきた。全部Node 24で実測したものを載せる。
読む側はデコードするしかない
まず前提として、ゲッターはエンコードされたまま返す。
new URL('https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E').pathname
// '/%E6%97%A5%E6%9C%AC%E8%AA%9E'
これを画面に出しても読めないので、decodeURIComponentを通すことになる。pathnameもhashもusernameも同じ。デコードは呼ぶ側の仕事で、URL APIは一切面倒を見ない。
そしてこのデコードが、以降の非対称の入口になる。
セッターは%をエンコードしない
セッターのほうはエンコードしてくれる。
const u = new URL('https://example.com/')
u.pathname = '/日本語'
u.href // 'https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E'
だから非ASCIIは往復で戻る。デコードして読ませて、編集されなければそのまま代入し直せば同じURLになる。
戻らないのは%だ。セッターは%をエンコードしない。%2Fはデコードすると/になり、/はパスの区切りなのでセッターもエンコードしない。エスケープされていた区切り文字が、素の区切りに戻る。
const u = new URL('https://example.com/')
u.pathname = '/a%2Fb' // 代入時に % は素通し
u.pathname // '/a%2Fb'
u.pathname = '/a/b' // デコードした結果を入れるとこうなる
u.pathname // '/a/b'
どこまで戻らないのかを数えた。ASCII 128文字を/a%XXbの形でパスに1つずつ置いて、デコード→再代入の往復を通す。
| 往復の結果 | 件数 |
|---|---|
| 入力と同じ文字列に戻る | 40 |
| 文字列は変わるが、区切りもデコード結果も同じ | 82 |
| 区切りかデコード結果が変わる | 6 |
82件は%41がAになる類の正規化で、意味は変わらない。ただし生のパス文字列で署名を検証するような相手には別物として届くので、無害と言い切れるかは受け手による。
意味が変わる6件はこれ。
| 入力のパス | 往復後 | 何が起きたか |
|---|---|---|
/a%09b | /ab | タブが捨てられる |
/a%0Ab | /ab | 改行が捨てられる |
/a%0Db | /ab | 復帰が捨てられる |
/a%25b | /a%b | %が素通しして、以後デコード不能になる |
/a%2Fb | /a/b | セグメントが1本から2本に増える |
/a%5Cb | /a/b | バックスラッシュが/に正規化されて2本に増える |
タブ・改行・復帰の3つはデコードでは消えない。セッターに渡した時点でURLパーサが捨てる(new URL('https://example.com/a\tb').pathnameも/abになる)。読むときは%09のまま見えているのに、書き戻すと存在ごと消える。
値なしキーはURLSearchParamsを通ると=が生える
クエリのほうにも同じ形の穴がある。?flagのような値なしキーを、URLSearchParamsは表現できない。
const u = new URL('https://example.com/?flag&x=1')
u.searchParams.get('flag') // ''
[...u.searchParams] // [['flag', ''], ['x', '1']]
u.searchParams.toString() // 'flag=&x=1'
?flagと?flag=が読んだ時点で同じ''になる。区別する口が無いので、書き戻すときはどちらかに倒すしかない。URLSearchParamsは=を付けるほうに倒している。
面白いのは、url.searchに直に書けば?flagが残ること。
const u = new URL('https://example.com/')
u.search = 'flag'
u.href // 'https://example.com/?flag'
u.searchParams.append('x', '1')
u.search // '?flag=&x=1'
searchは生の文字列を持っていて?flagのまま保つ。searchParams側を書き換えると、URLSearchParamsのシリアライズ結果がsearchへ書き戻される。パラメータを1つ足しただけで、触っていないflagまでflag=になる。
クエリの往復をいくつか並べるとこうなった。
| 入力 | 読んだ結果 | 書き戻し |
|---|---|---|
?flag | [['flag', '']] | ?flag= |
?q=a+b | [['q', 'a b']] | ?q=a+b |
?q=a%20b | [['q', 'a b']] | ?q=a+b |
?a=1& | [['a', '1']] | ?a=1 |
?&&a=1 | [['a', '1']] | ?a=1 |
?a=b%26c | [['a', 'b&c']] | ?a=b%26c |
%20が+になるのはURLSearchParamsがapplication/x-www-form-urlencodedのシリアライザだから。パスと違ってクエリでは空白を+で書くのが正で、そこは読み書きで揃っていない。クエリをdecodeURIComponentで自前デコードしている相手に渡すと、a+bという文字列として届く。
hashもパーセントエンコードで返る
hashは素の文字列が返ってきそうな顔をしているが、返らない。
new URL('https://example.com/#断片').hash // '#%E6%96%AD%E7%89%87'
fragment percent-encode setに非ASCIIが入っているので、日本語のアンカーはエンコードされる。表示用にはここもデコードが要る。そしてデコードすれば%2Fの話が再来する。
const u = new URL('https://example.com/#%2F')
u.hash // '#%2F'
decodeURIComponent(u.hash) // '#/'
// これを hash に書き戻すと '#/' のまま。'#%2F' には戻らない
ついでに、エンコードされる文字の集合は場所ごとに違う。同じ文字列を3つのセッターに入れると3通りの結果になる。
入力 : []{}|\^`"'<>␣ (␣は半角スペース)
pathname : /[]%7B%7D|/%5E%60%22'%3C%3E%20
hash : #[]{}|\^%60%22'%3C%3E%20
search : ?[]{}|\^`%22%27%3C%3E%20
{と}はpathnameだけがエンコードする。`はpathnameとhashがエンコードしてsearchはしない。'は逆にsearchだけ。|と[ ]はどこもエンコードしない。そしてpathnameの\は/に化けている。
区切り文字も揃っていない。a?bを入れるとpathnameは/a%3Fb、hashは#a?b、searchは?a?bになる。a#bならpathnameが/a%23b、searchも?a%23b、hashだけが#a#bのまま通す。1つのURLの中で3つの規則が同時に走っている。
punycodeの逆変換だけ口が無い
ホスト名は往復するけれど、読める形に戻せない。
new URL('https://日本語.jp/').hostname // 'xn--wgv71a119e.jp'
パスやクエリはdecodeURIComponentでいつでも読める形に戻せるのに、ホスト名にはそれに当たるものが無い。URL.prototypeが持っているのはhash host hostname href origin password pathname port protocol search searchParams usernameとtoJSON toStringだけで、ToUnicodeは無い。IntlにもIDNA関連は無い。URL Standardはxn--へ畳む側だけを規定していて、戻す側はブラウザのアドレスバーの実装詳細になっている。
Nodeにはnode:punycodeがあるが、DEP0040でdeprecated扱い。ブラウザには最初から無い。
なので画面にはxn--のまま出して、punycodeであることの注記を添えるところで止めた。判定はラベルのどれかがxn--で始まるかを見るだけで足りる。
decodeURIComponentは不正なパーセント列で落ちる
/100%は実在するパスだ。そしてdecodeURIComponentはこれで例外を投げる。
new URL('https://example.com/100%').pathname // '/100%'
decodeURIComponent('/100%') // URIError: URI malformed
URL APIは通す。デコードだけが落ちる。%E3%81のような途中で切れたUTF-8も、%GGのような16進でない並びも同じくURIErrorになる。
表示のためのデコードが失敗したところで、表示を止める理由にはならない。落ちたら元の文字列を素通しさせる。
function decodeForReading(value) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
ただしこれは前述の%25の行と地続きで、素通しした%はそのまま書き戻される。読めなかったものは元の形も保てない。
不透明パスは読めるのに書けない
mailto:やtel:のような不透明パスのURLもnew URL()は通す。
const u = new URL('mailto:[email protected]')
u.protocol // 'mailto:'
u.hostname // ''
u.pathname // '[email protected]'
u.origin // 'null'
protocolとhostnameから組み立て直す作りだと、ここから作れるのはmailto://で、まったくの別物になる。
もっと厄介なのは、不透明パスのURLではセッターが黙って効かないこと。
const u = new URL('mailto:[email protected]')
u.pathname = '/x'
u.hostname = 'example.org'
u.href // 'mailto:[email protected]'
例外も投げないし、戻り値も無い。代入したはずの値がどこにも入っていない。tel: data: javascript: urn:も同じで、blob:https://example.com/uuidに至ってはoriginがhttps://example.comを返すのにhostnameは空文字だった。
なのでパーサ側はhostnameが空文字ならその時点でnullを返すことにした。読めるが書けないものを途中まで通すと、編集して保存したのに何も変わらないという、一番原因の分かりにくい壊れ方をする。
まとめ
出てきた非対称を並べるとこうなる。
| 場所 | 読むと | 書き戻すと |
|---|---|---|
パスの%2F %5C | /にデコードされる | 素の区切りとして出る |
パスの%09 %0A %0D | %09などのまま見える | 代入時に捨てられる |
クエリの?flag | '' | ?flag= |
クエリの%20 | 空白 | + |
hashの非ASCII | %E6%96%AD%E7%89%87 | 同じ形に戻る |
| ホスト名の日本語 | xn--のまま | 戻す口が無い |
mailto:など | 各要素は読める | セッターが無反応 |
URL APIは「URLを解釈して正規化する」ためのAPIであって、「URLを分解して組み立て直す」ためのAPIではない、ということだと思う。正規化が仕事なので、入力の表記ゆれを潰すのはむしろ正しい振る舞いだ。
だから、表示用にデコードした時点で元のURLへ戻る保証は消える。戻す必要があるなら、デコードした文字列とは別に元のURLを持っておくしかない。編集されなかった行は元の文字列をそのまま使う、という作りにできるならそのほうが安全だと思う。