はじめに
フロントエンドのセキュリティというと、
- XSS対策
- CSP
- HttpOnly Cookie
- CSRF対策
などがよく挙げられます。どれも重要です。
しかし、対策の名前だけを知っていても、「なぜそれが必要なのか」が腹落ちしていないと、実装はどこかで抜けます。
私が普段バグバウンティで認可の不備(BOLAなど)を報告したり、CTF・ペネトレーションテストに取り組む中で感じるのは、こういうことです。
攻撃者の視点を一度通すと、「どこを直すべきか」が驚くほどはっきり見える。
実際、私がこれまで報告してきた脆弱性の多くは、高度な攻撃ではなく「ブラウザから見えている情報を、少し疑ってみる」ことから始まっています。
そこでこの記事では、各項目を
「攻撃者はここを見る」→「だから、あなたはこう直す」
という順番で書きます。攻撃の話は"なぜ直すのか"を理解するためのフックで、主役はあくまで防御の実装です。コードはReact / Next.js(App Router)を前提にしています。
※この記事は防御を目的とした内容です。
Step1. 前提:ブラウザに届いたものは、すべて見えている
一般ユーザーは画面を見ます。攻撃者はまずChrome DevToolsを開き、Network / Application / Sources を見ます。
ここで押さえるべき原則は1つだけです。
ブラウザに届いた情報は、100%攻撃者にも届いている。
だから、フロントエンドのセキュリティは「見せない工夫」では成立しません。難読化・URL秘匿・ボタン非表示は、どれも時間稼ぎにすらならないと考えてください。
✅ 直すべきこと
- 「ブラウザに送る = 全世界に公開する」と同義だと考えて設計する。
- 秘匿で守ろうとしている箇所があれば、それは認証・認可・入力検証などサーバー側の制御に置き換える。
以降のStepは、すべてこの原則の各論です。
Step2. APIは隠せない。守るのはURLではなく認可。
攻撃者はここを見る
Networkタブを開けば、アプリが叩いているAPIは丸見えです。
GET /api/profile
DELETE /api/admin/users/123
/api/admin/... の存在自体は隠せません。重要なのは「URLが見えること」ではなく「見えても実行できないこと」です。
✅ 直すべきこと
すべてのエンドポイントで、サーバー側が「誰が」「何をしてよいか」を検証します。フロントから呼んでいるかどうかは無関係です(攻撃者は直接叩きます)。
// app/api/admin/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getSession(req);
// 1. 認証:ログインしているか
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 2. 認可:この操作をする権限があるか
if (session.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await deleteUser(params.id);
return NextResponse.json({ ok: true });
}
ポイントは、認証(401)と認可(403)を分けて必ず両方書くことです。ログインさえしていれば通る、という実装が一番危険です。
Step3. バンドルに管理機能のヒントは残る。だから難読化に頼らない。
攻撃者はここを見る
UIにリンクが無くても、JSバンドルには痕跡が残ります。
fetch("/api/admin/users")
axios.post("/api/admin/delete")
Ctrl + Shift + F で admin debug internal を検索すれば、UIから到達できない機能が見つかることは珍しくありません。「ボタンが無い」は「機能が無い」を意味しません。
✅ 直すべきこと
- 難読化・minifyは対策ではないと割り切る(守るのはStep2のサーバー側認可)。
- バンドルに秘密情報・内部コメント・デバッグコードを載せない。ビルド時に落とす。
// next.config.js
module.exports = {
compiler: {
// 本番ビルドから console.* を除去(error は残す例)
removeConsole: process.env.NODE_ENV === "production"
? { exclude: ["error"] }
: false,
},
};
Step4. Source Mapは「設計図」。本番で配らない。
攻撃者はここを見る
/main.js.map にアクセスして取得できれば、元の変数名・ディレクトリ構成・コメントまで読める、より上等な設計図が手に入ります。
✅ 直すべきこと
Next.jsでは productionBrowserSourceMaps はデフォルト false です。つまり本番でSource Mapが取れるなら、意図的に有効化しているか設定ミスのどちらかです。
// next.config.js
module.exports = {
productionBrowserSourceMaps: false, // 明示しておく
};
そして設定を信じきらず、本番URLで実際に .map を叩いて404を確認します。CDNや静的ホスティング側が別途 .map を配信しているケースがあるためです。
Step5. 認可の本丸:BOLA(Broken Object Level Authorization)
ここがこの記事で一番伝えたい部分です。
攻撃者はここを見る
GET /api/users/123
を見たら、攻撃者が考えることは1つです。
「123を124に変えたら、他人のデータが取れる?」
取れてしまえば BOLA。OWASP API Security Top 10 で長年上位に居座る、最も踏まれている認可不備です。
重要なのは、認証は突破されていないことです。攻撃者は正規ユーザーとしてログインしています。壊れているのは「このユーザーは、このオブジェクトにアクセスしてよいか」というオブジェクト単位の認可です。
なお、REST の連番IDは分かりやすい例ですが、GraphQLではより深刻になり得ます。1リクエストで複数オブジェクトやネストした関連データをまとめて取得できるため、リゾルバ1箇所の認可漏れが芋づる式の情報漏洩につながります。「エンドポイント単位」ではなく「フィールド/オブジェクト単位」で誰がアクセスしてよいかを考える必要があります。
✅ 直すべきこと
「ログインしているか」ではなく「このオブジェクトの所有者か」を毎回サーバーで検証します。
// REST:所有者チェックを必ず入れる
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getSession(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await db.user.findUnique({ where: { id: params.id } });
// ❌ if (user) return user; ← これがBOLA
// ✅ 「リクエスト元 === 対象の所有者」を検証
if (!user || user.id !== session.userId) {
// 存在を隠すため 403 ではなく 404 を返す設計もある
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(user);
}
GraphQLなら、リゾルバ単位で同じ所有者チェックを書きます。
const resolvers = {
Query: {
order: async (_, { id }, { user }) => {
const order = await db.order.findUnique({ where: { id } });
if (!order || order.userId !== user.id) {
throw new GraphQLError("Not found", {
extensions: { code: "NOT_FOUND" },
});
}
return order;
},
},
};
補足として、ID採番を連番からUUIDへ変えるのは列挙(enumeration)をやりにくくする"緩和"であって、根本対策ではありません。UUIDでも所有者チェックが無ければBOLAは成立します。必ず認可そのものを実装してください。
Step6. 「管理者ボタンを隠す」はUXであって、防御ではない
攻撃者はここを見る
{user.role === "admin" && <AdminButton />}
これはUIとして正しい実装です。しかしボタンを消しても、Step2〜4で見たとおりAPIの存在は割れます。「表示しない」と「実行できない」は別物です。
✅ 直すべきこと
フロントの role 判定はUX(不要なボタンを出さない)専用と割り切り、同じ認可を必ずAPI側にも書く(二重化)。フロントの判定はいつでも改変・スキップされる前提です。
さらにNext.js特有の落とし穴として、middleware(新しめのバージョンでは proxy.ts)だけを認可の砦にしないでください。過去に middleware の認可チェックをバイパスできる脆弱性が実際に複数報告されています(例:CVE-2025-29927 の x-middleware-subrequest ヘッダによるバイパス)。middleware はリダイレクトやヘッダ付与などの用途に留め、認可はデータに触れる境界(API Route / リゾルバ)で必ず再検証するのが安全です。
// ❌ 危険:middleware だけで認可を完結させる
// export function middleware(req) {
// if (!isAdmin(req)) return NextResponse.redirect("/login");
// }
// ✅ middleware は補助。実際の認可は Step2/Step5 のように
// API Route / リゾルバ側で必ずやり直す。
Step7. Cookie / Storage:JWTの置き場所と、その次に来るCSRF
攻撃者はここを見る
Applicationタブで Local Storage / Session Storage / Cookie を確認します。JWTが Local Storage にあり、かつXSSが成立すれば、トークンはJavaScriptから読めてしまいます。
✅ 直すべきこと
トークンは HttpOnly Cookie に置き、JavaScriptから触れないようにします。属性は独立した3つではなく、1つのCookieに付ける3属性です。
Set-Cookie: token=...; HttpOnly; Secure; SameSite=Lax; Path=/
-
HttpOnly:JSから読めない(XSSでのトークン窃取を防ぐ) -
Secure:HTTPSのみ送信 -
SameSite=Lax:他サイトからのリクエストにCookieを原則付けない(CSRFの一次防御)
ただし、Cookie認証にすると今度はCSRFが顔を出します。SameSite=Lax を一次防御にしつつ、状態変更を伴う操作にはCSRFトークンを併用する、という多層で守ります。
1つの対策を入れると、次に考えるべき対策が変わる。これがセキュリティが"連鎖"であるということです。
Step8. XSSを塞ぐ:Next.jsでのCSP(nonce方式)実装
Step7の「トークンを盗ませない」の大前提は、そもそもXSSを起こさないことです。Reactは {value} を自動エスケープしますが、dangerouslySetInnerHTML を使う箇所や、防御の最終ラインとしてCSPを張ります。
注意:script-src 'self' をそのまま貼るとNext.jsは壊れます。 hydration用のインラインスクリプトが弾かれるためです。Next.jsではnonce方式を使います。middleware(proxy)でリクエストごとにnonceを生成し、CSPヘッダに乗せます。
// middleware.ts (新しいバージョンでは proxy.ts)
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// リクエストごとに一意なnonceを生成(毎回変わる必要がある)
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
.replace(/\s{2,}/g, " ")
.trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", cspHeader);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", cspHeader);
return response;
}
// prefetch や静的アセットは除外(nonce方式の相性問題を避ける)
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};
生成したnonceは、Server Componentで受け取ってインラインスクリプトに渡します。
// app/layout.tsx
import { headers } from "next/headers";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="ja">
<body>{children}</body>
{/* インラインスクリプトを使う場合は nonce を付ける */}
{/* <script nonce={nonce}>...</script> */}
</html>
);
}
補足:nonce方式にも運用上の注意があります。共有キャッシュ(CDN)の背後では、過去に不正なnonceがキャッシュ汚染に悪用され得るXSS advisoryが出ています。Next.jsは新しいバージョンで対策済みなので、フレームワーク自体を最新に保つことも防御の一部です。
dangerouslySetInnerHTML がどうしても必要な箇所は、描画前に必ずサニタイズします。
import DOMPurify from "isomorphic-dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
Step9. robots.txt / sitemap.xml:これは「案内板」であって「鍵」ではない
攻撃者はここを見る
Disallow: /admin
Disallow: /internal
robots.txtは検索エンジンへの"お願い"であり、アクセス制御ではありません。むしろ隠したい場所を教えてしまいます。sitemap.xmlも同様にページ構成のヒントになります。
✅ 直すべきこと
- 「robots.txtに書いた=守った」と絶対に勘違いしない。
- 保護したいパスは、URLを知っていてもアクセスできないようStep2/Step5の認可で守る。robots.txtは秘匿ではなくクロール制御の道具と割り切る。
まとめ:直すべきことリスト
攻撃者視点を通すと、フロントエンドで本当にやるべきことは実はシンプルです。
-
認可はサーバー側で必ず実装する(Step2)。フロントの
role判定はUX専用(Step6)。 - オブジェクト単位の所有者チェックを毎回書く(Step5)。REST/GraphQL問わずBOLAを塞ぐ。
- middleware/proxyだけを認可の砦にしない。データ境界で再検証する(Step6)。
- トークンは HttpOnly Cookie、そのうえでCSRF対策を重ねる(Step7)。
-
XSSはCSP(nonce方式)で最終防御、
dangerouslySetInnerHTMLはサニタイズ必須(Step8)。 - Source Map・秘密情報・デバッグコードをブラウザに送らない(Step3・Step4)。
- 秘匿(隠す)を防御と勘違いしない(Step1・Step9)。
共通する原則は最初に書いたとおりです。
攻撃者は「隠されているもの」を探すのではなく、「ブラウザから見えるもの」を組み合わせて全体像を把握する。
だからこそ、守り方は「隠す」ではなく「見えていても実行させない」——認証・認可・入力検証・CSPを、サーバー側を最終ラインとして積み重ねることに尽きます。
「攻撃者はどう考えるか」を知ることは、「どこを直すべきか」を理解する一番の近道です。ぜひ一度、ご自身のアプリを「最初の5分で何を見られるか」という目で眺めてみてください。