サーバーを自作するためにHTTPの仕様書とNginxの実装を確認した。
まずはhttp仕様を確認する。
- https://datatracker.ietf.org/doc/html/rfc9110
- https://datatracker.ietf.org/doc/html/rfc9112
HTTPの仕様はこれまで何回か更新されているようで、2023/09/10時点ではRFC9110から始まる一連のRFCが最新の仕様のようだ。
2022年に大きな再編があったようで、各RFC間の関係はこのブログで取り上げられている。今回はHTTP/1.1の
request-lineの仕様を確認したいだけなのでおそらく上記2つだけで大丈夫そう。Request Lineとは
HTTPリクエストの最初の行。ブラウザの開発者コンソールなどでよく見かけるやつ。
大前提としてパーサーは送られてきたデータをascii文字列として処理する必要がある。
request-line = method SP request-target SP HTTP-version
method, request-target, HTTP-versionの3つのパートで構成されている。
ここでSPは空白1つを表す。
仕様書によればタブなどの空白文字もSPとして扱って良さそうだが脆弱性になる可能性もあるからMAY程度の弱い記載になっている。
ざっとNginxのtokenize処理を見た限り単一の空白文字しか対応していなさそうだ。
request_lineの長さは仕様上定義されておらず、場合によっては非常に長くなる可能性がある。
ただし最低限8000オクテット以上はサポートするように推奨されている。
利用可能な文字はRFC9110のAppendixを見る限り以下の通り。
methodのBNFを読み解いていくと1文字以上のtchar = 以下の文字列であることが確認できる。
"!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
"^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
自前で実装するときに忘れそうなので、細かい仕様をメモしておく。
- 実装されているメソッドより長いメソッドを受信した場合501(Not Implemented)を返す。
- 渡されたrequset-targetが長過ぎる場合には414(URI Too Long)を返す必要がある。
Method
GETとかPOSTとか。RFC9112の9章に主に記載がある。
メソッドは大文字で指定する必要がある。
GETとHEADはマストで、後はオプショナルらしい。
RFC9110内で定義されているMethodは以下8通りだが、
HTTP Method Registryという外部の資料で拡張されているようだ。
実際にNginxのパーサーは16種類のメソッドに対応している。HTTP1.1の仕様書セクション9に記載のあるメソッド(順不同)
GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE
NGINXがサポートしているメソッド(順不同)
GET, PUT, POST, COPY, MOVE, LOCK, HEAD, MKCOL, PATCH, TRACE, DELETE, UNLOCK,
OPTIONS, CONNECT, PROPFIND, PROPPATCH
Request Target
request-targetはどのリソースに対して操作を適用するかを指定する。
以下のように4種類の形式が存在している。
request-target = origin-form
/ absolute-form
/ authority-form
/ asterisk-form
- origin-form: リソースへの絶対パス + クエリ(日常的に使っているやつ)。
- absolute-form: リソースへのURL。プロキシサーバーとして使うときに使う。
- authority-form: CONNECTでのみ使われる。
ホスト名:ポート番号という形式。 - asterisk-form: OPTIONSでのみ使われる。
*という形式。
日常のWeb開発だとorigin-form以外の通信を直接見る機会はほとんど無い気がする。
読んでいて大事そうだったのはrequest-targetにはwhitespaceを使うことが許可されていないこと。
このような無効なリクエストを受け取った場合には400か301を返すべき。
セキュリティ上の理由から自動的に修正することは推奨されていない。
パースには関係ないがHostヘッダに関する記載がこの章に何点かある。
よく存在意義がわかってなくて別に無くてもよくない?と考えていたが仕様上マストな存在のようだ。
おそらく何かしら重要な役割を発揮していると思うが、まだ重要性を理解できていない。
その他仕様に関するメモ。
- クライアントはHTTP1.1でリクエストを送る際にはHostヘッダを指定する必要がある。
- サーバーはHostヘッダが欠落したHTTP1.1リクエストには400(Bad Request)を返す必要がある。
HTTP Version
2.3節に記載がある。特に書くことが無い。
プロキシする際の注意点や、HTTP/1.0へのダウングレードについて記載があるが、
リクエストのパースという観点で読む必要がある内容ではなさそう。
HTTP-version = HTTP-name "/" DIGIT "." DIGIT
HTTP-name = %s"HTTP"
NginxにおけるRequset Lineのパース処理
Request Lineのパースは
ngx_http_parse.cの中にあるngx_http_parse_request_lineで処理されている。nginx/src/http/ngx_http_parse.c at master · nginx/nginx
github.com
この関数の呼び出し元は
ngx_http_process_request_lineという関数で、パースした結果の後処理や
次の状態(ヘッダのパース)への遷移はこちらで行われている。
request-lineのパースなんてwhitespaceで区切ってそれぞれの値が妥当化どうか検証するだけでは?
という高級言語脳だったが、ちゃんと1文字readして場合訳している。
入力がストリームでRequestLineすべての情報が与えられるとは限らないので、
途中まで処理した結果を保存しておいて再開できるようにしておく必要がある。基本的にはbufferの値を1文字ずつループして読んでいく。
今何のパースしているかを表す変数を用意しておいて、その値と読み込んだ文字によってクソデカ場合訳をしていく。
for分でひたすら先頭から順番に読んでいく。
今のパース状態を保持する変数stateが存在して、その値によってどのような挙動をするかが変化する。
パースの過程では各パーツが何オクテットに始まって、何オクテット目で終わるのかだけを記録していくような実装になっている。
各状態毎に何やっているかの簡単なメモを作成したが、無駄に長い+コード見ればわかるので割愛。
おわりに
仕様書を読むのは大変。
request-lineのパース処理を把握するだけで疲れた。