/var/log/study

つまり雑記

golangのnet/urlでhttpのURLをパースする際の細かい話

この記事は富士通クラウドテクノロジーズアドベントカレンダー202220日目の記事です。

前日はaokuma さんの GitLab agent for Kubernetes を使った GitOps (Helm版) を試してみた でした。 CI/CDはそれ自体の難しさもありますが、ソースコードやその成果物に対してセキュアにアクセスするのが面倒な印象があります。 GitLab上でk8sにデプロイすることも一括で面倒を見てくれるとなると、より便利に安定して使える様になることを期待してしまいますね。

(余談ですが、ニフクラでは, DevOps with Gitlab と言うマネージドのGitLabサービスを提供しているので、興味があれば見て見てください。)

さて、本日は、URLとは?と言う話に触れつつ、golangのnet/urlの Parse 関数について記載しようと思います。

目次

前提

  • golang のバージョンは1.19 動作確認しています
  • URLの話をするので、MDNのURLとは? に目を通しておくと良いと思います
    • より詳しく知りたい場合は、RFC3986の和訳 を参考にすると良いでしょう
  • RFC的なURLの正しさ、の話はするつもりがないです
  • また、golangの実装がおかしい、と批判するつもりもないです。

TL;DR

golang の net/url のParse関数を利用する際は、net/url をよく読みましょう。きっと考えているよりもさまざまなことを実施してくれます。ユースケースに応じて、特に不正だと思えるURLのケースの振る舞いをよくチェックしましょう

背景

この記事を記載するに至った理由を少し記載します。

とあるシステムにhttpもしくはhttpsでアクセス可能なURLを登録したいのですが、そのシステムはURLを全体で1つの文字列として取り扱わず、要素毎に登録用のフィールドが分かれています。 そのシステムにURLを登録するツールをgolangで作りたかったので、適当な文字列を与えた時、その文字列をURLのフォーマットに従って、分解するしできる限りオリジナルな入力のまま扱う仕組みがgolangで欲しい、と言うのが今回の課題です。

ただ、これはあくまできっかけに過ぎず、色々と触った結果を踏まえて記述できればと考えています。

url.Parseは手広くパース成功と判定する

golangでURLを要素ごとに分解しようと思うと、ブログのタイトルにしているくらいなので、net/url のParseパッケージが利用できます。

package main

import "net/url"

func main() {
     parsed, err := url.Parse("http://user:pass@example.com:8080/path1/path2?key=value#fragment")
     if err != nil {
        fmt.Println(err)
     } else {
       fmt.Println("ok")
       fmt.Printf("Scheme: %s\n", parsed.Scheme)
       fmt.Printf("UserInfo: %s\n", parsed.UserInfo)
       fmt.Printf("Host: %s\n", parsed.Host)
       fmt.Printf("Path: %s\n", parsed.Path)
       fmt.Printf("RawQuery: %s\n", parsed.RawQuery)
       fmt.Printf("Fragment: %s\n", parsed.Fragment)
     }
}

いかがでしたでしょうか?

想定している課題によっては間違いではありませんが、前述した背景のようなケースで、url.Parseでパース出来ない文字列はURLではない、と考えていると問題点があります。まず、Parseのドキュメントを参照すると、以下の様に記載があります。

Parse parses a raw url into a URL structure. The url may be relative (a path, without a host) or absolute (starting with a scheme). Trying to parse a hostname and path without a scheme is invalid but may not necessarily return an error, due to parsing ambiguities.

と言うことで、

url.Parse("/hoge/fuga")

などはschemeやauthorityが無いケースは許容されることになります。

また、実際にPase関数のドキュメントの直下に、ParseRequestURIと言う関数がありますが、こちらは

ParseRequestURI parses a raw url into a URL structure. It assumes that url was received in an HTTP request, so the url is interpreted only as an absolute URI or an absolute path. The string url is assumed not to have a #fragment suffix. (Web browsers strip #fragment before sending the URL to a web server.)

なので、絶対URI絶対パスしか許容しない、と言うことになります。

if _, err := url.ParseRequestURI("/hoge/fuga"); err != nil {
   fmt.Println(err)
} else {
  fmt.Println("absolute: ok") # こちらになる
}
if _, err := url.ParseRequestURI("hoge/fuga"); err != nil {
   fmt.Println(err) # こちらになる
} else {
  fmt.Println("relative: ok")
}

どちらの関数も、特定のRFCに準拠している、と言う記載も無いので振る舞いに対しての意見は無いですが、schemeやauthorityが欠けていてもパースを成功と判定するので注意すべき点だと考えています。

