net/http を使うときは Content-Type を指定しよう
Go 標準の net/http
パッケージはレスポンスに Content-Type
ヘッダが指定されていない場合、 User Agent 向けの MIME スニッフィングアルゴリズム (非公式日本語訳) を使って Content-Type
を 推測 して設定する。
この中途半端な "安全機構" によって HTML 文書 (text/html
) が text/plain
として配信されるケースがある。
これは前述したアルゴリズムのバイト列パターンが HTML のサブセットであるため、つまり正しい HTML 全てにマッチするものではないためである。
実際の利用でこれにぶち当たるケースはあまりないとは思う。
ただ、 Go の公式チュートリアルを教本として実装し、 template/html
を利用せずに静的な HTML を直接配信した場合起こる可能性がある。
解決策は Content-Type
を設定するという、非常にシンプルで簡単なもの。
しかし原因、特に根本原因というか "犯人" が非常にわかりづらい。(ネタバレ: 犯人は Go)
どうしてこうなっているのかを知るためにはサーバサイドとは関係のないブラウザ実装者向けの仕様書を読む必要がある。
net/http
のMIME スニッフィングに頼るということは、踏むかもしれない地雷と潜在的な脆弱性を蒔くことに等しい。
リスクとコストを天秤にかければ、net/http
を直接扱う場合1は必ず明示的に Content-Type
を設定するべきだ。
この地雷を踏んだ経緯
個人開発プロジェクトで イベントソーシング を採用するために Proof of Concept としてサンプルアプリケーションを書いていた。 Go を選んだ理由は外部ライブラリに頼らずサクッと書けることと、ある程度文法を知っていたこと。
Go でまともに web サーバを書いたことがない (もしくは覚えてないほど昔) のと、新しいパラダイム・設計に触れたいという理由から公式の web アプリケーションチュートリアル を "理想的な net/http
サーバの書き方" ガイドとして使うことにした。
PoC で使う HTML ファイルを go:embed
で埋め込みハンドラーでその文字列を返す。
go run .
で建ったサーバにアクセスして—正常に HTML の ソースコード が表示された。
HTML のソースコードがそのまま表示されるというのは稀によくある問題だ: Content-Type
が text/plain
になっているため HTML として解釈されないのだ。
net/http
は MIME を嗅ぎ回る
ここで考えるべき疑問は「何故 fmt.Fprint(w, html)
が text/plain
になるのか」ではなく「何故 fmt.Fprint(w, html)
が Content-Type
を設定するのか」だろう。
fmt.Fprint()
は汎用 IO 書き込み処理であり、ハンドラー内にはファイル拡張子や Content-Type
を指定するようなコードはない。
w
の実際の型である http.ResponseWriter
インターフェイスは Writer
インターフェイスに準拠する Write([]byte)
メソッド を実装しており、これが fmt.Fprint()
といった関数内で呼ばれる。
そして http.ResponseWriter.Write()
メソッドのコメントにはこう書いてある:
// ...
// WriteHeader(http.StatusOK) before writing the data. If the Header
// does not contain a Content-Type line, Write adds a Content-Type set
// to the result of passing the initial 512 bytes of written data to
// [DetectContentType]. Additionally, if the total size of all written
// ...
訳すと:
もし
Header
がContent-Type
を含んでいない場合、Write
は書き込まれるデータの先頭 512 バイトをDetectContentType
に渡し、その結果をContent-Type
に設定する
DetectContentType
のドキュメントにはこうある:
DetectContentType implements the algorithm described at https://mimesniff.spec.whatwg.org/ to determine the Content-Type of the given data. It considers at most the first 512 bytes of data. DetectContentType always returns a valid MIME type: if it cannot determine a more specific one, it returns "application/octet-stream".
DetectContentType は https://mimesniff.spec.whatwg.org/ で定義されているコンテンツのデータから Content-Type を決定するアルゴリズムの実装です。この関数はデータの先頭 512 バイトまでを参照します。 DetectContentType は必ず有効な MIME タイプを返します: もしもこの関数が適切な MIME タイプを選択できない場合は "application/octet-stream" を返します。
つまり http.ResponseWriter
は ContentType
が未設定の場合、ユーザエージェント向けの web コンテンツ用スニッフィングアルゴリズムを使って推測した値を設定していることになる。
そしてこのアルゴリズムが HTML を text/plain
として推測した、ということだ。
この時点での原因は俺の書いた HTML かアルゴリズムのどちらかだ。 ("問題点" は Go がこのアルゴリズムを適切でない場所で利用していることだが)
text/plain になる HTML
問題となった HTML の中身はこんな感じ:
<!--
(よくあるライセンスヘッダ)
SPDX-FileCopyrightText: ...
SPDX-License-Identifier: ...
-->
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PoC</title>
</head>
<body>
<h1>Top</h1>
</body>
</html>
初見で問題になりそうな箇所を挙げるとすれば勿論最初のコメントヘッダだろう。
実際にこのコメントブロックを取り除くとハンドラは text/html; charset=utf-8
を返す。
Go のバグトラッカーで "net/http" や "html comment" あたりで色々探した結果 golang/go#16275 を見つけた。
2 つ目の返信で HTML が仕様違反というものがあり、ありえないだろうと思いながら念の為 HTML の仕様を見返してみた。
やはり仕様には準拠しておりコメントの内容が完全に間違っていた。
XML やら古代のまじない言語やらは知らんが、 HTML4.1 も the Living Standard も <!DOCTYPE>
や <html>
の前後にコメントを許可している。
これで原因はアルゴリズムで確定となった。
whatwg/mimesniff — MIME スニッフィングの web 標準
前述した Issue の最後 2 つのコメントは whatwg/mimesniff という net/http
が利用している MIME スニッフィングについてのものとなる。
whatwg/mimesniff の仕様を読んでいる最中におかしいものに気がついた。 開始タグの区切りを示す "tag-terminating byte" の定義が、
any one of the following bytes: 0x20 (SP), 0x3E (">").
0x20 (半角スペース), 0x3E (">") のどちらか
となっているのだ。 つまり最初の要素の開始タグやコメントの開始マーカーの直後に改行があると、この web 標準アルゴリズムは HTML 文書を text/plain と判断する、ということになる。 件の HTML 文書の最初のコメントは開始マーカーの直後に改行があるため、これによって "HTMLではない" と判断されたのだ。
前述した Issue を作成した人が Go チームからの返信をうけてこの仕様の変更要望を投げている。 そもそも仕様の意図した利用方法ではないため当然のようにクローズされている。 HTML のスニッフィングは実際殆ど利用されていないようなので余計だろう。
解決方法
今回はコメントヘッダを <!DOCTYPE>
行の後に移して "対処" とした。
この一連のリサーチをしたのは一通り終わった後だからだ。
当たり前だが適切な対処は (net/http
が正しく推測するかにかかわらず) Content-Type
を明示的に設定することであり、 net/http
が推測できるコンテンツに変えることではない。
今回の HTML の件だけを見ると許容できるエッジケースに思えるかもしれないが、これは Go の net/http
の根本的な設計欠陥であるため個人的には MIME スニッフィングを避けるのを強く推奨する。
- HTML に限らずスニッフィングのアルゴリズムは全ての正しいファイルを識別できるわけではない
- もしも将来標準仕様が変わった場合、現在正しく推測されている
Content-Type
から意図せぬ間違ったものに変わる可能性がある - ユーザアップロードなどのユーザ入力がある場合、潜在的な脆弱性となりえる
最後の点は今回色々調べるうちに知ったことだ。
実際に Caddy がこの net/http
の "親切設計" による脆弱性を修正した Issue が非常に参考になる。
- Disable Go's default MIME sniffing behavior · Issue #2629 · caddyserver/caddy
- Add option to enable MIME sniffing · Issue #6843 · caddyserver/caddy
所感や教訓、知ったこと
- Go の標準ライブラリは充実しているが、必ずしも品質が高いというわけではない。特に
net/http
は全体的にトラップが多い (MIME スニッフィング、書き込みタイミング、 etc.) 。 - チュートリアルを書くのは難しい。特に低レベルなプログラムの場合は冗長さは避けられないため構成でどうにかするしかない?
- Simple != Easy
- サーバサイドで MIME スニッフィング (コンテンツスニッフィング) はしてはならない。
- ユーザの入力を推測してはならない。拒否するかユーザに選ばせるべきである。
- 標準は正しく適切に使おう。濫用はダメ。ゼッタイ。
Content-Type: text/html
の場合ブラウザは MIME スニッフィングを行わない
Footnotes
-
Gin などのライブラリやフレームワークを使う場合はそれぞれ異なってくる。コンテンツを返すだけで勝手に
Content-Type
が設定されている場合はドキュメントなどを確認しよう。 ↩