3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

備忘録:管理画面は /admin か admin.example.com か。「なんとなく」で選んでいたので、XSSの波及範囲から考え直しました

3
Last updated at Posted at 2026-08-21

この記事について

予約システムのようなマルチテナントのWebアプリを設計していると、必ず同じところで手が止まります。

「管理画面は https://app.example.com/admin でいいのか、それとも https://admin.example.com に分けるべきなのか」

オリジンやCORSやCookieの話は基礎で、良質な解説も山ほどあります。
/admin がパスにすぎず、同一オリジンなら管理画面も同じ箱に入る、という事実自体も知ってはいました。
詰めていなかったのは、その事実が実際の被害としてどこまで広がるのかのほうです。
「まあ分けたほうがいいよね」で止めたまま、何度か設計判断を先送りにしてきました。

今さらではありますが、そこを一度きちんと詰めて、自分の言葉で1か所にまとめておくことにしました。
教科書的な正解を並べるというより、何を調べて何を選んだかの記録に寄せた備忘録です。

想定している攻撃シナリオ

以下はすべて、次の状況を前提に書いています。
ここを共有しないと「一般ユーザーのブラウザで起きたXSSが、離れた管理者のセッションを直接奪う」という別の話に読めてしまうため、先に置いておきます。

  1. 攻撃者が、公開ページに保存型XSSを仕込める(店舗紹介文や問い合わせ本文など)
  2. 管理者が、同じブラウザで管理画面にログインしている
  3. 管理者が、その入力内容を管理画面または公開ページで閲覧する
  4. 管理APIが、Cookieなどブラウザの資格情報で認証している

ブラウザは2026年8月時点のモダンブラウザ(Chrome / Firefox / Safari の現行版)を前提にしていて、Cookieの挙動やCORSの仕様はRFCおよびWHATWG Fetch Standardの記述に沿っています。
特定のフレームワークやクラウドサービスの設定手順には触れず、設計判断の整理に絞ります。
セキュリティの最適解は要件と体制に左右されるので、ここに書いたのは私が採った選択であって、唯一の正解のつもりはありません。

オリジンとは何だったか

通常のHTTP(S) URLのオリジンは、次の3要素の組で決まります。

スキーム + ホスト名 + 正規化されたポート番号

ポートは正規化されるため、https://example.comhttps://example.com:443 は同一オリジンです。
data: URLやsandbox属性付き iframe はopaque originという別扱いになりますが、この記事ではHTTP(S)だけを扱います。

たとえば次の4つのURLは、すべて同じオリジンです。

https://app.example.com/shop-a
https://app.example.com/shop-b
https://app.example.com/admin
https://app.example.com/api/reservations

いずれも https + app.example.com + 443 の組み合わせだからです。

出発点は、URLのパスがオリジンの判定にまったく使われないという点です。
つまり次の2つは同一オリジンとして扱われます。

https://app.example.com/
https://app.example.com/admin

サーバー側でルーティングを分けていても、ブラウザ上の分離境界にはなりません。
/admin はディレクトリの整理であって、境界ではありません。
ここまでは知っていた話です。
問題は、境界が無い状態で公開ページに穴が空いたとき何が起きるかを、具体的に詰めたことがなかったことでした。

一方、次のURLはホスト名が異なるため、それぞれ別オリジンになります。

https://shop-a.example.com
https://shop-b.example.com
https://admin.example.com
https://api.example.com

「オリジン」と「サイト」は別物

ここは私の理解がいちばん粗かったところです。

サブドメインを分けると、ブラウザ上は別オリジンになります。
ところがCookieや SameSite の判定で使われる「サイト」は、スキームと registrable domain(登録可能ドメイン。この例では example.com)の組で決まります。
スキームまで含むので「schemeful same-site」と呼ばれます。
同じスキームで同じ registrable domain に属するホスト同士は、オリジンが違っても同一サイトです。

整理すると次のようになります。

