Atsulog#004
停滞していたAtsulogシリーズを再開
今回はセキュリティについて扱います.
認証方式
書き手は自分 1 人だけなので,ユーザー登録やユーザー ID は持っていません.環境変数 ADMIN_PASSWORD に設定したパスワード 1 つでログインし,ログイン状態は署名付きの JWT を Cookie に入れて保持します.環境変数とは,プログラムの外から与える設定値で,パスワードのような秘密の値をコードに書かずに済みます.JWT(JSON Web Token)とは,データに改ざんを検出するための署名を付けた文字列で,サーバー自身が発行したものかどうかを確かめられます.Cookie とは,サーバーがブラウザに保存させ,以後のリクエストに自動で付けて送らせる小さなデータです.

ログインからページ表示までの流れは次のとおりです.
sequenceDiagram
participant B as Browser
participant S as Server
B->>S: POST password
Note over S: loginAction<br>Check if locked<br>Compare password<br>Sign JWT
S-->>B: Set-Cookie (httpOnly)
B->>S: GET /edit (Cookie: JWT)
Note over S: proxy.ts<br>Verify JWT
S-->>B: 200, or 302 to /login on failure
JWT
セッションは jose ライブラリで署名した JWT です.実装は app/lib/auth/auth.ts にあります.
// 共通鍵を文字列からバイト列に変換する
const SECRET_KEY = new TextEncoder().encode(process.env.SESSION_SECRET);
export async function createSession() {
const expires = new Date(Date.now() + SESSION_DURATION_MS); // セッションの有効期限
const session = await new SignJWT({ authenticated: true }) // ペイロードはこれだけ
.setProtectedHeader({ alg: "HS256" }) // 署名方式を HS256 にする
.setIssuedAt() // 発行時刻 (iat) を入れる
.setExpirationTime(expires) // 有効期限 (exp) を入れる
.sign(SECRET_KEY); // 共通鍵で署名し,JWT 文字列にする
// JWT を Cookie としてブラウザに保存させる
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, session, {
httpOnly: true, // JavaScript から読めなくする
secure: process.env.NODE_ENV === "production", // 本番では HTTPS のときだけ送る
expires,
sameSite: "lax", // 別サイトからの POST には付けない
path: "/", // サイト全体で送る
});
}
ペイロード は { authenticated: true } だけです.ペイロードとは,JWT に入れて運ぶデータの本体です.ユーザーが 1 人なので,「ログインしているかどうか」さえわかれば十分だからです.
署名には HS256(HMAC-SHA256)を使っています.HMAC とは,鍵とデータから,ハッシュ関数(ここでは SHA-256)を使って署名の値を計算する方式です.検証するときも同じ鍵で署名の値を計算し直し,JWT に付いている値と一致するかを比べます.鍵を知らない人は正しい署名の値を作れないので,ペイロードを書き換えると検証で見つかります.なお,HMAC は厳密には電子署名ではなくメッセージ認証コード(MAC)ですが,JWT では慣習的に署名と呼びます.
署名の方式は,鍵の使い方によって次の 2 種類に分かれます.
共通鍵方式 (HS256 など)
発行側 --[共通鍵で署名]--> JWT --[同じ共通鍵で検証]--> 検証側
検証できる人は,署名もできる
公開鍵方式 (RS256 など)
発行側 --[秘密鍵で署名]--> JWT --[公開鍵で検証]--> 検証側
公開鍵は配ってよく,持っていても署名はできない
- 共通鍵方式:署名と検証に同じ鍵を使う.この鍵は両方の側で秘密にしておく
- 公開鍵方式:署名には 秘密鍵 を,検証にはそれと対になる 公開鍵 を使う.公開鍵暗号とも呼ばれる
公開鍵方式は,認証サーバーが発行した JWT をほかのサービスが検証する場合のように,発行する側と検証する側が別のときに向いています.Atsulog では,発行するのも検証するのも同じサーバーなので,共通鍵 SESSION_SECRET を 1 つ持つだけで済む HS256 を選んでいます.
Cookie には次の属性を付けています.
httpOnly:JavaScript から Cookie を読めなくし,XSS(ページに悪意のあるスクリプトを埋め込んで実行させる攻撃)でトークンを盗まれにくくするsecure:HTTPS のときだけ送る.ローカルのhttp://localhostで開発できるよう,本番環境でのみ有効にしているsameSite: "lax":別サイトからの POST などには Cookie を付けず,CSRF(別のサイトから,ログイン中のユーザーになりすましてリクエストを送らせる攻撃)の危険を減らすexpires:有効期限は 24 時間で固定.アクセスのたびに期限を延ばす処理はない
検証側では,受け付けるアルゴリズムを HS256 に固定しています.
/** Verifies a session JWT; returns its payload, or null if invalid. */
export async function verifySession(session: string | undefined) {
if (!session) return null; // Cookie がなければ未ログイン
try {
const { payload } = await jwtVerify(session, SECRET_KEY, {
algorithms: ["HS256"], // HS256 以外の JWT は受け付けない
});
return payload; // 署名と期限が正しければ中身を返す
} catch {
// 署名が不正,または期限切れ
return null;
}
}
JWT のヘッダーにはアルゴリズム名(alg)が書かれていますが,これは攻撃者が自由に書き換えられます.許可するアルゴリズムを検証側で決めておくことで,alg を none などにすり替える攻撃を防げます.署名が不正なときも期限切れのときも jwtVerify が例外を投げるので,null(未ログイン)として扱います.
JWT を使うと,サーバー側にセッションを保存しなくて済みます.ログイン状態を確かめるたびにデータベースを引く必要がなく,proxy.ts でも署名を検証するだけで判定できます.その代わり,ログアウトは Cookie を消すだけで,トークン自体を期限前に無効にする仕組みはありません.
パスワードの比較
パスワードの比較は app/lib/auth/auth-actions.ts にあります.
/** Hashed first, as timingSafeEqual needs equal lengths. */
function passwordMatches(password: string): boolean {
const expected = process.env.ADMIN_PASSWORD;
if (!expected) return false; // 未設定なら誰もログインできない
// 文字列を SHA-256 で 32 バイトのバッファにする関数
const digest = (value: string) => createHash("sha256").update(value).digest();
return timingSafeEqual(digest(password), digest(expected)); // 一定時間で比較する
}
文字列を === で比べると,先頭から 1 文字ずつ比べて違いが見つかった時点で終わるため,一致している文字数によって処理時間がわずかに変わります.この差を測って正解を推測するのが タイミング攻撃 です.Node.js の crypto.timingSafeEqual は,内容に関係なく一定の時間で比較します.
ただし timingSafeEqual は,長さの違うバッファを渡すと例外を投げます.そこで両方を先に SHA-256 で ハッシュ し,どちらも 32 バイトにそろえてから比較しています.ハッシュとは,任意の長さのデータから決まった長さの値を計算する処理で,同じ入力からは必ず同じ値が得られます.
ログイン試行の制限
stateDiagram-v2
state "No record" as NoRecord
state "Failing (1–4)" as Failing
state "Locked" as Locked
[*] --> NoRecord
NoRecord --> NoRecord: Success
NoRecord --> Failing: 1st failure
Failing --> Failing: Failure
Failing --> Locked: 5th failure
Failing --> NoRecord: Success
Failing --> NoRecord: 15 min passed
Locked --> Locked: Rejected
Locked --> NoRecord: 15 min passed
総当たり攻撃 とは,パスワードの候補を片端から試す攻撃です.その対策として,同じクライアントから 5 回続けて失敗すると,15 分間ログインできなくなります.回数と時間は site-config.ts の loginMaxFailures と loginLockMinutes で設定しています.
/** Failed attempts per client, in memory: a restart clears them. */
const failures = new Map<string, { count: number; resetAt: number }>();
export async function loginAction(formData: FormData) {
const client = await clientAddress(); // 接続元の IP アドレス
if (isLocked(client)) {
// 失敗が続いてロック中なら,パスワードを見ずに断る
redirect("/login?error=locked");
}
const password = formData.get("password");
// フォームの値はファイルの場合もあるので,文字列かどうかを先に確かめる
if (typeof password === "string" && passwordMatches(password)) {
failures.delete(client); // 成功したら失敗回数を消す
await createSession();
// redirect は例外を投げて処理を止めるので,下の行には進まない
redirect("/edit");
}
recordFailure(client); // 失敗を 1 回数える
redirect("/login?error=invalid");
}
失敗の記録はメモリ上の Map に持っているだけなので,サーバーを再起動すると消えます.記録が 10,000 件を超えたときには,期限切れのものをまとめて削除し,メモリを使い続けないようにしています.
クライアントの識別には,X-Forwarded-For ヘッダーの一番右の値を使います.リバースプロキシ とは,ブラウザとアプリの間でリクエストを中継するサーバーです.X-Forwarded-For ヘッダーには,中継したサーバーが接続元の IP アドレスを順に書き足していきます.
/** The rightmost X-Forwarded-For entry, the one the reverse proxy appends. */
async function clientAddress(): Promise<string> {
const forwarded = (await headers()).get("x-forwarded-for"); // ヘッダーがなければ null
// カンマで分割 → 最後の要素 → 前後の空白を除く.
// ?. は途中で値がなければ undefined を返し,そのときは || で "unknown" にする
return forwarded?.split(",").at(-1)?.trim() || "unknown";
}
このヘッダーはクライアントが自由に付けられるため,左側の値は偽装できます.一方,一番右の値は手前のリバースプロキシが接続元の IP アドレスとして追記したものなので,信用できます.
proxy.ts によるページの保護
/edit 以下のページは,リポジトリ直下の proxy.ts で守っています.Next.js 16 では,これまでの middleware.ts が非推奨になり,proxy.ts という名前に変わりました.あわせて,デフォルトの実行環境も Edge Runtime ではなく Node.js Runtime になっています.ミドルウェア とは,ページを返す前にリクエストを受け取り,処理を挟む仕組みです.Edge Runtime は使える機能を絞った軽量な実行環境で,Node.js Runtime は Node.js の機能をすべて使える通常の実行環境です.
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; // URL のパス部分(例: "/edit/hello")
const isProtectedRoute = pathname.startsWith("/edit"); // ログインが必要なページ
const isPublicAuthRoute = pathname === "/login"; // ログイン画面
// Cookie の JWT を検証する.!! で結果を true / false に変換
const isAuthenticated = !!(await verifySession(
request.cookies.get(siteConfig.cookie)?.value,
));
// 未ログインで管理画面 → ログイン画面へ
if (isProtectedRoute && !isAuthenticated) {
return NextResponse.redirect(new URL("/login", request.url));
}
// ログイン済みでログイン画面 → 管理画面へ
if (isPublicAuthRoute && isAuthenticated) {
return NextResponse.redirect(new URL("/edit", request.url));
}
return NextResponse.next(); // それ以外はそのままページを表示
}
export const config = {
matcher: [
// Skip Next.js internals and the favicon.
// api, _next/static, _next/image, favicon.ico で始まらないパスすべてに一致
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
未ログインで /edit を開くとログイン画面へ,ログイン済みで /login を開くと管理画面へ リダイレクト します.リダイレクトとは,ブラウザを別の URL へ移動させることです.matcher の正規表現は否定先読 (?!...) を使い,_next/static などの静的ファイルへのリクエストでは proxy を動かさないようにしています.否定先読みとは,「その位置の後ろに〜が続かない」という条件を表す正規表現の書き方です.
Server Action の中でも確認する
記事の保存や画像のアップロードは Server Action(Server Function)で実装しています.Server Action とは,フォームの送信などをきっかけにサーバー上で実行される関数です.Server Action はブラウザから直接 POST で呼べる公開エンドポイント なので,proxy.ts だけに頼らず,関数の中でも 認証 を確認しています.エンドポイントとは,外部からリクエストを受け付ける窓口となる URL のことです.
確認の処理は,app/lib/auth/auth.ts の requireAuth にまとめています.
/** Throws unless signed in. Actions are public endpoints; proxy.ts alone is not enough. */
export async function requireAuth() {
if (!(await isAuthenticated())) {
throw new Error("Unauthorized"); // 未ログインなら,ここで処理を中断
}
}
各 Server Action では,最初にこれを呼びます.
async function handleSave(formData: FormData) {
"use server"; // この関数を,サーバーで実行する Server Action にする
await requireAuth(); // 保存処理の前に,必ずログインを確認
// ...
}
Next.js の公式ドキュメントでも,matcher の変更や Server Function を別のルートへ移すリファクタリングによって,proxy の保護が気づかないうちに外れることがあると注意されています.そのため,各 Server Function の中で認証と認可を確認することが推奨されています.認証とは「誰がアクセスしているか」を確かめること,認可とは「その人がその操作をしてよいか」を確かめることです.このアプリでも,記事の作成(app/edit/create/page.tsx),更新(app/edit/[slug]/page.tsx),画像のアップロード(app/lib/image/image-actions.ts)のすべてで,最初に requireAuth() を呼んでいます.
flowchart TD
R["Request"] --> M{"Matches matcher?"}
M -->|Yes| P{"proxy.ts<br>Logged in?"}
P -->|No| L["Redirect to /login"]
M -->|No| SA
P -->|Yes| SA["Server Action"]
SA --> Q{"requireAuth()<br>Logged in?"}
Q -->|No| E["Abort with Unauthorized"]
Q -->|Yes| OK["Save, etc."]
最後に
セキュリティは完全に専門外なので,もしかしたら見落としている点があるかもしれません.というかあると思います.もし脆弱性を発見しても,決してハッキングせずちゃんと僕に教えてください.