`String#==` は実行時間が最適化されているか?
昨日の記事で、 secure_compare
は String#==
とは異なり常に一定時間で処理が行われる、と書いた。
あらためて GitHub Developer から引用する。
Using a plain == operator is not advised. A method like secure_compare performs a "constant time" string comparison, which renders it safe from certain timing attacks against regular equality operators._1
しかし、本当に String#==
は実行時間が最適化されていて、入力次第で実行時間が異なるのだろうか?
String#==
のドキュメントには記述がなく、 Ruby 本体のソースを読もうにも上手く見当がつけられなかったので、愚直にベンチマークをとることにした。その結果が以下。
require 'benchmark' n = 1_000 chars = ['a'].cycle.take(n-1).join('') s1 = 'a' + chars s2 = 'b' + chars s3 = chars + 'b' t = 10_000 Benchmark.bm(12) do |x| x.report('at_the_front') do t.times { s1 == s2 } end x.report('at_the_end') do t.times { s1 == s3 } end end # user system total real # at_the_front 0.000444 0.000005 0.000449 ( 0.000442) # at_the_end 0.000990 0.000002 0.000992 ( 0.000991)
長さ1000の文字列について、先頭の文字が異なるケースと末尾の文字が異なるケースで比較している。それを10000回繰り返して、有意と言える差は出ている。わずかな差といえばわずかな差であるが、少なくとも表題の疑問には答えられているだろう。
Rack::Utils.secure_compare を読む
Hanami と Sinatra の CSRF 対策のコードで、いずれも Rack::Utils.secure_compare
という API を利用していることを発見した。何が secure なのだろうか? という思いがあり、読んでみた。
def secure_compare(a, b) return false unless a.bytesize == b.bytesize l = a.unpack("C*") r, i = 0, -1 b.each_byte { |v| r |= v ^ l[i += 1] } r == 0 end
https://github.com/rack/rack/blob/master/lib/rack/utils.rb#L371-L385
一瞥して、短いコードであるが、なかなか見慣れない API が目白押しである。以下で順に見ていく。
String#unpack で文字列をバイト配列に変換する
l = a.unpack("C*")
文字列の入った変数 a
を任意のフォーマットで解釈し、読み取った値を配列にして変数 l
に格納している。フォーマットとして、ここでは "C*"
を指定している。これは unsigned char
つまり1バイト文字を指定している。
マルチバイト文字に対してこれが実行されるとどうなるか? 答えは次のように先頭の1バイトだけを解釈することになる。要するに、'あ'
と'い'
が等価とみなされてしまうことになる。
'あ'.bytes # => [227, 129, 130] 'あ'.unpack('C') # => [227] 'い'.bytes # => [227, 129, 132] 'い'.unpack('C') # => [227]
とはいえ、secure_compare
はドキュメントによると、 HMAC 等であらかじめ固定長に符号化した上で呼び出すように指示されているので、シングルバイト文字のみを考慮すれば十分なのだろう。
String#unpack
と対になる API として、 Array#pack
がある。いずれも芯を掴めていない感じがあるが、ここでは深入りせずにまた別の機会に検討する。
String#each_byte でバイト配列をイテレートする
r, i = 0, -1 b.each_byte { |v| r |= v ^ l[i += 1] }
each_byte
は名前の通り、文字列を1バイトずつイテレートするメソッドである。ブロック内で、先ほど unpack した配列 l
と1バイトごとに演算している。
マルチバイト文字に対して each_byte
を呼び出すと、もちろん全バイトを解釈する。
'あ'.each_byte.to_a # => [227, 129, 130]
というわけで、やはりここではシングルバイト文字列しか受け取らないよう、はじめからデザインされているようである。そうでなければ、 each_byte
と unpack('C*')
の長さが変わってしまい、比較にならない。
ビット演算
# r, i = 0, -1 b.each_byte { |v| r |= v ^ l[i += 1] }
each_byte
のブロック内では二種類のビット演算が記述されている。右辺からみていくことにしよう。
XOR
# i = -1 # v は任意の正の整数, l は任意の正の整数の配列 v ^ l[i += 1]
演算子 ^
は、競技プログラミングでもお馴染みの排他的論理和、XORである。ここでは二つの文字列の先頭から1バイトずつ比較し、排他的論理和を取っている。
排他的論理和、といちいち書くと仰々しいが、ここでは「演算対象の8-bitが等しければ 0 を返し、等しくなければ正の整数を返す」と単純に捉えてしまってもいい。
OR
# r, i = 0, -1 # v は任意の正の整数, l は任意の正の整数の配列 r |= v ^ l[i += 1]
XORの演算を右辺で行いつつ、式全体としては左辺の変数 r
にフィードバックしている。その式を仲介している |=
とはなにか?
正確な名称を調べてみると、複合代入演算子、と呼ぶらしい。これは ||=
の形で見慣れているだろうから、それと同じイメージで構わない。つまり次のような対応関係となる。
a ||= b # a = a || b a |= b # a = a | b
|
はビット演算子 OR である。詳しく復習することはしないが、ここで期待される便利な性質として、「増加こそすれ、減少することはない」ことがある。
XOR 演算の結果を OR 演算で累積している。XORの「二つの値が等しければ 0 を返し、等しくなければ正の整数を返す」という性質と、ORの「増加こそすれ、減少することはない」という性質から、その結果を累積していく変数 r の特性は、
- r == 0 のとき、比較対象は等しい
- 0 < r のとき、比較対象は等しくない
とシンプルに表される。
なにがどう secure
なのか?
さて、もともとあった疑問に戻る。 secure_compare
と名付けられた根拠はなんだろう? 言い換えると、あえて String#==
ではなく、 secure_compare
を使う動機はなんだろう?
GitHub Developer にてヒントが示されていた。
Using a plain == operator is not advised. A method like secure_compare performs a "constant time" string comparison, which renders it safe from certain timing attacks against regular equality operators._1
"constant time" string comparison
とある。普通 constant time というと、実行時間が O(1) であることを意味すると思うが、上で見たコードからして、実行時間は文字列の長さに比例して O(N) であるはず。いったいどういう含意なのか?
で、こんな投稿を見つけた。
要するに、 secure_compare
は「常に文字列を末尾まで舐める」という意味で "constant time" だという。文字列を単純に比較するだけでは、文字列の途中で一致しない文字があることを見つけ次第 false を返すため、入力次第で実行時間が変わってしまう。
入力次第で実行時間が変わってしまうというのはまずい。攻撃者が実行時間を計測し、正しい値を推測できるようになってしまう。このことをタイミング攻撃と呼ぶらしい。実際、 Rack::Utils.secure_compare
のコメントにも、「必ず固定長の文字列を比較し、タイミング攻撃を防ぐこと」とある。
固定長の文字列が入力されるという前提に立てば、なるほど実行時間は一定となるはずである。つまり、トークンなどの秘匿したい情報を、検証に要する実行時間から推測できない、という意味で secure_compare
は「安全」であるということになる。
おわりに
上にて結論づけたため、蛇足にはなるが、アルゴリズムとしてもすっきりしていて学びになるコードであった。XOR と OR 演算の特性をうまく利用して、シンプルな整数計算で結果を導けるのは清々しい。
また二つの配列の要素を比べるにしても、要素数が等しいことを利用して、シンプルなイテレートでそれを実現できていて気持ちがいい。僕であればあるいは Array#zip
で二次元配列をいったん作ってからイテレートする、というような不器用なやり方をしかねない。
こういうエレガントな着想が湧くのが羨ましい。嫉みではなく、もっと知識と経験を身につけようと奮い立たせられた。
<form> タグが送信できる HTTP メソッドは GET と POST のみ
素の HTML form では GET と POST リクエストしか送信できない。PUT, PATCH, DELETE のような HTTP メソッドをリクエストするには、サーバーサイドで適当に解釈してあげる必要がある。
例えば Rails の場合、 form_with
ヘルパーで編集画面の form タグを生成すると、こうなっている。
<form action="/posts/1" accept-charset="UTF-8" method="post"> <input type="hidden" name="_method" value="patch" /> ... <input type="submit" valud="Submit" /> </form>
見ての通り、 form は method="post"
を指定している。そしてその内側で _method=patch
が指示されている。要するにこれは第一にPOSTとしてリクエストされた上で、サーバーサイドでPATCHリクエストとして解釈され、実行される。
思えば初学者の頃にも同じ発見をした気がする。しかしそれもすっかり忘れて form から直接 PUT を送信しようとしていた自分がいたのも事実。
フレームワークや JavaScript に頼っていて、こうした基本的な仕様をかえって意識しないようになっていたようである。まあ、調べればすぐにわかることではあるが、誤った先入観を持っていた自分に気づいてハッと目が覚めた体験として、記憶することにする。
Sinatra, Rails, Hanami それぞれの CSRF 対策の流儀
CSRF 対策がフレームワークでどう行われるかを読み比べていた。せっかくなので簡単にまとめることにする。
TL;DR
対応するクラス/モジュール | 不正時の挙動 | トークン | |
---|---|---|---|
Sinatra v2.0.8 | Rack::Protection::AuthenticityToken [source] | 403 | base64 |
Rails v6.0.3 | ActionController::RequestForgeryProtection [source] | exception (default) | base64 |
Hanami v1.3.3 | Hanami::Action::Protection [source] | reset_session & exception | hex(32) |
Sinatra
Sinatra の CSRF 対策は Rack::Protection::AuthenticityToken
が担当する。これは CSRF トークンを検証して、不正な場合は 403 を返すだけのシンプルな Rack ミドルウェアである。シンプルではあるが、過不足がなくて気持ちがいい。トークンの生成には SecureRandom.base64
を利用している。
Rails
Rails は ActionController::RequestForgeryProtection
モジュールがお馴染みの protect_from_forgery
を定義している。
以前はこれは ApplicationController
に書かれていたが、直近のバージョンで rails new
を実行しても protect_from_forgery
の記述は生成されない。調べてみると、 Rails 5.2 以降この宣言は ActionController::Base
に移動され、暗黙的に有効化されることになったようである1。
class ApplicationController < ActionController::Base # Rails 5.1 以前はこれを明示的に記述する必要があった。 protect_from_forgery :exception # Rails 5.2 以降はデフォルトで有効化されているため明記しなくてもよい。 # protect_from_forgery :exception end
:with
オプションで振る舞いを変えることができる。デフォルトでは上のスニペットの通り、 with: :exception
となっており、 CSRF を検知すると例外を投げる。
コントローラが API としてリクエストを捌く時は、 with: :null_session
を指定する。これは CSRF トークンの検証時にセッションオブジェクトをダミーに差し替える。つまりトークンの検証を実質スキップすることになる。
最後に、 with: :reset_session
というオプションもある。これは名前の通り、 CSRF を探知したときにセッションをリセットする。要するに、ログアウトを強制する。例外は投げないので、後段の before_action
でログイン画面へリダイレクトされるような処理が期待される。しかしこれは悪意のないユーザーからして見ると不親切な動作とも思われる。
トークンの生成には Sinatra と同様に SecureRandom.base64
を利用している。
Hanami
Hanami は Hanami::Action::Protection
がセッションを初期化し、例外を投げる。つまり Rails の protect_from_forgery
でいうところの、 :reset_session
と :exception
が合同したような動作となる。
律儀にセッションをリセットするのはなぜだろう? 二重に防御して、直感的にはより安全に思われるが、不正なリクエストを遮断するという目的に照らすと、例外を投げるだけで十分とも感じられる。
ともあれ、 Hanami の場合は Rails よりもユーザー側でオーバーライドしやすいデザインとなっているため、実際には必要な処理を定義し直して使うことになる。
トークンの生成には SecureRandom.hex(32)
が使われている。
おわりに
Sinatra はシンプルで必要最小限、 Rails は内部を意識せずに宣言的に使える API 、 Hanami はカスタマイズしやすいデザインと、おのおのの設計思想がよく現れていておもしろい。
Rails と Hanami は、デフォルトの設定を利用するのであれば、それぞれ例外を発生させる。しかし例外を投げるのであれば、きちんと例外処理も書いてあげないといけない。リクエストにパラメータが不足しているときに、 Internal Server Error を引き起こしてしまうのは本意ではあるまい。 Sinatra が実装している通り、 403 Forbidden を返すのが HTTP のシンタックスとしては理にかなっているはずだ。
無理して Docker を使わないこと
Rails のちょっとした機能を検証するとする。そんなとき、まずはなにからやるか?
今日の僕自身はと言うと、 Dockerfile を作るところから始めていた...。
改めていうまでもないことだが、自戒を込めて書く。愚の骨頂であった。ほんのちょっとした検証にわざわざコンテナを用意する理由は、はっきり言ってひとつもない。こうしたシンプルなユースケースのためにこそ、素の Rails の立ち上げの速さは光るというもの。それを捨ててコンテナに手を出す意味は何か?
それはつまり、手段の目的化。トレンディな技術を扱っている自分に酔っていたにすぎない。あれもこれもできる、というしょうもないエゴが、かえって障害となってしまっていることが明らかだ。認めるのは恥ずかしいが、このエゴこそを、僕は克服しなければならない。
先日、SPAに対する思いを整理するエントリを書いた。そこでは、流行の技術に踊らされていた自分を省みて、技術=手段ではなく機能=目的にフォーカスするべきである、と書いた。
それが、コンテナについて同じことを繰り返している。トレンドに振り回されて、意味なく回り道をしている。
この悪い癖はなんとか直したい。また繰り返すかもしれない、という不安から、反省を込めてこのエントリを書く。書くことで、失敗の記憶として定着させたい。
heroku でのビルドが突然壊れたのでイシューを提出した
いわゆる「何もしていないのに壊れた」という状況に陥った。
症状としては、 Hanami アプリのデプロイにおける rake assets:precompile
タスクの実行時に、 uninitialized constant Bundler
とはねつけられてしまう。スタックトレースによると ここ でコケている。
Ruby 向け buildpack のレポジトリ をみると、ちょうど今日(2020/06/23)に新バージョン(v216)がリリースされていた。もしかしてこのリリースで壊れたのでは? とあたりをつけて、次のようにv215を指定してデプロイしてみる。すると思った通りパスする。
% heroku buildpacks:set https://github.com/heroku/heroku-buildpack-ruby.git\#v215 % git push heroku master
buildpack 側でリリースに問題があるのが明らかに思われたので、イシューを提出した。なかなかないことと思うので、どう推移するか注視したい。
iOS で Safari の Cookie を無効化しているとリクエストが CSRF 扱いされる
iOS の設定で Safari の Cookie 機能を無効化することができる。
普段 Safari を使うことは多くないのでそう意識していなかったが、僕自身はこれを有効化していた。で、これを有効化していると、当然 Cookie を利用したセッション管理を利用できなくなり、見た目にはウェブアプリケーションの不具合と見えてしまう。
これは Apple のサポートページでも言及されている。
Cookie をブロックすると、一部の Web ページが機能しなくなることがあります。以下は一例です。
- 正しいユーザ名とパスワードを使ってもサイトにサインインできなくなる場合がある。
- Cookie が必要だというメッセージや、ブラウザの Cookie がオフになっているというメッセージが表示されることがある。
- サイトの一部の機能が働かなくなる場合がある。1
Rails の場合、セッションオブジェクトにはCSRFトークンも格納されている2。
つまり、 Cookie を無効化したブラウザで POST アクションを実行しようとすると、サーバーは CSRF を探知し、 ActionController::InvalidAuthenticityToken
を投げる3。クライアントには 500 が返される。
ActionController::InvalidAuthenticityToken
などというものはサーバーサイドの不具合ではなく、クライアントからの不正なリクエストに分類する方が適当に思われるから、このエラーはきちんと rescue してあげて、 403 を返してあげるのがベターだ。例えばこう。
class ApplicationController < ActionController::Base rescue_from ActionController::InvalidAuthenticityToken do render file: Rails.root.join('public', '403.html'), status: 403 end end
「iOS で Cookie を無効化した Safari を使っているユーザー」というニッチなターゲットにしか影響は出ないが、きちんとしたサービスを設計するのであれば、きちんと考慮してあげたい。
と言うのは、僕自身が某大手サービスを利用していたおりに、まさしくこれに起因する不具合を発生させて、カスタマーサポートに問い合わせた経験から言う。「ただいま解決に取り組んでいます」などというテンプレートを表示するのではちょっと物足りない。「このサイトではクッキーを有効化してください」などと適当なメッセージを掲示してくれていればそれだけでずいぶん親切だったし、カスタマーサポートの手を煩わせるまでもなかったはず、と思った。