最近、新規事業で完全0-1のプロダクト開発をした際に
Rails/Vue.jsのSPAでいかにセキュアに認証情報を扱うかについて調べた結果を書いていきます
「それおかしくない?」とか「脆弱性つぶせてなくない?」とかあればコメントで指摘していただけるととても助かります
どう実装するか
実装の意図や詳細については下の方で解説します
Rails側
- 認証情報を
secure
属性とhttponly
属性をつけたcookieでフロントエンドに返す- domain属性はAPIのドメインのみにする(特に設定変えなければデフォルトでそうなってるはず)
- 独自ヘッダの有無のチェックを認証処理に組みこむ
- gem
rack-cors
を導入する- originsをSPAのURLのみに制限する
-
credentials
をtrue
にしておく
Vue.js側
-
withCredentials
オプションをtrue
に設定する(axiosやxhr)- fetchなら
credentials: 'include'
とかかな
- fetchなら
- リクエストに独自ヘッダをつける
- なんでもいいし中身も空文字でよくて、ヘッダがあることが重要
なぜ上記のような実装をするのか
以下の3つがポイントになります
-
httponly
属性とsecure
属性が有効でdomain
属性にはAPIのドメインのみを設定したcookieでアクセストークンを持たせる - 独自ヘッダが必要であること
- corsでoriginsを制限されていること
cookieについて
なぜhttponlyにするのか
- XSSや悪意あるパッケージなどのjsによりcookieにあるトークンを抜かれないようにするため
- jsから抜かれる危険性はlocalstorageを使わない理由でもある
- 代わりに、cookieにあるトークンを非同期通信でもリクエストするために
withCredentials
オプションを有効にする
なぜsecureにするのか
- 非SSLのリクエストを盗聴されないように、とかっていうよくある理由
なぜdomainをAPIのドメインのみに制限するのか
- サブドメをワイルドカードにしたりとか他のドメインを許容する必要がないため
- 少なくとも今回のケースでは複数ドメインをまたいでcookieの共有は必要なかった
- withCredentialを有効にしてcookieとして飛ばすからフロントエンドのjsからcookieを操作できる必要はない
- フロントのurlからはcookie見えないけどAPIのドメインへのcookieとしてちゃんと存在しているのでwithCredential有効にしておけばちゃんとリクエストされる
- ワイルドカードを入れようものならXSSで攻撃者のサーバにリクエストされたりした時にcookieが一緒に飛んでしまう
- 恥ずかしながら自分は最初「フロントのドメインでも扱えなきゃいけないよなぁ」と思い込んでいました。。。
独自ヘッダについて
- htmlのformによるCSRF対策
- htmlのform(同期通信)では独自ヘッダを付与することはできないため
CORSについて
- 独自ヘッダと併用してプリフライトリクエストを強制することでjsによるCSRF対策となる
- originsでちゃんと制限かけることで外からの不正なリクエストを弾く
- ただし、それだけでは不十分で、実際はブラウザ上ではエラーになるがリクエスト自体は飛んで処理されてしまうので「弾く」という表現は誤解を招く
- レスポンスを見せないので不正なGETには有効だが単純リクエストのPOSTは処理されてしまうので困る
- POSTは条件次第(formDataを使うこと)で単純リクエストになり、プリフライトリクエストが走らない場合があるため独自ヘッダによりプリフライトを強制させる
- これにより外部サイトからの不正なリクエストをすべてプリフライトリクエストで止める事ができる
- originsでちゃんと制限かけることで外からの不正なリクエストを弾く
- 独自ヘッダは2度美味しい
- ちなみに、rack-corsの設定で
credentials: true
にしないとフロントでwithCredential: true
のリクエストを弾いてしまう
実装のサンプル
一部抜粋して書いていきます
参考記事などは下の方にまとめてます
rails側
rack-corsなどの設定
config/application.rb
# ...割愛
# apiモードのrailsでcookiesを使えるようにするため
# コントローラ側に `include ActionController::Cookies` も必要
config.middleware.use ActionDispatch::Cookies
config.middleware.insert_before 0, Rack::Cors do
allow do
# 複数のorigins制限するためドメインの配列
# 環境変数はjsonにすると配列が簡単に扱えるやんという最近見つけたtips
origins JSON.parse(ENV.fetch('CORS_DOMAINS_JSON') { '[]' })
resource "*",
# axiosなどで `withCredential: true` にした上で、そのcookieを受け取るため
credentials: true,
headers: :any,
methods: [:get, :post, :patch, :delete]
end
end
# ...割愛
認証情報をcookieで返す
- アクセストークンの期限はDBにあるので
permanent
- 開発環境はsecure外したいので環境変数
COOKIE_SECURE
で操作
# ...割愛
cookies.permanent[:access_token] = {
value: access_token,
httponly: true,
secure: ENV['COOKIE_SECURE'].present?,
}
cookies.permanent[:refresh_token] = {
value: refresh_token,
httponly: true,
secure: ENV['COOKIE_SECURE'].present?,
}
# ...割愛
認証処理
access_token = cookies[:access_token]
# ...割愛(access_tokenチェックの処理など)
raise 適当な例外クラス if request.headers[:'X-REQUESTED-BY-MY-APP'].nil?
Vue.js側
withCredentialsを有効にする
src/main.js
// ...割愛
Vue.prototype.$http = axios.create(
{ baseURL: process.env.VUE_APP_API_URI, withCredentials: true },
);
// ...割愛
独自ヘッダをつける
- Vue.prototype.$httpにインスタンス入れることでどこからでも
this.$http
で使える - interceptorsを使うことですべてのリクエストでヘッダ設定の処理を走らせる
src/main.js
// ...割愛
Vue.prototype.$http.interceptors.request.use((request) => ({
...request,
headers: {
...request.headers,
...{ 'X-REQUESTED-BY-MY-APP': '' },
},
}));
// ...割愛
疑問
XSS脆弱性があっても認証情報を抜かれないための対策とか、外からのCSRFの対策はしたけど
XSSでXHRのコードをインジェクトされて外部ではなく本来のバックエンドに勝手にリクエストを飛ばされないようにするための対策ってどうすればいいんだろう。。。
根本的にXSSを塞ぐしかないってことになるのかな
参考記事
rails-apiでcookieを使う
RailsでAPIにCORSを設定する
rack-corsでCORS設定をする
さよならCSRF(?) 2017
CORS: OPTIONSリクエスト(preflight request)を避ける