URL(比較元は https://admin.example.com オリジン サイト
https://shop-a.example.com 同じ
http://shop-a.example.com 別(スキームが違う)
https://public-example.net

この違いが実務に効いてくる場面が2つありました。

1つ目は SameSite Cookieです。
SameSite=Strict はクロスサイトリクエストへのCookie送信を強く制限します。
Lax はクロスサイトのサブリソース読み込みや通常の fetch() では送信を止めますが、トップレベルナビゲーションかつ安全なメソッドなら送信されます。
つまり Lax は「クロスサイトを一律に遮断する」設定ではありません。

そのうえで肝心なのは、https://shop-a.example.com から https://admin.example.com へのリクエストが同一サイト扱いだという点です。
Lax でも Strict でも、ここは止まりません。
サブドメインを分けただけでは、サブドメイン間のCSRFは防げないということです。
「分けたのだから SameSite も効くだろう」と、確かめずに済ませていた部分でした。

2つ目はCookieの書き込みです。
あるサブドメインで動くJavaScriptは、Domain=example.com を指定して親ドメイン向けのCookieを設定できます。
このCookieは他のサブドメインにも送信されます。
攻撃者が公開ページのXSSからこれをやると、管理画面側のセッションを狙った細工ができてしまいます。
兄弟サブドメインなどから同名のCookieを注入するこの手口を Cookie tossing と呼びます。
後で出てくる __Host- プレフィックスがこの経路への対策でもある、というつながりは今回はじめて意識しました。

テナントが完全に信頼できない第三者の場合(誰でもサインアップして独自コンテンツを置けるようなサービス)は、テナント基盤の親ドメインを Public Suffix List の PRIVATE セクションに登録する手もあります。
登録されると、その直下のテナントホストがそれぞれ独立した registrable domain になります。
結果として shop-a.example.comshop-b.example.com は別サイトになり、Domain=example.com のCookieも設定できなくなります。
GitHub Pages の github.io などが同じ考え方です。

ただし、テナントホスト配下のさらに深いサブドメイン(x.shop-a.example.comshop-a.example.com)は引き続き同一サイトです。
またPSLは防御目的なら誰でも登録できる制度ではなく、相互に信頼しない多数の利用者へサブドメインを配るサービスが主な対象です。
反映に時間がかかり後から取り消しづらいので、採るなら初期設計で決める話だと思っています。

より強い分離が要るなら、公開ページと管理システムで親ドメイン自体を分ける選択肢もあります。

公開ページ: <tenant>.example-sites.com
管理画面:   admin.example.com

同一オリジンだと何が共有されるのか

同じオリジンで実行されるJavaScriptは、原則として次の対象に手が届きます。

  • 同一オリジンのDOM
  • localStorage
  • sessionStorage(オリジンに加えてタブ単位でも分かれます)
  • IndexedDB
  • Cache Storage / Service Worker / BroadcastChannel / SharedWorker
  • JavaScriptから参照可能なCookie
  • 同一オリジンAPIのレスポンス
  • ログイン中ユーザーの権限で実行できる操作

Service Worker が同一オリジンで登録できる点は、あとで気づいて肝が冷えました。
スコープとスクリプト配置の条件を満たされると、XSSが一度きりで終わらず永続化する経路になり得ます。

具体例のほうが頭に入るので、書き下しておきます。

公開ページ: https://app.example.com/shops/shop-a
管理画面:   https://app.example.com/admin
管理API:    https://app.example.com/api/admin/users

公開ページでXSSが成立すると、攻撃コードは app.example.com 上の正規のJavaScriptとして実行されます。
管理者が同じブラウザで管理画面にログイン中なら、次のリクエストが通ります。

// 攻撃者が仕込んだコードが、管理者のブラウザ上で実行される
const res = await fetch("/api/admin/users", {
  credentials: "include",
});
const users = await res.json();
// => 管理APIのレスポンスをそのまま読み取れてしまう

セッションCookieに HttpOnly を付けていても、これは成立します。
HttpOnly はJavaScriptからのCookie読み取りを防ぐ属性であって、ブラウザが自動でCookieを付けること自体は止めないからです。

つまりXSSでは、Cookieの値を盗めなくても「管理者としての操作」が実行されます。
HttpOnly が直接防ぐのは document.cookie などJavaScriptからのCookie値の読み取りだけです。
認証済みレスポンスの読み取りも、画面上の情報の持ち出しも、防ぎません。
当然のように付けていましたが、それが何を防いで何を防がないのかを言語化したことはありませんでした。

XSSのおさらいと、予約システムでの侵入経路

XSSは、攻撃者が入力したスクリプトを、正規サイトのコードとして別の利用者のブラウザで実行させる脆弱性です。

たとえば店舗紹介文に次の値が登録されたとします。

<img src="invalid" onerror="fetch('/api/admin/users')">

アプリがこの入力を文字列として安全に出力せず、HTMLとしてそのまま埋め込むと、画像の読み込み失敗をきっかけにJavaScriptが動きます。

予約システムで私が洗い出した「信頼できない入力源」は次のとおりです。
書き出してみると思ったより多く、同一オリジンのまま進める前提を見直すきっかけになりました。

  • 店舗紹介文
  • 注意事項
  • 利用規約
  • レビュー本文
  • 問い合わせ本文
  • 予約者名
  • 備考欄
  • 画像URL
  • 外部リンク
  • HTML形式のメールテンプレート

これらは入力源であって、それ自体がXSSではありません。
危険な出力処理(HTMLとしての生出力、innerHTML などのDOM sink、URLスキームの素通し、HTMLメールのWebプレビュー)と組み合わさってはじめて成立します。
入力源の数だけ、出力側を点検する箇所があるという読み方をしています。

Stored XSS

攻撃コードがデータベースに保存され、その画面を開くたびに実行されます。

攻撃者が入力
    ↓
データベースに保存
    ↓
管理者が管理画面で閲覧
    ↓
管理者のブラウザで攻撃コードが実行

予約システムでは、管理者が一般ユーザーの入力を業務として毎日開きます。
「管理者に見せる画面」がそのまま「攻撃コードの実行場所」になるわけで、Stored XSSの影響がとくに大きくなる構造だと理解しました。

Reflected XSS

URLパラメータなどの入力が、そのままレスポンスに反映されて発生します。

https://www.example.com/search?q=<script>...</script>

DOM-based XSS

フロントエンドのJavaScriptが、URLや入力値を危険な方法でDOMへ差し込むことで発生します。

// location.hash の中身をそのままHTMLとして解釈してしまう
element.innerHTML = location.hash;

同一オリジン構成の何が引っかかるのか

次の構成では、公開ページ・管理画面・APIがすべて同一オリジンです。

https://app.example.com/shops/shop-a
https://app.example.com/shops/shop-b
https://app.example.com/admin
https://app.example.com/api

整理してみると、性質の異なる2つの問題が混ざっていました。

1つ目は、店舗Aのページで起きたXSSが、同じオリジンにある管理機能へ直接届くことです。
公開ページ側の小さな出力エスケープ漏れが、そのまま管理者権限の侵害につながります。

2つ目は、店舗間もブラウザ上では分離されていないことです。

/app/shops/shop-a
/app/shops/shop-b

この2つは同一オリジンなので、localStorage や IndexedDB といったオリジン単位のストレージは共有されます。
店舗BのDOMまで読むには、iframe に埋め込む、window.open() で開くなど、そのウィンドウへの参照を得る必要があります。
無条件に他タブを覗けるわけではありませんが、参照さえ取れれば同一オリジンとして素通しです。
店舗B向けAPIを呼べるかどうかは、結局サーバー側の認可だけが決めています。
つまりテナント間の分離は、ブラウザではなくサーバー側の実装だけに依存している状態です。

そうなると、次の処理をすべて正しく実装し続ける必要が出てきます。

  • 認証ユーザーのテナント判定
  • APIごとの認可
  • オブジェクト単位のアクセス制御
  • データベース検索時の tenant_id 条件
  • 管理者ロールの確認

1か所でも漏れると破綻します。
守る層が1枚しかない状態はレビューの負荷も高く、自分が全部見きれる自信がありませんでした。

オリジンを分けると何が変わるか

同一オリジンのままの構成と、ホストを分けた構成を並べると、境界の差が分かりやすくなります。

同一オリジン構成とオリジン分離構成の対比。左の同一オリジン構成では、https://app.example.com の下に /shops/shop-a(公開ページ・XSSの侵入口)、/admin(管理画面)、/api/admin/users(管理API)が並び、ブラウザから見た境界は1つしかない。公開ページのXSSから管理APIを fetch でき、HttpOnly でも管理者としての操作は実行される。右のオリジン分離構成では、shop-a.example.com(公開ページ)、admin.example.com(管理画面・__Host-admin_session Cookie)、api.example.com(API)がそれぞれ別ホストになり、その間に越えられない境界が立つ。DOM・ストレージ・レスポンスを跨げず、被害は発生したオリジン内に留まる。ただしサーバー側の認可は別途必要

テキスト版を開く
同一オリジン構成
https://app.example.com   ← 境界は1つだけ
  /shops/shop-a       公開ページ(XSS の侵入口)
  /admin              管理画面
  /api/admin/users    管理 API
  → XSS から管理 API を fetch できる(HttpOnly でも操作は実行される)

オリジン分離構成
https://shop-a.example.com   公開ページ
  ─────────── 越えられない境界 ───────────
https://admin.example.com    管理画面(__Host-admin_session)
  ─────────── 越えられない境界 ───────────
https://api.example.com      API
  → DOM・ストレージ・レスポンスを跨げない
  → ただしサーバー側の認可は別途必要

次のように構成したとします。

公開ページ: https://shop-a.example.com
公開ページ: https://shop-b.example.com
管理画面:   https://admin.example.com
API:        https://api.example.com

この場合、shop-a.example.com で実行されたJavaScriptは、原則として次の情報を直接読み取れません。

  • admin.example.com のDOM
  • admin.example.comlocalStorage / sessionStorage / IndexedDB
  • admin.example.com のAPIレスポンス
  • shop-b.example.com のDOMやブラウザストレージ

効果を並べると次のようになります。

効果 内容
DOMの分離 公開ページのJavaScriptから管理画面のHTMLや画面状態を操作できません
ストレージの分離 localStorage / sessionStorage / IndexedDB はオリジン単位で分かれます
レスポンス読み取りの制限 CORSで明示的に許可しない限り、別オリジンAPIのレスポンスを読めません
Cookieのホスト分離 ホスト限定Cookieを使えば、管理画面用Cookieを公開サブドメインへ送りません
ポリシーの分離 公開ページと管理画面で別々のContent Security Policyを設定できます
影響範囲の限定 公開ページ側の脆弱性が管理システム全体へ波及しにくくなります

私にとって腹落ちしたのは最後の行でした。
オリジン分離は脆弱性をゼロにする施策ではなく、脆弱性が出る前提で被害を区画で止める設計だと考えると、投資の理由が説明しやすくなります。

オリジン分離では防げないもの

これも私自身に言い聞かせるためのメモです。
オリジン分離はブラウザ側の境界であって、サーバー側の認可不備は防げません。

たとえば次のSQLは、予約IDさえ分かれば他店舗の予約を取得できてしまいます。

-- 認可条件が無く、IDを知っていれば誰の予約でも読める
SELECT *
FROM reservations
WHERE id = :reservation_id;

少なくとも、認証情報から決定したテナントIDを検索条件に含める必要があります。
条件の付け忘れを実装の注意力だけに任せたくないなら、データベース側のRow-Level Securityを併用する手もあります。

-- テナント条件は「認証済みの値」から組み立てる
SELECT *
FROM reservations
WHERE id = :reservation_id
  AND tenant_id = :authenticated_tenant_id;

オリジンを分けても、次は別途つぶす必要があります。

  • APIの認可漏れ
  • IDOR / BOLA(他人のIDを指定して他人のデータを操作できる問題)
  • SQLのテナント条件漏れ
  • クライアントが送ってきた tenant_id をそのまま信用する実装
  • 管理者ロールの確認漏れ
  • CORSの過剰許可
  • CSRF
  • Cookieの誤設定
  • SSRF
  • SQLインジェクション
  • ファイルアップロードの脆弱性

私がやりそうな最悪のパターンは、オリジン分離を入れたことでサーバー側の認可レビューを緩めることです。
分離は層を1枚足す施策であって、既存の層を減らす施策ではないと書き留めておきます。

CORSが実際に制御しているもの

CORSは「別オリジンからのリクエストを禁止する仕組み」と要約されがちですが、実際の役割は違います。
主な役割は、JavaScriptから別オリジンのレスポンスを読み取れるかどうかの制御です。
自分も、その要約より細かい粒度では押さえていませんでした。

悪意あるページから、次のリクエスト自体は飛びます。

// レスポンスは読めなくても、リクエストは相手サーバーに届き得る
fetch("https://api.example.com/admin/delete-user", {
  method: "POST",
  credentials: "include",
});

CORSで許可されていなければレスポンスは読み取れません。
しかしサーバー側の処理は実行されます。
削除や更新のAPIなら、それだけで十分な被害です。

ここで自分が混同していたのは、「リクエストが届くか」と「Cookieが付くか」が別の判定だという点でした。
credentials: "include" を書けばCookieが必ず乗るわけではありません。
実際に送信されるのは、CookieのDomain・Path・SecureSameSite、そしてブラウザの第三者Cookieポリシーを通過したものだけです。
まったく別の親ドメイン上の攻撃ページからなら、LaxStrict のCookieは通常送られません。
一方、侵害された兄弟サブドメインからなら同一サイトなので送られ得ます。
つまり怖いのは、外部の見知らぬサイトより自分のサブドメインです。

プリフライトについても条件があります。
Content-Type: application/json のPOSTは非safelistedなので OPTIONS が先に飛び、許可されていなければ本リクエストは送信されません。
一方 application/x-www-form-urlencoded / multipart/form-data / text/plain はCORS-safelistedなContent-Typeです。
メソッドが GET / HEAD / POST のいずれかで、ほかに非safelistedなヘッダーが無ければ、プリフライトなしで本リクエストが飛びます。

したがって「JSONのAPIだから安全」は、サーバーがJSON以外のContent-Typeを厳密に拒否している場合にだけ成り立つ話でした。
それも設計された防御ではなく仕様の副作用なので、CSRFトークンや Origin 検証の代わりにはなりません。
片づけそうになったところなので、条件まで書き残しておきます。

更新系APIでは、次を組み合わせる前提で考えることにしました。

  • CSRFトークン
  • Origin ヘッダーの検証
  • SameSite Cookie(前述のとおりサブドメイン間には効きません)
  • 適切なHTTPメソッドの使い分け
  • Content-Type の制限
  • 重要操作での再認証
  • 操作権限の確認

Origin の検証に加えて、Sec-Fetch-Site: same-origin を要求する Fetch Metadata による絞り込みも有効です。
SameSite では区別できない兄弟サブドメインからのリクエストを、ここで落とせます。
後述するBFF方式とは特に相性がよいと感じました。

Cookie設計で自分が決めたこと

管理画面のセッションCookieは、管理画面のホストだけに限定します。
私の標準形は次のとおりです。

Set-Cookie: __Host-admin_session=xxxxx;
  Path=/;
  Secure;
  HttpOnly;
  SameSite=Lax

肝は Domain 属性を指定しないことでした。
__Host- プレフィックスを使うと、ブラウザ側で次の制約が強制されます。

  • Secure が必須
  • Path=/ が必須
  • Domain 属性を指定できない
  • Cookieを設定したホストにだけ送信される
  • 他のサブドメインから同名のCookieを上書きできない

最後の1行が、前に書いたCookie tossingへの対策になります。
サブドメインを分けたうえで __Host- を付けて、はじめてCookieの分離が実効的になる、という順番で覚えることにしました。

ただし万能ではありません。
__Host- が防ぐのは「兄弟サブドメインが同名Cookieを親Domain Cookieとして注入してくる」典型的な経路であって、Cookie tossing全般ではありません。
Cookieを設定したホスト自身が侵害される、大量のCookieでCookie jarを埋めて追い出される、__Host- を付けていない別のCookieが狙われる、といったケースは残ります。
Cookieはポートを区別しないので、同一ホストの別ポートとも共有されます。

関連して、Domain=example.com のCookieを使う設計では、サブドメインテイクオーバーが直接のCookie注入経路になります。
放置されたDNSレコードや外部SaaS向けのdangling CNAMEを残さない運用が前提になります。

避けたいのは次の設定です。

Set-Cookie: admin_session=xxxxx; Domain=example.com; Secure; HttpOnly

これだと、管理者のセッションCookieが次のような公開用サブドメインにも送信されます。

shop-a.example.com
shop-b.example.com
www.example.com

公開ページ側にXSSがあれば、そのリクエストにも管理者Cookieが乗ります。
せっかくホストを分けた意味がなくなるので、Domain を書きたくなったら一度止まるようにしています。

Cookieの配置は次の形に落ち着きました。

admin.example.com
  └─ __Host-admin_session

shop-a.example.com
  └─ 必要な場合のみ公開サイト用Cookie

shop-b.example.com
  └─ 必要な場合のみ公開サイト用Cookie

管理者セッションと一般ユーザーセッションは、Cookie名・用途・有効期限・認証方式をすべて分けます。
同じ仕組みを使い回すと、片方の緩い要件がもう片方に引きずられるからです。

APIをどこに置くか

APIの配置は、大きく2案で迷いました。

方式A:共通のAPIオリジンを立てる

公開ページ: https://shop-a.example.com
管理画面:   https://admin.example.com
API:        https://api.example.com

フロントエンドからAPIへの通信がクロスオリジンになるため、CORSの設定が要ります。
許可するオリジンは明示的に列挙します。

Access-Control-Allow-Origin: https://admin.example.com
Access-Control-Allow-Credentials: true

認証Cookieを使う場合、次の組み合わせは仕様上使えません。

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

ワイルドカードと資格情報の同時指定はブラウザが拒否します。
テナントごとにサブドメインが増える構成だと、許可オリジンを動的に組み立てることになります。
このとき、リクエストの Origin を検証せずそのまま反射すると実質的に全許可です。
私が書きそうな実装だったので、許可リストとの照合を必ず挟むとメモしました。
Origin に応じてレスポンスヘッダーを変える以上、Vary: Origin も要ります。
これが無いとキャッシュが別オリジン向けの応答を配ってしまいます。

もう1つ、方式Aで詰める必要があるのが認証方式です。
後述する __Host-admin_sessionadmin.example.com 専用なので、api.example.com へは送信されません。
つまり方式AでCookie認証を続けるなら、APIホスト専用のCookieを別に置くか、Authorizationヘッダーに切り替えるか、トークン交換を挟むかを決める必要があります。
そしてAPIホストにCookieを置くと、そのCookieは同一サイトである公開サブドメインからのリクエストにも乗ります。
CORSでレスポンス読み取りを止めても、単純リクエストによる状態変更は別に防ぐ必要があります。
私がBFF寄りになったのは、この分岐を毎回考えたくなかったからでもあります。

方式B:BFF(Backend for Frontend)を置く

BFF は Backend for Frontend の略で、特定のフロントエンド専用に立てる中間サーバーのことです。
汎用のバックエンドAPIをブラウザから直接叩かせるのではなく、画面の都合に合わせた薄いAPIをフロント専用に用意し、そこが内部でバックエンドAPIを呼びます。
もともとは「画面ごとに必要なデータをまとめて返す」ためのパターンですが、ここでは認証境界を1本にまとめる目的で使います。

管理画面:      https://admin.example.com
管理画面用API: https://admin.example.com/api/*

外部から見えるAPIを管理画面と同一オリジンにして、内部でバックエンドAPIへ転送する形です。

ブラウザ
   ↓
admin.example.com/api
   ↓
内部API(外部公開しない)

これなら管理画面の認証Cookieを admin.example.com だけに限定しやすく、ブラウザ側のCORS設計も単純になります。
内部APIをインターネットに露出させずに済むのも気に入っています。

私は管理画面についてはBFF方式を採ることが多いです。
CORSの許可リストを運用し続けるより、境界を1本にまとめたほうが私は事故を起こしにくいと感じているためです。
一方で、公開ページとネイティブアプリの両方から同じAPIを叩く要件があるなら方式Aのほうが素直だと思います。
クライアントの数と運用体制で決まる話なので、次に迷ったらそこから見るつもりです。

公開ページでHTMLを扱うとき

店舗がHTMLや外部スクリプトを自由に登録できる設計は、XSSリスクが跳ね上がります。
原則として、店舗入力はプレーンテキストか制限付きのMarkdownに寄せることにしました。

方針 内容
採る プレーンテキスト / 制限付きMarkdown / 許可タグを限定したHTML
採らない 任意のHTML / 任意のJavaScript / script タグ / 自由な iframe / イベントハンドラ属性

HTMLを許可するなら、拒否リストではなく許可リスト方式でサニタイズします。
拒否リストは新しい記法が出るたびに破られるので、自分の手には負えないと判断しました。

削除対象の代表例は次のとおりです。

<script>
<iframe>
<object>
<embed>

属性についても、イベントハンドラは一律で落とします。

onclick
onerror
onload
onmouseover

URL属性では、危険なスキームを拒否します。

<a href="javascript:alert(1)">

サニタイズは自前で書かず、実績のあるライブラリに任せています。
思いつく限りのケースを潰す作業ではなく、仕様の追随作業だからです。

CSPで守りを重ねる

CSPはレスポンス単位のヘッダーなので、正確に言えば同一オリジンでも公開ページと管理画面で別のポリシーを出せます。
オリジンを分ける利点は「別のCSPを書けること」そのものではなく、管理画面が公開ページ側の同一オリジンリソースを信頼せずに済むことのほうでした。
script-src 'self' は同一オリジン上のすべてのスクリプトURLを信頼するので、同じオリジンにユーザーアップロードファイルが載っているなら、この指定は思ったほど強くありません。
分離を決めたあとでこれに気づき、CSPの設計が一段楽になりました。

管理画面では、外部スクリプトを使わない前提で厳しめのポリシーにします。

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data:;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';

frame-ancestors 'none' はクリックジャッキング対策です。
このディレクティブは meta 要素では無効なので、HTTPレスポンスヘッダーとして配信する必要があります。

base-uri 'self'<base> で指定できるURLを同一オリジンに制限します。
外部オリジンへのすり替えは防げますが、同一オリジン内のパスへの差し替えまでは防ぎません。
<base> を使わない管理画面なら base-uri 'none' のほうが強い設定です。
ここはコピペで書いていて意味を説明できなかったので、今回調べ直しました。

object-src 'none'objectembed によるプラグインコンテンツの読み込みを禁止します。

公開ページで外部画像や計測タグが必要なら、必要な送信先だけを足します。
同じCSPを両方に適用しようとすると、公開ページ側の要件に引きずられて管理画面のポリシーが緩みます。
ここも「分けておいてよかった」と思う場面でした。

できる限り書きたくない指定は次のとおりです。

'unsafe-inline'
'unsafe-eval'
*

CSPはXSS対策の補助です。
出力エスケープやサニタイズの代わりにはなりません。

管理画面に足しておく対策

私用のチェックリストとして残します。

  • 管理画面を admin.example.com へ分離する
  • 管理者用Cookieを __Host- でホスト限定にする
  • 認証トークンを localStorage に置かない(XSSで読まれます)
  • 多要素認証を入れる
  • 管理者ロールを細分化する
  • 重要操作の直前に再認証を求める
  • CSPを厳格化し、frame-ancestors 'none' を設定する
  • 操作ログを保存する
  • ログイン試行回数を制限する
  • セッションを定期的に更新する
  • 退職者や無効化ユーザーのセッションを即時失効させる
  • 本部管理者と店舗管理者の権限を分ける
  • document.domain を使わない(非推奨であり、兄弟サブドメイン間で双方が緩和するとDOM境界が消えます)

サービス運営側だけが使う画面があるなら、そこをさらに分ける手もあります。

店舗管理画面:     admin.example.com
システム管理画面: system-admin.example.com

システム管理画面は、IPアドレス制限やVPN、ゼロトラスト系のアクセスプロキシと組み合わせやすくなります。
入口を1段増やしておくと、認可バグが1つ出たときの最悪ケースが変わる、というのが分けた理由です。

取り違えやすかった論点

/admin はパスであって境界ではない

https://app.example.com/
https://app.example.com/admin

この2つは同一オリジンです。
サーバー側のルーティングが分かれていても、ブラウザから見た境界は1つのままです。
この事実自体は知っていました。
詰めていなかったのは、境界が1つしかないと具体的に何ができてしまうか(上に書いた管理APIへの fetch)のほうです。

サブドメインを分けても認可処理は減らない

オリジン分離はブラウザ上の境界であって、APIとデータベースでは引き続き厳密な認可が要ります。
分離を入れたことで「分けたから大丈夫」という感覚が出てくる分、むしろ危ないと思っています。

HttpOnly が防ぐのはCookie値の直接読み取りだけ

document.cookie などJavaScriptからのCookie値の読み取りは防げます。
ただしログイン中ユーザーとしてリクエストを送信されるため、操作は実行されます。
認証済みレスポンスの読み取りや、画面上の情報の持ち出しも防げません。
「盗まれない対策」と一括りにせず、防げる範囲と防げない範囲を分けて覚えておく必要がありました。

CORSとCSRFは別の問題

CORSは主にレスポンスの読み取りを制御します。
CSRF対策としては、Origin の検証・CSRFトークン・SameSite Cookieなどが別途必要です。
セットで語られることが多いので、役割を分けて書き留めておきます。

サブドメインは完全には独立しない

別オリジンではありますが、Cookieの Domain 設定や同一サイト判定を通じて関連が残ります。
サブドメイン間のCSRFは SameSite では止まりません。
分離の効果に上限があることを、効果と一緒に覚えておきたいところです。

落ち着いた構成

今回想定した攻撃シナリオと私の運用体制では、次の形を基本にすると扱いやすいという結論になりました。

公開ページ:     https://<tenant>.example.com
店舗管理画面:   https://admin.example.com
バックエンドAPI: https://api.example.com
                または https://admin.example.com/api

管理画面は店舗ごとにサブドメインを作らず、共通の管理オリジンに集約します。

https://admin.example.com/tenants/shop-a
https://admin.example.com/tenants/shop-b

ログイン後、アクセス可能なテナントはサーバー側で決めます。

認証ユーザー
    ↓
所属テナントとロールを取得
    ↓
アクセス可能な tenant_id をサーバー側で決定
    ↓
対象データを検索

クライアントから送られてきた次の値は、そのまま認可判定に使わないと決めています。
過去にやりかけたことがあるので、いちばん忘れたくない一行です。

{
  "tenant_id": "shop-a"
}

URLやリクエストボディに tenant_id が含まれていても、最終判定はサーバー側の認証情報と照合して行います。

公開ページで扱う内容が次の範囲に収まるなら、サブドメイン分離で足りると考えています。

  • プレーンテキスト
  • 制限付きMarkdown
  • サニタイズ済みHTML
  • 運営側が管理するJavaScriptのみ

ただし「サブドメインに分けた」だけでは足りず、次がセットで初めて成立します。

  • Cookieを親ドメインへ広げない(__Host- を使う)
  • 公開ページ向けと管理API向けでCORSの許可先を分ける
  • 管理APIにCSRF対策を入れる
  • 公開サブドメインのDNSを管理し、テイクオーバーを許さない
  • document.domain を使わない
  • 管理者が閲覧するユーザー入力を、管理画面側でも安全に出力する

一方、次の機能を提供するなら、公開ページを別の親ドメインへ逃がすことを検討します。

  • 店舗が任意のHTMLを登録できる
  • 店舗が独自JavaScriptを登録できる
  • 外部タグを自由に埋め込める
  • iframe やウィジェットを自由に設置できる
  • 信頼できない第三者のコンテンツを表示する

その場合の構成例は次のとおりです。

公開ページ: https://<tenant>.example-sites.com
管理画面:   https://admin.example.com
API:        https://api.example.com

まとめ

オリジンを分ける意味は、URLを整理することではありません。
公開ページでXSSが起きたときに、管理画面と管理APIまで影響が波及しにくくするための区画化です。
「分けたほうがいい」までは前から思っていましたが、なぜどこまで分けるのかを説明できる状態になったのが今回の成果でした。

次に同じところで迷ったら、この2行だけ思い出せば足りるはずです。

  1. /admin はセキュリティ境界にならない。境界を作るならホストを分け、__Host- Cookieまでセットで設計する。
  2. オリジン分離はサーバー側の認可を置き換えない。分離した上で、テナント条件・ロール確認・CSRF対策を従来どおり積む。

最終的には、次を組み合わせて多層で守る形に落ち着きました。

オリジン分離
    +
XSS対策(出力エスケープ・サニタイズ)
    +
ホスト限定Cookie
    +
CSRF対策
    +
厳密なAPI認可
    +
テナント単位のデータ分離
    +
CSP
    +
監査ログ

既存システムで今すぐホストを分けられない場合もあると思います。
私も全部は一度に動かせませんでした。
そのときはまず「どの入力がどの画面で表示されるか」を洗い出すところから始めると、分離の優先順位が見えてきます。
私はそこから着手しました。

参考

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?