導入:Flask のセキュリティは「賢い既定 + あなたの選択」
Flask のセキュリティを語るとき、最初に直さなければならない誤解が 2 つあります。
- 「
sessionに入れた値は秘密だ」 ——違います。Flask のsessionはクライアント側の署名付き Cookie です。署名で改ざんは検知できますが、中身はユーザーが読めます。 - 「フレームワークが CSRF や XSS を全部守ってくれる」 ——半分だけ正しい。XSS は Jinja の自動エスケープが既定で守りますが、CSRF は Flask 核に存在しません。セキュリティヘッダも既定では一切付きません。
この記事は、Flask 本番運用ガイド の §7(セキュリティ)を、本番品質で実装するための深掘り(スポーク)です。扱うのは「Flask が既定で守ってくれること」と「あなたが明示的に固めなければならない境界」の正確な切り分けと、その実装です。
筆者は、経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python / Flask / SQLAlchemy / PostgreSQL で設計・実装し、マルチテナント構成で本番運用してきました。テナント間でデータが混ざれば事業が終わるという緊張感の中で、ここに書く境界設計を一つずつ固めてきました。本稿のコードは、その実戦と Flask 3.1 系の公式ドキュメントに基づきます。
💡 この記事で扱うバージョン:Flask 3.1 系を前提とします。Flask 3.1 では
SECRET_KEY_FALLBACKS(鍵ローテーション)・SESSION_COOKIE_PARTITIONED(CHIPS)・MAX_FORM_MEMORY_SIZE/MAX_FORM_PARTS(DoS 緩和)・TRUSTED_HOSTS(Host ヘッダ検証)といった、本番堅牢化に直結する設定が追加されています。本稿はそれらを順に扱います。
1. Flask のセッション:それは「署名付きクライアント Cookie」である
1.1 何を守り、何を守らないのか
まず Flask の session の正体を、公式の言葉で正確に押さえます。クイックスタートはこう述べています——「セッションは Cookie の上に実装されており、Cookie を暗号学的に署名する。ユーザーは Cookie の中身を見ることはできるが、署名に使った秘密鍵を知らない限り変更はできない」。署名の実装は依存ライブラリの ItsDangerous です。
ここから導かれる結論は、セキュリティ設計上きわめて重要です。
| 性質 | Flask の session Cookie | 意味 |
|---|---|---|
| 完全性(Integrity) | ✅ 守る | 署名により改ざんを検知できる。session['role']='admin' への書き換えは弾かれる |
| 機密性(Confidentiality) | ❌ 守らない | 中身は Base64 で誰でもデコードして読める(暗号化ではない) |
| 真正性(Authenticity) | ✅ 守る | 秘密鍵を持つサーバーが署名したことを保証する |
使い方そのものは素直です。session は dict のように振る舞います。
from flask import session
# 書き込み(レスポンスで署名付き Cookie として送られる)
session['username'] = request.form['username']
# 読み取り
user = session.get('username')
# 削除
session.pop('username', None)
1.2 「読める」がもたらす設計の鉄則
session の中身が読めるという事実から、運用上の鉄則が 2 つ出ます。
sessionに秘密を入れない。API キー・パスワード・他人に見られて困る個人情報・内部フラグの「生値」をsessionに格納してはいけません。入れてよいのは「ユーザー ID」のような、漏れても署名がある限り害が小さい識別子だけです。実データは DB から ID で引きます。- 権限判定は
sessionの値だけに依存しない。session['user_id']は改ざんできませんが、その ID が「いまこのテナントのこのリソースにアクセスしてよいか」は別問題です。認可は DB の状態と突き合わせて判定します(後述のマルチテナント認可設計に分けています)。
⚠️ 最頻出の誤解:「
sessionは暗号化されているから機密を入れて良い」。これは誤りです。署名(signing)と暗号化(encryption)は別物で、Flask の既定は署名のみです。sessionCookie をブラウザの開発者ツールで取り出し、Base64 デコードすれば JSON が読めます。秘密を Cookie に乗せたいなら、暗号化込みの仕組みか、そもそもサーバー側セッションへ移します。
1.3 いつサーバー側セッションへ移すか
クライアント側 Cookie セッションには、もう一つ実務的な制約があります。Cookie のサイズ上限です。Flask は MAX_COOKIE_SIZE(既定 4093 バイト)を超えると警告を出します。ブラウザの Cookie サイズ上限(おおむね 4KB)に起因する制約で、これを超えるとセッションが黙って壊れる(Cookie が送られない)ことがあります。
次のいずれかに当てはまるなら、クライアント Cookie ではなくサーバー側セッション(Flask-Session 拡張など。Redis / DB / ファイルにセッション本体を保存し、Cookie には ID だけを乗せる)を検討します。
- セッションに入れたいデータが数 KB を超える(カート・ウィザードの途中状態など)。
- 機密データをセッションに保持する必要があり、クライアントに渡したくない。
- 「サーバー側から特定ユーザーのセッションを即時失効させたい」(クライアント Cookie はサーバーから無効化しづらい)。
💡 「署名付き Cookie」の応用:セッション以外のデータに署名付き Cookie や署名付きトークン(パスワードリセットリンクなど)を使いたいときは、Flask が内部で使っているのと同じ
itsdangerousを直接使えます。itsdangerous.URLSafeTimedSerializerで「有効期限付き・署名付きトークン」を発行・検証できます。sessionを再発明せず、同じ基盤を再利用するのが筋の良い設計です。
2. SECRET_KEY:すべての署名の根
2.1 なぜ必須なのか、どう生成するのか
session の署名は SECRET_KEY で行われます。これが未設定だとセッションは使えず、弱い/漏れると署名を偽造され、session['role']='admin' のような改ざんが通ってしまいます。SECRET_KEY は Flask セキュリティの土台です。
生成は公式が示すワンライナーを使います。CPython の secrets モジュールで暗号学的に安全な乱数を作ります。
python -c 'import secrets; print(secrets.token_hex())'
設定はコードに直書きせず、必ず環境変数経由にします。app.secret_key = ... でも app.config['SECRET_KEY'] = ... でも構いません。本番ではFlask 本番運用ガイドの §4 で扱った from_prefixed_env() と組み合わせ、FLASK_SECRET_KEY から注入するのが筋です。
import os
from flask import Flask
app = Flask(__name__)
# 環境変数から注入。コードにもリポジトリにも秘密を置かない
app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
# あるいは from_prefixed_env() で FLASK_SECRET_KEY を読む(本番運用ガイド §4 参照)
⚠️ 絶対にやってはいけないこと:
SECRET_KEY = 'dev'やSECRET_KEY = 'changeme'のままデプロイする。これは「鍵がない」のと同じで、誰でもセッションを偽造できます。開発用の固定値とは別に、本番用の鍵を Secrets Manager / コンテナの環境変数から注入し、コードにもログにも残さないこと。SECRET_KEYをログに出力するのは情報漏洩です(エラー処理・可観測性ガイド で「秘密・PII をログに残さない」原則を扱います)。
2.2 鍵ローテーション:SECRET_KEY_FALLBACKS(3.1 で追加)
SECRET_KEY が漏れた疑いがあるとき、あるいは定期的なセキュリティ運用として、鍵を交換したくなります。しかし鍵を素朴に差し替えると、旧鍵で署名された既存セッション Cookie が全て無効になり、全ユーザーが即ログアウトします。これは可用性の事故です。
Flask 3.1 で追加された SECRET_KEY_FALLBACKS(旧鍵のリスト)が、この問題を解きます。新しい鍵で署名しつつ、旧鍵で署名された Cookie も検証を通す——無停止で鍵をローテーションできます。
app.config.update(
SECRET_KEY=os.environ['SECRET_KEY'], # 新しい鍵(これで署名する)
SECRET_KEY_FALLBACKS=[os.environ['SECRET_KEY_OLD']], # 旧鍵(検証だけ通す)
)
運用フローはこうなります。
- 新しい鍵を生成し、
SECRET_KEYに設定。旧鍵をSECRET_KEY_FALLBACKSに追加してデプロイ。 - しばらく(既存セッションの寿命 =
PERMANENT_SESSION_LIFETIMEを超える期間)両方を有効にしておく。この間、既存ユーザーはアクセスのたびに新鍵で再署名される。 - 旧鍵で署名された Cookie が事実上消えたら、
SECRET_KEY_FALLBACKSから旧鍵を外して再デプロイ。
💡 なぜ「リスト」なのか:
SECRET_KEY_FALLBACKSは配列なので、複数世代の旧鍵を同時に許容できます。鍵漏洩のインシデント対応では「いま使っている鍵を即座に交換し、旧鍵は短期間だけ受け入れる」という素早い切り替えが必要になります。3.1 以前はこれを手作業のitsdangerous多重署名で実装する必要があり、技術的負債になりがちでした。3.1 では設定キー 1 つで済みます。
3. 安全な Cookie:本番の既定を「設定」で固める
3.1 本番の Cookie ブロック
Flask のセッション Cookie には、セキュリティ属性を制御する設定キー群があります。既定値は開発に最適化されており、本番では一部を必ず締め直す必要があります。主要キーを一覧します。
| 設定キー | 既定値 | 本番推奨 | 役割 |
|---|---|---|---|
SESSION_COOKIE_NAME | 'session' | 任意 | Cookie 名 |
SESSION_COOKIE_HTTPONLY | True | True | JS(document.cookie)から読めなくし、XSS による Cookie 窃取を防ぐ |
SESSION_COOKIE_SECURE | False | True | HTTPS でのみ Cookie を送る。HTTP 平文での漏洩を防ぐ |
SESSION_COOKIE_SAMESITE | None | 'Lax' | クロスサイト送信を制限し、CSRF を緩和する |
SESSION_COOKIE_PARTITIONED | False(3.1) | 必要時 True | CHIPS(パーティション化 Cookie)。有効化で SECURE も暗黙的に強制 |
SESSION_COOKIE_DOMAIN | None | 通常 None | Cookie の有効ドメイン |
SESSION_COOKIE_PATH | None | 通常 None | Cookie の有効パス |
SESSION_REFRESH_EACH_REQUEST | True | True | 各リクエストで Cookie の有効期限を延長 |
MAX_COOKIE_SIZE | 4093 | 4093 | 超えると警告(§1.3 参照) |
本番で最低限固めるブロックはこれです。ProductionConfig クラスや create_app 内で設定します。
app.config.update(
SESSION_COOKIE_SECURE=True, # HTTPS 限定(本番は必須)
SESSION_COOKIE_HTTPONLY=True, # JS から読めない(既定だが意図を明示)
SESSION_COOKIE_SAMESITE='Lax', # クロスサイト送信を制限
)
各属性が守るものを整理します。
| 属性 | 値 | 防ぐ攻撃 |
|---|---|---|
Secure | HTTPS のみ送信 | 中間者による平文 Cookie の盗聴 |
HttpOnly | JS から不可視 | XSS で document.cookie を盗む攻撃 |
SameSite | Lax(推奨)/ Strict | CSRF(クロスサイトからの自動送信) |
⚠️
SESSION_COOKIE_SECURE=Trueは HTTPS が前提。ローカルの平文 HTTP(http://localhost)では、SecureCookie はブラウザに送られずログインできなくなります。本番は TLS 終端の背後で動かすのが前提なので問題ありませんが、リバースプロキシ(nginx / ALB)の背後ではX-Forwarded-Protoを正しく Flask に伝えるProxyFixの設定が要ります。この TLS とプロキシまわりは本番デプロイガイド で扱います。
💡
SameSite=LaxかStrictか:Strictは「外部サイトからのリンク遷移ですら Cookie を送らない」ため、外部リンクから来たユーザーがログイン切れに見える UX 問題が起きます。Laxは「トップレベルの GET ナビゲーション(普通のリンククリック)では送るが、クロスサイトの POST では送らない」という現実的なバランスで、多くのアプリでLaxが最適解です。決済確定のような特に重要な操作のみStrictの別 Cookie に分離する、という設計もあります。
3.2 セッション固定攻撃とリプレイ攻撃の緩和
Cookie 属性を固めても、セッションのライフサイクルを誤ると穴が残ります。代表が 2 つです。
セッション固定攻撃(Session Fixation):攻撃者が用意したセッション ID を被害者に使わせ、被害者がログインした後でその ID を乗っ取る攻撃です。対策は単純で、ログイン成功時に古いセッションを必ずクリアし、新しいセッションを発行すること。公式の web セキュリティページのパターンがこれです。
from flask import session
app.config.update(PERMANENT_SESSION_LIFETIME=600) # セッション寿命を 600 秒に短縮
@app.route('/login', methods=['POST'])
def login():
user = authenticate(request.form['username'], request.form['password'])
if user is None:
abort(401)
session.clear() # 既存のセッションを破棄(固定攻撃の遮断)
session['user_id'] = user.id # 新しいセッションに最小限の識別子だけ入れる
session.permanent = True # PERMANENT_SESSION_LIFETIME を適用する
return redirect('/dashboard')
リプレイ攻撃の緩和:PERMANENT_SESSION_LIFETIME(既定 timedelta(days=31))は、session.permanent = True のセッションに有効期限を与えます。公式はこう述べています——「Flask の既定 Cookie 実装は、暗号署名がこの値より古くないことを検証する。この値を小さくすると、リプレイ攻撃の緩和に役立つことがある」。盗まれた古い Cookie の使い回しを、時間で無効化する仕組みです。機密性の高いアプリでは、寿命を 31 日ではなく数十分〜数時間に短縮します。
| 対策 | 手段 | 防ぐもの |
|---|---|---|
| セッション再生成 | ログイン時に session.clear() → 新規発行 | セッション固定攻撃 |
| セッション寿命 | PERMANENT_SESSION_LIFETIME を短縮 + session.permanent | リプレイ攻撃(古い Cookie の使い回し) |
| ログアウト | session.clear() | セッション残留 |
💡 ログアウトは
session.clear()。session.pop('user_id')で個別キーを消すより、session.clear()で丸ごと破棄するほうが、消し忘れのキーが残る事故を防げます。SRP の観点でも「ログアウト = セッション全消去」という単純な不変条件にしておくほうが安全です。
4. CSRF:核に無い理由と、Flask-WTF での実装
4.1 なぜ Flask 核に CSRF が無いのか
CSRF(クロスサイトリクエストフォージェリ)は、ログイン済みユーザーのブラウザに、攻撃者のサイトから本物のサイトへ意図しないリクエスト(例:「送金する」POST)を送らせる攻撃です。Cookie はブラウザが自動で付けるため、ユーザーの認証情報がそのまま悪用されます。
Flask は CSRF 対策を核に持ちません。公式セキュリティページは、その設計判断を明示しています——「なぜ Flask がそれをやってくれないのか? それが起きるべき理想的な場所はフォーム検証フレームワークであり、それは Flask には存在しないからだ」。Flask が「micro(核だけ)」である哲学の、首尾一貫した帰結です。CSRF 対策はフォーム検証層(= 拡張)の責務として、Flask-WTF に委ねます。
4.2 CSRFProtect のセットアップ(ファクトリ形)
Flask-WTF の CSRFProtect は、全ての状態変更リクエスト(POST / PUT / PATCH / DELETE)にトークン検証を要求します。アプリケーションファクトリと整合させるため、他の拡張と同じく「未束縛で生成 → init_app で束縛」のパターンに乗せます(この設計の背景は大規模構成ガイドを参照)。
# extensions.py — どのアプリにも束縛されていない「裸」の拡張
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()
# __init__.py — アプリケーションファクトリ内で束縛
from flask import Flask
from .extensions import csrf
def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ['SECRET_KEY'] # CSRF トークン署名にも使われる
csrf.init_app(app)
# ...
return app
💡
CSRFProtectもSECRET_KEYに依存する。CSRF トークンはSECRET_KEY(またはWTF_CSRF_SECRET_KEYを別に設定すればそちら)で署名されます。つまり §2 のSECRET_KEY管理は、セッションだけでなく CSRF 防御の土台でもあります。鍵が一つで二役を担う以上、その生成・保管・ローテーションの厳格さは二重に重要です。
4.3 テンプレートへのトークン埋め込み
CSRFProtect を有効にすると、フォームには CSRF トークンの hidden フィールドが必須になります。FlaskForm(WTForms)を使う場合は {{ form.csrf_token }} が出力します。素の HTML フォームには csrf_token() ヘルパーで埋め込みます。
<!-- FlaskForm を使う場合 -->
<form method="post">
{{ form.csrf_token }}
<input type="text" name="title">
<button type="submit">保存</button>
</form>
<!-- 素の HTML フォームの場合 -->
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text" name="title">
<button type="submit">保存</button>
</form>
トークンのフィールド名は WTF_CSRF_FIELD_NAME(既定 'csrf_token')、有効期限は WTF_CSRF_TIME_LIMIT(既定 3600 秒)で制御できます。
4.4 AJAX:X-CSRFToken ヘッダで送る
JavaScript からの fetch / XMLHttpRequest では hidden フィールドが使えないため、トークンをHTTP ヘッダ X-CSRFToken で送ります。トークンは <meta> タグに埋めておき、JS から読み出すのが定石です。
<!-- レイアウトの <head> にトークンを埋める -->
<meta name="csrf-token" content="{{ csrf_token() }}">
// meta タグから読んで、状態変更リクエストのヘッダに付ける
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/authors', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken, // Flask-WTF がこのヘッダを検証する
},
body: JSON.stringify({ name: '友田' }),
});
4.5 API エンドポイントの除外:CSRF が「効かない」境界を理解する
ここが設計判断として最も重要です。ステートレスな Bearer トークン認証の API には、CSRF 保護は原理的に不要です。理由を正確に押さえます。
CSRF が成立するのは「ブラウザが認証情報(Cookie)を自動で付ける」からです。攻撃者のサイトから POST させても、ブラウザが被害者の Cookie を勝手に同梱するため攻撃が通ります。一方、Authorization: Bearer <token> のようなヘッダ認証は、ブラウザが自動では付けません。攻撃者のサイトの JS は、被害者のトークンを読み出して他オリジンの API に付与することが(同一オリジンポリシー / CORS により)できません。つまり「ブラウザの自動送信」という CSRF の前提が崩れるため、トークン認証 API に CSRF トークンを足すのは無意味な二重防御になります。
したがって、Cookie セッションで守るブラウザ向けフォームには CSRFProtect を効かせ、トークン認証の API エンドポイントは @csrf.exempt で除外します。
from flask import Blueprint, jsonify
# トークン認証の API Blueprint は CSRF 検証から除外する
api_bp = Blueprint('api', __name__, url_prefix='/api')
@api_bp.before_request
def require_bearer_token():
# ここで Authorization: Bearer を検証する(Cookie に依存しない認証)
...
# Blueprint 全体を除外
csrf.exempt(api_bp)
# 個別のビューを除外する場合
@app.route('/webhook', methods=['POST'])
@csrf.exempt
def stripe_webhook():
# 外部サービスからの Webhook は署名検証で守る(CSRF トークンは持てない)
...
⚠️ 「除外」は無防備ではない。
@csrf.exemptは「CSRF トークン検証をしない」だけで、その分の認証・認可を別の手段で必ず張るのが前提です。トークン認証 API ならAuthorizationヘッダの検証、外部 Webhook なら送信元の署名検証(Stripe なら署名ヘッダの HMAC 検証)。除外したエンドポイントを認証なしで晒すのは本末転倒です。「CSRF が不要な理由(ブラウザの自動送信が無い)」と「認証が不要な理由」は別物だと、常に切り分けてください。
| 認証方式 | CSRF 保護 | 理由 |
|---|---|---|
| Cookie セッション(ブラウザフォーム) | 必要 | ブラウザが Cookie を自動送信するため攻撃が成立する |
Authorization: Bearer トークン | 不要(@csrf.exempt) | ブラウザが自動送信せず、CSRF の前提が崩れる |
| 外部 Webhook | 除外 + 署名検証 | トークンを持てない。送信元署名(HMAC)で真正性を担保 |
5. XSS と自動エスケープ:Jinja が守る範囲と、守らない範囲
5.1 Jinja の自動エスケープ規則
XSS(クロスサイトスクリプティング)は、ユーザー入力に混ざった <script> などがそのまま HTML として実行される攻撃です。Flask のテンプレートエンジン Jinja は、既定で自動エスケープが有効で、これが最初の防壁になります。{{ name }} に <script> が来ても、<script> に変換されて無害化されます。
ただし自動エスケープが効くのは拡張子が .html / .htm / .xml / .xhtml のテンプレートだけである点が、見落とされがちな落とし穴です。
| テンプレートの種類 | 自動エスケープ |
|---|---|
.html / .htm / .xml / .xhtml ファイル | ✅ 有効(既定) |
上記以外の拡張子(.txt など) | ❌ 無効 |
文字列から直接ロードしたテンプレート(render_template_string 等) | ❌ 無効 |
⚠️ 文字列テンプレートは自動エスケープされない。
render_template_string(user_supplied)のようにユーザー入力をテンプレート文字列として渡すのは、自動エスケープが無効なうえに SSTI(サーバーサイドテンプレートインジェクション) の温床で、二重に危険です。テンプレートは必ず.htmlファイルとして持ち、ユーザー入力は**データ({{ var }}に渡す値)**として扱い、テンプレートそのものにはしないこと。
5.2 markupsafe の escape と Markup
テンプレートの外(ビュー関数が直接 HTML 断片を返す等)で手動エスケープが必要なときは、escape を使います。重要な変更点として、escape と Markup は現在 flask ではなく markupsafe から import します(以前は flask からも取れましたが、現在は markupsafe が正です)。
from markupsafe import escape
@app.route("/hello/<name>")
def hello(name):
# name はユーザー入力。escape で <, >, & 等を無害化してから埋め込む
return f"Hello, {escape(name)}!"
逆に「この文字列は安全な HTML だと自分が保証する」と宣言するのが Markup です。これは自動エスケープを意図的に無効化する、強力で危険な道具です。
from markupsafe import Markup
# Markup でラップした文字列はエスケープされずにそのまま HTML として出力される
safe = Markup("<strong>太字</strong>")
⚠️
Markup()をユーザー入力に使わない。公式が明示的に警告しています。Markup(user_input)は「ユーザー入力を信頼できる HTML として扱う」という意味になり、自動エスケープを自ら無効化して XSS の穴を開ける行為です。Markupを使ってよいのは、自分が完全に制御している定数 HTML だけ。ユーザー由来の文字列をMarkupに通すコードを見たら、それは脆弱性だと考えてください。
5.3 自動エスケープでも防げない XSS パターン
「Jinja の自動エスケープがあるから XSS は安心」も誤りです。公式ドキュメントは、自動エスケープが守らない代表的なパターンを挙げています。
- 引用符で囲まないHTML属性:
<a href={{ value }}>のように属性値を引用符で囲まないと、valueに空白やonmouseover=...を混ぜられてエスケープをすり抜けられます。属性値は必ず引用符で囲む:<a href="{{ value }}">。 javascript:スキームの URL:<a href="{{ user_url }}">のuser_urlがjavascript:alert(1)だと、クリックでスクリプトが走ります。属性のエスケープでは防げません。URL のスキーム(http/httpsのみ許可)を検証するか、後述の CSP でjavascript:を禁じます。Markup()をユーザーデータに適用(§5.2 のとおり)。
<!-- ❌ 危険:属性値を引用符で囲んでいない -->
<a href={{ url }}>link</a>
<!-- ✅ 安全:属性値を引用符で囲む -->
<a href="{{ url }}">link</a>
これらは「テンプレートの書き方」の問題であり、エスケープ機能ではカバーしきれません。だからこそ、次節の CSP を多層防御(defense-in-depth) として重ねます。
6. セキュリティヘッダ:既定では付かないものを付ける
6.1 付けるべきヘッダ
Flask はセキュリティ系のレスポンスヘッダを既定では一切付けません。HSTS・CSP・nosniff・フレーム制御は、自分で付与するか拡張に任せます。本番で付けるべき基本セットはこれです。
| ヘッダ | 推奨値 | 役割 |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains | 以後 HTTPS のみで接続させる(HSTS)。ダウングレード攻撃を防ぐ |
Content-Security-Policy | default-src 'self' | スクリプト等の取得元を制限し、XSS を多層防御する |
X-Content-Type-Options | nosniff | MIME スニッフィングを禁止し、誤った型解釈による実行を防ぐ |
X-Frame-Options | SAMEORIGIN | 他サイトからの iframe 埋め込みを禁止し、クリックジャッキングを防ぐ |
💡
X-XSS-Protectionは付けない。かつて推奨されたX-XSS-Protectionヘッダは現在非推奨で、ブラウザ側のフィルタ自体が廃止されています。公式の推奨リストにも載っていません。古いブログや生成 AI の出力がこれを勧めることがありますが、付けても効果はなく、設定によってはかえって脆弱性を生むため入れないのが正解です。XSS の防御は「Jinja 自動エスケープ + 正しいテンプレート記述 + CSP」で構成します。
6.2 after_request で付与する(最小実装)
外部拡張を増やさず、after_request フックですべてのレスポンスにヘッダを付ける実装です。グローバルな副作用を一箇所に集約でき、何が付くかがコードから一目で分かります。
from flask import Flask
app = Flask(__name__)
@app.after_request
def set_security_headers(response):
"""全レスポンスにセキュリティヘッダを付与する(SRP:ヘッダ付与だけを担う)。"""
response.headers['Strict-Transport-Security'] = (
'max-age=31536000; includeSubDomains'
)
response.headers['Content-Security-Policy'] = "default-src 'self'"
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
return response
⚠️ HSTS は HTTPS でのみ意味を持つ。
Strict-Transport-Securityは HTTPS で配信して初めて有効です。開発の平文 HTTP で付けると、ブラウザがそのホストを「HTTPS 必須」と記憶してしまい、ローカルで困ることがあります。本番(TLS 終端の背後)でのみ付ける、あるいはapp.debugを見て出し分けるのが安全です。CSP もdefault-src 'self'は厳しめの出発点なので、外部 CDN・解析タグ・インラインスクリプトを使うサイトでは自分のアプリに合わせて段階的に緩めるチューニングが要ります(まずContent-Security-Policy-Report-Onlyで観測してから本適用するのが定石)。
6.3 after_request vs Flask-Talisman
ヘッダ付与には専用拡張 Flask-Talisman という選択肢もあります。HTTPS 強制リダイレクト・HSTS・CSP(nonce 生成含む)・フレーム制御などを一括で面倒見てくれます。
| 観点 | after_request(手動) | Flask-Talisman |
|---|---|---|
| 依存追加 | なし(標準機能のみ) | 拡張 1 つ追加 |
| 透明性 | 何が付くかコードで一目瞭然 | 既定値が暗黙的(要ドキュメント確認) |
| CSP nonce | 自前実装が必要 | 組み込みでサポート |
| HTTPS 強制リダイレクト | 自前 | 組み込み |
| 向いているケース | ヘッダが数個・要件が単純 | CSP nonce や HTTPS 強制が要る複雑な要件 |
筆者の B2B SaaS では、CSP が単純(自オリジン中心)で要件が固定だったため、依存を増やさず after_request の数行で管理しました。「拡張で楽になる」よりも「何が付いているか明示的に分かる」ことを優先した判断です。CSP に nonce が要る、HTTPS 強制リダイレクトを Flask 層でやりたい、といった要件が出てきたら Talisman に移行します。これは「現在の要件に最小フィットさせ、必要になってから足す」という YAGNI の実践です。
7. DoS とリソース制限:入力の「大きさ」も境界で止める
セキュリティは「不正な値」だけでなく「過大なリクエスト」からの防御も含みます。巨大なボディや膨大なフォームパートを無制限に受け付けると、メモリ枯渇によるサービス拒否(DoS)になります。Flask 3.1 系には、これを設定で止める仕組みがあります。
| 設定キー | 既定値 | 追加 | 役割 |
|---|---|---|---|
MAX_CONTENT_LENGTH | None | — | リクエストボディの最大バイト数。超過で 413 を返す |
MAX_FORM_MEMORY_SIZE | 500_000(500kB) | 3.1 | 非ファイルのフォーム値の合計サイズ上限 |
MAX_FORM_PARTS | 1_000 | 3.1 | マルチパートフォームのパート数上限 |
TRUSTED_HOSTS | None | 3.1 | ルーティング時に Host ヘッダを検証 |
app.config.update(
MAX_CONTENT_LENGTH=10 * 1024 * 1024, # ボディ全体を 10MB に制限
# MAX_FORM_MEMORY_SIZE / MAX_FORM_PARTS は 3.1 の既定値で十分なことが多い
TRUSTED_HOSTS=['example.com', 'www.example.com'], # Host ヘッダ攻撃対策
)
押さえるべき点を 3 つ。
MAX_CONTENT_LENGTHの既定はNone(無制限)。ファイルアップロードを受けるなら必ず明示設定します。Flask 3.1 ではRequest.max_content_lengthでエンドポイント単位の上限も設定でき、「アップロードのルートだけ大きく、他は小さく」という細かい制御ができます。MAX_FORM_MEMORY_SIZE/MAX_FORM_PARTSは 3.1 で既定が付いた。公式によれば、3.1 の既定では1 つのフォームが占有するメモリは最大でも約 500MB に収まるよう設計されています。古いバージョンからの移行では、この既定が増えたことで挙動が変わる可能性があるので、巨大フォームを扱うアプリは値を見直します。TRUSTED_HOSTS(3.1)で Host ヘッダ攻撃を防ぐ。Hostヘッダを信頼してパスワードリセットリンクを生成するようなコードは、偽のHostを送られると「攻撃者ドメインへのリセットリンク」を発行してしまいます。TRUSTED_HOSTSはルーティング時にHostを検証し、これを遮断します。
💡 アプリ層の限界と WAF:ここまでの DoS 対策は「1 リクエストの大きさ」を制限するもので、「大量のリクエストが来る」DDoS は別レイヤの問題です。レート制限・IP 評価・大規模なボリューム攻撃の吸収は、アプリの前段(WAF / CDN / ロードバランサ)で行うのが筋です。アプリ層の入力検証と前段の WAF を組み合わせた多層防御の設計は、WAF による多層防御ガイドにまとめています。Flask の
MAX_*設定は「前段をすり抜けた、あるいは認証済みユーザーからの」過大入力を止める最後の砦として位置づけます。
8. まとめと Flask セキュリティ・チェックリスト
Flask のセキュリティは、フレームワークの「賢い既定」と「あなたが明示的に固める境界」の正確な切り分けで決まります。本記事の要点を再掲します。
sessionは署名付きクライアント Cookie。完全性(改ざん検知)は守るが機密性は守らない——中身は読める。秘密や大きなデータはサーバー側セッションへ。SECRET_KEYがすべての署名の根。secrets.token_hex()で生成し環境変数で注入。3.1 のSECRET_KEY_FALLBACKSで無停止の鍵ローテーション。- 本番 Cookie は
SECURE/HttpOnly/SameSite='Lax'で固め、ログイン時のsession.clear()+ 再生成で固定攻撃を、PERMANENT_SESSION_LIFETIMEの短縮でリプレイ攻撃を緩和する。 - CSRF は核に無い(公式の設計判断)。Flask-WTF の
CSRFProtectをファクトリでinit_app、テンプレートトークンと AJAX のX-CSRFTokenで守る。ステートレスなトークン認証 API は@csrf.exempt(CSRF の前提が成立しないため)。 - XSS は Jinja 自動エスケープ(
.html等)が既定の防壁。escape/Markupはmarkupsafe由来。引用符なし属性・javascript:URL・Markup(ユーザー入力)は防げないので、CSP で多層防御。 - セキュリティヘッダは既定で付かない。HSTS / CSP /
nosniff/X-Frame-Optionsをafter_requestか Flask-Talisman で付与(X-XSS-Protectionは非推奨なので付けない)。 - DoS は入力の「大きさ」も止める。
MAX_CONTENT_LENGTH/MAX_FORM_MEMORY_SIZE/MAX_FORM_PARTS/TRUSTED_HOSTS(3.1)。大量リクエストは前段の WAF で。
最後に、本番投入前に通すべきチェックリストです。筆者がマルチテナント B2B SaaS で実際に確認している項目を整理しました。
| 区分 | チェック項目 | 確認 |
|---|---|---|
| 鍵 | SECRET_KEY を secrets.token_hex() で生成し、環境変数から注入している | ☐ |
| 鍵 | SECRET_KEY がコード・リポジトリ・ログのどこにも残っていない | ☐ |
| 鍵 | 鍵ローテーション手順(SECRET_KEY_FALLBACKS)を運用に用意している | ☐ |
| Cookie | 本番で SESSION_COOKIE_SECURE=True | ☐ |
| Cookie | SESSION_COOKIE_HTTPONLY=True / SESSION_COOKIE_SAMESITE='Lax' | ☐ |
| セッション | ログイン時に session.clear() + 再生成、ログアウトで session.clear() | ☐ |
| セッション | session に秘密・大きなデータを入れていない(ID のみ) | ☐ |
| セッション | PERMANENT_SESSION_LIFETIME を要件に合わせて短縮している | ☐ |
| CSRF | ブラウザフォームに CSRFProtect が効いている | ☐ |
| CSRF | トークン認証 API / Webhook は @csrf.exempt し、別手段(Bearer / 署名)で認証している | ☐ |
| XSS | テンプレートは .html ファイル、render_template_string(ユーザー入力) を使っていない | ☐ |
| XSS | 属性値を引用符で囲み、URL のスキームを検証、Markup(ユーザー入力) が無い | ☐ |
| ヘッダ | HSTS / CSP / nosniff / X-Frame-Options を付与(X-XSS-Protection は付けない) | ☐ |
| DoS | MAX_CONTENT_LENGTH を明示設定、TRUSTED_HOSTS(3.1)で Host を検証 | ☐ |
| 認可 | 認証(誰か)と認可(何をしてよいか)を分離し、認可を DB と突き合わせている | ☐ |
セッション・Cookie・CSRF・XSS・ヘッダ——これらは「アプリに入れる外部入力」と「アプリが返す出力」の境界を固める仕事です。境界をどれだけ宣言的に・設定で守れているかが、session['role']='admin' のような改ざんや、{"role":"admin"} のようなマスアサインメント(marshmallow による REST API 境界設計 で load_only / dump_only を使って構造的に防ぐ手法を解説)を未然に止めます。
Flask のセキュリティ全体像と他の設計対象(構成・コンテキスト・デプロイ・テスト・可観測性)との接続は、Flask 本番運用ガイドに戻って俯瞰してください。そして「認証されたユーザーが、許されたデータにだけアクセスできる」という認可の設計は、セッションの一段上の話としてマルチテナント SaaS のデータ分離・認可設計ガイドに分けています。境界を一つずつ、設定とコードの構造で固めていくこと——それが、事業の信頼性を支える Flask セキュリティの実装です。