15
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

fukuoka.ex Elixir/PhoenixAdvent Calendar 2020

Day 12

Elixir の OSS を読んでみよう ~ Plug.CSRFProtection を例に ~

Last updated at Posted at 2020-12-12

この記事は、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上のコードにジャンプできるので大変便利・・・!
image.png

ということでコードを読んでみます。
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

image.png

コードはこちら。ドキュメント込みで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 に定義されてそうでした
image.png

  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 という関数の中で登録されたコールバック関数をひたすら適用してそうです
image.png

  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

どこで発行されるか

次にどのタイミングで発行されるかですが、 一旦ドキュメントを読んでみましょう
コード追った後に読んでみると理解しやすいですね!

image.png

どうも 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.getProcess.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 を呼ばないといけないですね!

確認してみると確かにされてそうでした
image.png

まとめ

今回読んだOSSは結構難しい方でしたが、もう少し簡単なものはいくつもあるので(Enumモジュールとか)、ぜひ自分が使っているライブラリの細かい挙動が気になったらドキュメントを読むだけでなくコードを読んでみてもいかがでしょうか?
またOSSのコードは大抵自分よりすごい人が書いていることが多いので、そこから学ぶことも多いです
自分自身、仕事でコードを読むことで成長できた面があるので、自分の成長という意味でもオススメです

あとは単にこれどうやって動いてるんだろう・・・?というのを解き明かしていくのは楽しいですね
謎解きみたいな感覚になります笑

Elixirは他の言語に比べると読みやすい方かなと思うので、上級アルケミストを目指す人はぜひチャレンジしてみてください!

明日13日目はわれらが @piacere_ex さんの「プログラミングElixir第二版でアップデートされた内容について」です!
僕も購入しましたがまだ読みきれてないので、気になりますね・・・!笑

15
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?