この記事は、fukuoka.ex Elixir/Phoenix Advent Calendar 2020 12日目です。
前日は、 @tomoaki-kimura さんの 細かい話は置いといて、とりあえず触ってみたい人のための Elixir でした
OSS を読む
月1で Elixir Digitalization Implementors というElixirの情報交換やつくったものの発表を行うイベントがあるんですが、その中で「ElixirのOSSは実は読みやすい」という話を小耳に挟みました
最近仕事では10万コミットを超える巨大なPerlコードを読み解いて運営・改修する仕事をしているので、コードリーディング能力がどれくらい伸びたかを試してみよう!というモチベーションもありました
ということでこの記事では実際に読んでみます!
Elixir のライブラリとして有名なものとしてRailsライクなWebフレームワークのPhoenixがあると思いますが、この中で頻繁に使われている Plug というモジュールがあります
詳しくは Elixir School の記事がわかりやすいかと
https://elixirschool.com/ja/lessons/specifics/plug/
Plugモジュールはさらにいろんなモジュールで構成されているので、その中の何読もうかなーと思ったんですが、せっかく読むしちょっと難しいのにしようと思ったので、Phoenixのボイラープレートの中にデフォルトで定義されている :protect_from_forgery
(CSRFトークンの発行と検証) を読んでみます
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
end
ちなみに読み方のコツ
読む前に、読み方のコツを書いておきます
コツは基本的にいろんなファイルを横断することになるので、検索しにくい github 上で読まずに、手元にcloneしてみなさんが普段使い慣れているエディタ上で読むことをオススメします
すでに mix deps.get などでダウンロードしたことあるライブラリであれば、 clone せずに deps ディレクトリ以下の対象ライブラリを直接エディタで開くという手もあります笑
僕は普段VsCodeの高速な全検索機能にお世話になっているので、こちらをフル活用していきます!
読んでみる
まず :protect_from_forgery
のドキュメントを読みます
https://hexdocs.pm/phoenix/Phoenix.Controller.html#protect_from_forgery/2
CSRF攻撃を防ぐplugっぽいですね。 Plug.CSRFProtection
のラッパーとのこと
ちなみにCSRF攻撃の説明はこちらです。
https://ja.wikipedia.org/wiki/%E3%82%AF%E3%83%AD%E3%82%B9%E3%82%B5%E3%82%A4%E3%83%88%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%83%95%E3%82%A9%E3%83%BC%E3%82%B8%E3%82%A7%E3%83%AA
Elixirはドキュメントが充実しているので大変良いですね。右上の </>
マークをクリックするとgithub上のコードにジャンプできるので大変便利・・・!
ということでコードを読んでみます。
https://github.com/phoenixframework/phoenix/blob/e05af0ad5527c9a192de122f3e43d7ed4c7a4c9f/lib/phoenix/controller.ex#L1082-L1084
def protect_from_forgery(conn, opts \\ []) do
Plug.CSRFProtection.call(conn, Plug.CSRFProtection.init(opts))
end
本当にそのままですね笑
ということで次に Plug.CSRFProtection
を読みます
https://hexdocs.pm/plug/Plug.CSRFProtection.html
コードはこちら。ドキュメント込みで450行程度(実装は300行くらい)と意外とコンパクトですね。
https://github.com/elixir-plug/plug/blob/v1.11.0/lib/plug/csrf_protection.ex#L1
Plug部分を読む
Plug は必ずビヘイビアが要求する init/1 と call/2 の関数を満たすので、そこから追っていきます。
https://github.com/elixir-plug/plug/blob/692655393a090fbae544f5cd10255d4d600e7bb0/lib/plug/csrf_protection.ex#L289-L317
def init(opts) do
session_key = Keyword.get(opts, :session_key, "_csrf_token")
mode = Keyword.get(opts, :with, :exception)
allow_hosts = Keyword.get(opts, :allow_hosts, [])
{session_key, mode, allow_hosts}
end
def call(conn, {session_key, mode, allow_hosts}) do
csrf_token = dump_state_from_session(get_session(conn, session_key))
load_state(conn.secret_key_base, csrf_token)
conn =
cond do
verified_request?(conn, csrf_token, allow_hosts) ->
conn
mode == :clear_session ->
conn |> configure_session(ignore: true) |> clear_session()
mode == :exception ->
raise InvalidCSRFTokenError
true ->
raise ArgumentError,
"option :with should be one of :exception or :clear_session, got #{inspect(mode)}"
end
register_before_send(conn, &ensure_same_origin_and_csrf_token!(&1, session_key, csrf_token))
end
init/1 をみると色々オプションあるみたいですね。ドキュメントに書いてるものと一致してそうです。
call/2 をみるとざっくりセッションからcsrf_tokenを読み出して、validateしてから register_before_send
してそうです。
validate の定義は以下です。 @unprotected_methods
に定義されたメソッドじゃないとそもそも必ずtrueになるのでそれ以上の判定をしないようですね(CSRF攻撃の性質からこれは妥当な対応です)
@unprotected_methods ~w(HEAD GET OPTIONS)
...
defp verified_request?(conn, csrf_token, allow_hosts) do
conn.method in @unprotected_methods ||
valid_csrf_token?(conn, csrf_token, body_csrf_token(conn), allow_hosts) ||
valid_csrf_token?(conn, csrf_token, header_csrf_token(conn), allow_hosts) ||
skip_csrf_protection?(conn)
end
疑問を設定してみる
あとはこんな感じでエディタ検索を駆使すれば大体の概要は読めそうです(細かい挙動を追うのは大変ですが、概要理解できるだけでも十分価値があります)
実際にOSS読みたい場合は知りたい挙動があることが多いので、今回も疑問を設定してみます
今回は**「どのタイミングでどこにtokenは記録されるのか?」**を明らかにしてみます
先ほどのコードリーディングではtokenの検証はしてそうでしたが、発行はしてなさそうでした
ということで深掘りしてみます。
どのタイミングで記録するか
まず発行したtokenをどのタイミングで記録しているかを調べましょう
register_before_send(conn, &ensure_same_origin_and_csrf_token!(&1, session_key, csrf_token))
が怪しそうです。 register_before_send
を全検索してみると、これは大元の Plug.Conn
に定義されてそうでした
def register_before_send(conn, callback)
def register_before_send(%Conn{state: state}, _callback)
when not (state in @unsent) do
raise AlreadySentError
end
def register_before_send(%Conn{before_send: before_send} = conn, callback)
when is_function(callback, 1) do
%{conn | before_send: [callback | before_send]}
end
どうもこれを呼び出すと、 conn
(Plug.Conn型の変数)の :before_send
キーにコールバック関数を登録してくれてそうです。
みた感じ色んなWebフレームワークでおなじみの end_hook
みたいなイメージですね
もう少し追ってみると run_before_send
という関数の中で登録されたコールバック関数をひたすら適用してそうです
defp run_before_send(%Conn{before_send: before_send} = conn, new) do
conn = Enum.reduce(before_send, %{conn | state: new}, & &1.(&2))
if conn.state != new do
raise ArgumentError, "cannot send/change response from run_before_send callback"
end
%{conn | resp_headers: merge_headers(conn.resp_headers, conn.resp_cookies)}
end
run_before_send
は色んなところで呼ばれてますが、分かりやすいのが send_resp
するときに呼ばれているところですね
やはり予想通り end_hook っぽい挙動のようです
こういう書き方あるんですね・・・!初めて知りました
自作Plug作るときに役立ちそうです
@spec send_resp(t) :: t | no_return
def send_resp(conn)
def send_resp(%Conn{state: :unset}) do
raise ArgumentError, "cannot send a response that was not set"
end
def send_resp(%Conn{adapter: {adapter, payload}, state: :set, owner: owner} = conn) do
conn = run_before_send(conn, :set)
{:ok, body, payload} =
adapter.send_resp(payload, conn.status, conn.resp_headers, conn.resp_body)
send(owner, @already_sent)
%{conn | adapter: {adapter, payload}, resp_body: body, state: :sent}
end
def send_resp(%Conn{}) do
raise AlreadySentError
end
そして register_before_send
で登録された ensure_same_origin_and_csrf_token
をみてみるとこんな感じです
あればセッションに記録するけど、そうでなければスルーみたいな挙動になってますね
defp ensure_csrf_token(conn, session_key, csrf_token) do
Process.delete(:plug_masked_csrf_token)
case Process.delete(:plug_unmasked_csrf_token) do
^csrf_token -> conn
nil -> conn
:delete -> delete_session(conn, session_key)
current -> put_session(conn, session_key, current)
end
end
どこで発行されるか
次にどのタイミングで発行されるかですが、 一旦ドキュメントを読んでみましょう
コード追った後に読んでみると理解しやすいですね!
どうも Plug.CSRFProtection が自動で生成してくれるわけではなく、 get_csrf_token
を呼んで、なかったら自動生成しますよ、という感じみたいですね
コードみてみると確かにそんな感じっぽい
def get_csrf_token do
if token = Process.get(:plug_masked_csrf_token) do
token
else
token = mask(unmasked_csrf_token())
Process.put(:plug_masked_csrf_token, token)
token
end
end
※このように疑問点はドキュメントだけでなくコードリーディングを組み合わせることで理解が深まるのでおすすめです!
tokenってどこに保存するんだろう?(DB?)と思ってましたがここで Process
モジュール使ってるんですね!
https://hexdocs.pm/elixir/Process.html
こういう簡単なデータ保存に使えるとはあまり思っていなかったので、新しい発見でした
Process.get
や Process.put
便利ですね
乱用するとグローバル変数の嵐になりそうですが、簡単にredisみたいな使い方したいだけなら良さそうです
ただ expire の設定とかはできなさそうなので、実際に検討する場合は要件に応じて、という感じになりそうですね
おまけ もうちょっと深追い
get_csrf_token
を呼んでる箇所どこかなーと探してみましたが、これは結構見つけるの大変でした笑
deps以下全検索してみたら PhoenixHTML
モジュールがやってそうです( get_csrf_token_for
は内部で get_csrf_token
を呼んでます)
これを使うことで、フォームに自動で埋め込んでくれるみたいですね
def form_tag(action, opts \\ [])
def form_tag(action, do: block) do
form_tag(action, [], do: block)
end
def form_tag(action, opts) when is_list(opts) do
{:safe, method} = html_escape(Keyword.get(opts, :method, "post"))
{extra, opts} =
case method do
"get" ->
{"", opts}
"post" ->
csrf_token_tag(
action,
Keyword.put(opts, :method, "post"),
""
)
_ ->
csrf_token_tag(
action,
Keyword.put(opts, :method, "post"),
~s'<input name="#{@method_param}" type="hidden" value="#{method}">'
)
end
opts =
case Keyword.pop(opts, :multipart, false) do
{false, opts} -> opts
{true, opts} -> Keyword.put(opts, :enctype, "multipart/form-data")
end
html_escape([tag(:form, [action: action] ++ opts), raw(extra)])
end
...
defp csrf_token_tag(to, opts, extra) do
case Keyword.pop(opts, :csrf_token, true) do
{csrf_token, opts} when is_binary(csrf_token) ->
{extra <> ~s'<input name="#{@csrf_param}" type="hidden" value="#{csrf_token}">', opts}
{true, opts} ->
csrf_token = Plug.CSRFProtection.get_csrf_token_for(to)
{extra <> ~s'<input name="#{@csrf_param}" type="hidden" value="#{csrf_token}">', opts}
{false, opts} ->
{extra, opts}
end
end
逆にいうと、自前で HTML 書いたりしているけど、CSRF攻撃に対する対策したい場合は自分で get_csrf_token
を呼ばないといけないですね!
まとめ
今回読んだOSSは結構難しい方でしたが、もう少し簡単なものはいくつもあるので(Enumモジュールとか)、ぜひ自分が使っているライブラリの細かい挙動が気になったらドキュメントを読むだけでなくコードを読んでみてもいかがでしょうか?
またOSSのコードは大抵自分よりすごい人が書いていることが多いので、そこから学ぶことも多いです
自分自身、仕事でコードを読むことで成長できた面があるので、自分の成長という意味でもオススメです
あとは単にこれどうやって動いてるんだろう・・・?というのを解き明かしていくのは楽しいですね
謎解きみたいな感覚になります笑
Elixirは他の言語に比べると読みやすい方かなと思うので、上級アルケミストを目指す人はぜひチャレンジしてみてください!
明日13日目はわれらが @piacere_ex さんの「プログラミングElixir第二版でアップデートされた内容について」です!
僕も購入しましたがまだ読みきれてないので、気になりますね・・・!笑