説明
Cookie APIは、通常の値をシリアル化する際に引用符や制御文字を処理します。そのため、リクエストの設定値を theme のような固定された非セッションCookieに保存すること自体は、Cookieインジェクションではありません。
Cookie名やセッション識別子の取得元を信頼すると、次のような問題が起こり得ます。
- 信頼できない値をCookieの名前に使う。
- 信頼できない値をセッション識別子に使う。
- 信頼できない文字列を
Set-Cookieヘッダー全体に使う。
外部入力をセッションCookieに入れただけで、必ずアカウントが奪われるわけではありません。セッション固定が成立するには、攻撃者が知っている識別子をアプリケーションが受け入れ、被害者の認証後も同じ識別子やセッション状態を再利用する必要があります。Secure、HttpOnly、SameSite を設定しても、このライフサイクル上の欠陥は解消しません。
信頼できないCookie名は、既存のセッションCookieやセキュリティ上重要なCookieを上書きするおそれがあります。ヘッダー全体を入力に委ねると、名前、値、適用範囲、属性も操作されます。
想定される影響
- 攻撃者が知る識別子がログイン後も有効な場合の、セッション固定やアカウントの乗っ取り
- 重要なCookie名の上書きによる、認証やアプリケーション状態の改ざん
Domain、Path、SameSite、Secure、HttpOnlyを操作されることによる、適用範囲や送信方針の弱体化
セキュリティ属性の欠落、Cookie内の機密情報、Cookie値をHTMLに表示する際のXSSは、それぞれ別途確認してください。
対処方法
- リクエストデータをセッションCookieにコピーせず、フレームワークのセッション管理機能を使ってください。サーバー側セッションの識別子はセッションシステムが生成し、自ら発行した値だけを受け入れる必要があります。
- ログイン時と権限変更時にセッションを更新し、古い識別子を無効化してください。Djangoの
login()は、必要に応じてcycle_key()でキーを更新するか、既存セッションを置き換えます。FlaskやStarletteの署名付きCookieセッションでは、認証済みユーザーを記録する前に認証前の状態を消去してください。サーバー側セッションの拡張機能では、その機能のローテーションAPIを使ってください。 - Cookie名を固定してください。選択が必要な場合は、入力をサーバーが管理する少数の定数名に対応付けてください。クライアントが渡す許可リストは信頼しないでください。
- 信頼できる名前と
Response.set_cookie()を使い、入力由来の文字列をSet-Cookieヘッダー全体に代入しないでください。Werkzeugのdump_cookie()は通常のCookieの値を構造化しますが、セッション識別子の取得元を検証したり更新したりはしません。名前とセキュリティ属性はサーバー側で管理してください。 - セッションCookieにはHTTPSとともに
Secure、HttpOnlyを使い、処理に合うSameSiteを選んでください。Domain、Pathを必要最小限にし、互換性があれば__Host-を優先してください。これらの属性はセッションの更新に代わるものではありません。 - 固定された非セッションCookieの値は、業務上許可された値か検証してください。完全性が重要なクライアント側の状態には署名するか、サーバーに保存してください。署名は改ざんを防ぎますが、機密性は提供しません。
secrets.token_urlsafe() で値を作ってCookieに入れるだけでは不十分です。サーバー発行の識別子だけを受け入れ、認証時に更新し、古い識別子を無効にするライフサイクルも必要です。
例
セッション管理
変更前
FastAPI/Starletteで、リクエストの値をセッションCookieに使っています。
from fastapi import FastAPI, Request
from starlette.responses import Response
app = FastAPI()
@app.get("/set-session")
async def set_session(request: Request):
response = Response("ok")
response.set_cookie(
key="session",
value=request.query_params["sid"], # 危険: 攻撃者が知っているセッション値
secure=True,
httponly=True,
samesite="lax",
)
return response
セキュリティ属性をすべて設定しても、攻撃者が選んだ値を認証後に再利用すると、セッション固定の可能性が残ります。
変更後
Djangoのセッション管理機能を使います。
from django.contrib.auth import authenticate, login
from django.http import HttpResponse
def sign_in(request):
user = authenticate(
request,
username=request.POST["username"],
password=request.POST["password"],
)
if user is None:
return HttpResponse(status=401)
login(request, user) # Djangoがセッションキーを更新、または安全に置換
return HttpResponse(status=204)
Flaskの署名付きCookieセッションを使います。
import os
from flask import Flask, abort, request, session
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
@app.post("/login")
def sign_in():
user = verify_credentials(
request.form["username"],
request.form["password"],
)
if user is None:
abort(401)
session.clear() # 認証前の状態を削除
session["user_id"] = user.id
return {"status": "ok"}
verify_credentials はアプリケーションの認証関数を表します。重要なのは入力をセッション識別子にコピーせず、認証に成功した後で既存の状態を消去し、フレームワークのセッションに認証済みユーザーを記録することです。
Cookie名とヘッダー
変更前
Flaskでリクエストの値をCookie名に使っています。
from flask import Flask, make_response, request
app = Flask(__name__)
@app.get("/set-cookie")
def set_cookie():
response = make_response("ok")
response.set_cookie(request.args["name"], "1") # 危険: 動的なCookie名
return response
Djangoで Set-Cookie ヘッダー全体をリクエストの値にしています。
from django.http import HttpResponse
def set_raw_cookie(request):
response = HttpResponse("ok")
response.headers["Set-Cookie"] = request.GET["raw"] # 危険: ヘッダー全体を直接設定
return response
Flaskでリクエストの値を、対応表の既定値やシリアル化したセッション値に使っています。
from flask import make_response, request
from werkzeug.http import dump_cookie
def unsafe_cookie_defaults():
names = {"theme": "theme", "locale": "locale"}
response = make_response("ok")
name = names.get("unknown", request.args["name"])
response.set_cookie(name, "light") # 危険: 既定値が定数の対応表にないリクエスト値
response.headers["Set-Cookie"] = dump_cookie(
"__Host-session", request.args["sid"], secure=True, path="/"
) # 危険: シリアル化してもセッションIDは攻撃者が選択
return response
変更後
固定された非セッションCookieを使います。
from fastapi import HTTPException, Request
from starlette.responses import Response
async def save_theme(request: Request):
theme = request.query_params.get("theme", "light")
if theme not in {"light", "dark"}:
raise HTTPException(status_code=400)
response = Response("saved")
response.set_cookie(
key="theme",
value=theme,
secure=True,
samesite="lax",
)
return response
固定の theme キーの値はCookie APIがシリアル化します。ここでの値の検証は業務規則のためであり、生の Set-Cookie ヘッダーを無害化する手段ではありません。
定数の名前を選択し、通常の値をシリアル化します。
from flask import make_response, request
from werkzeug.http import dump_cookie
def save_preference():
names = {"theme": "theme", "locale": "locale"}
name = names.get(request.args["kind"], "theme")
response = make_response("ok")
response.set_cookie(name, "light")
response.headers["Set-Cookie"] = dump_cookie("locale", request.args["locale"])
return response
既定値も定数のCookie名なので、選択範囲は固定されます。locale は非セッションCookieで、シリアル化はCookie構文を処理します。値の業務上の意味は別途検証してください。