urlはhttpだけではない

URLの表記はhttpやhttpsだけではありません。 例えば、chrome://settings であったり、gitlabのGlobal IDでgid://gitlab/Issue/123 のように利用されているケースもあります。 URLはその利用ケースに応じて、schemeもそうですが、URLで表現したいリソースを下支えする制約 (例えばIPv4IPv6, 将来的に出てくるそれ以外のプロトコルや、TCPUDPのポートナンバーの上限下限など) があるはずです。

と、言うところで、責務を考えれば当然ですが(そして他言語のライブラリでも同様ではあると思うのですが)、url.Parseではhttp向けではないURLも全く問題なくパースすることができますし、例えばTCPのポートナンバーの上限を超えた値をセットしたURLも問題なくパース成功とみなします。

url.Parse はパスのパーセントエンコーディングで振る舞いが変わる

少し話が変わりますが、httpもしくはhttpsでアクセス可能なURLというと、例えば、URL内に日本語がある時、その日本語はパーセントエンコーディングされるか、日本語のままか、と言うのは少し気になる点ではあります。 以下の2つのURLは実質同じURLで前者がパーセントエンコーディングなし、後者がパーセントエンコーディングありとなります。

  • http://example.com/ほげ/ふが
  • http://example.com/%E3%81%BB%E3%81%92/%E3%81%B5%E3%81%8C

上記の2URLでurl.Parseを動かすと、以下のような振る舞いになります。

if s, err := url.Parse("http://example.com/ほげ/ふが"); err != nil {
    fmt.Println(err)
} else {
    fmt.Println(s)               // http://example.com/%E3%81%BB%E3%81%92/%E3%81%B5%E3%81%8C
    fmt.Println(s.Path)          // /ほげ/ふが
    fmt.Println(s.RawPath)       // /ほげ/ふが
    fmt.Println(s.EscapedPath()) // /%E3%81%BB%E3%81%92/%E3%81%B5%E3%81%8C
}
if s, err := url.Parse("http://example.com/%E3%81%BB%E3%81%92/%E3%81%B5%E3%81%8C"); err != nil {
    fmt.Println(err)
} else {
    fmt.Println(s)               // http://example.com/%E3%81%BB%E3%81%92/%E3%81%B5%E3%81%8C
    fmt.Println(s.Path)          // /ほげ/ふが
    fmt.Println(s.RawPath)       //
    fmt.Println(s.EscapedPath()) // /%E3%81%BB%E3%81%92/%E3%81%B5%E3%81%8C
}

特に気にすべきは、パーセントエンコーディングされたURLを与えた時に、パース結果のPathと言うフィールドがデコード後の結果が入り、RawPathがなく、オリジナルなデータを確認するにはEscapedPathメソッドを呼ぶ必要がある点だと思います。些細な点ではあり、かつユースケースとして珍しいとは思いますが、url.Parseの結果だけでは元々与えられたURLのパスが、エンコーディング前の状態だったか、エンコーディング後の状態だったかを判別することができず、ParseしたURLともともとのURLの比較が必要になります。

punycode対応はしてくれない

前述した内容では、urlのパスなどにパーセントエンコーディングされた文字が入ってくるとデコードしてくれることは示しました。 URLに日本語を入れる、と言う観点で考えると、国際化ドメインがどうなるのか?というのが少し気になるのではないかと思います。

例として以下のURLはpunycodeで変換されている、されていないの違いで、実質的な意味は一緒のものだと思います。

  • http://たとえ.テスト
  • http://xn--r8j2b1a.xn--zckzah

エンコードされている文字列がデコードされる、と考えると、url.Parseを通すと、後者が前者に変換されるように思えますが、こちらは特に変換されず、また前者のURLからpunycode変換する機能はnet/urlでは提供されておらず、golang.org/x/net/idna などを利用することになります。

国際化ドメインの話は、URLの話は別だと考えると自然な結果かもしれません。

終わりに

golangnet/url のParse関数を使った際の話をつらつらと書きました。この記事を読んで、ちょっとした想定外を回避できる人が増えたらうれしいと思っています。

この記事は富士通クラウドテクノロジーズアドベントカレンダー202220日目の記事でした。 明日は、natsumo さんの、続・Scratchにデータベースが使える拡張ブロックを追加して活用してみた話です。 GUIで簡単にプログラムを組めるScratchにデータベースが使える拡張が入ると、例えば、Scratchでゲームを作りたい人にとってはできることの幅が広がってうれしいのではないか?と思うので気になりますね!