APIセキュリティベストプラクティス:全プロジェクトで実行するチェックリスト
認証、認可、入力バリデーション、レート制限、CORS、シークレット管理、OWASP API Top 10。本番デプロイ前に毎回チェックする項目をまとめました。
セキュリティが完全に抜け落ちたAPIをリリースしたことがある。悪意からでも、手抜きからでもない — 単に知らないことを知らなかっただけだ。ハッシュ化されたパスワードを含むユーザーオブジェクトの全フィールドを返すエンドポイント。IPアドレスのみをチェックするレートリミッターで、プロキシの背後にいる人なら誰でもAPIを叩き放題だった。issクレームの検証を忘れたJWT実装では、まったく別のサービスのトークンが問題なく通ってしまった。
これらのミスはすべて本番環境にリリースされた。そしてすべて発見された — 自分で見つけたもの、ユーザーに報告されたもの、そしてある親切なセキュリティ研究者がTwitterに投稿するのではなくメールで知らせてくれたものもあった。
この記事は、それらのミスから作り上げたチェックリストだ。本番デプロイの前に毎回このリストを確認する。被害妄想ではなく、セキュリティバグが最も痛手になることを学んだからだ。壊れたボタンはユーザーを苛立たせる。壊れた認証フローはユーザーのデータを漏洩させる。
認証 vs 認可#
この2つの言葉は会議でもドキュメントでもコードコメントでも混同して使われる。しかし同じものではない。
認証(Authentication) が答えるのは「あなたは誰ですか?」という問いだ。ログインのステップだ。ユーザー名とパスワード、OAuthフロー、マジックリンク — 身元を証明する手段は何であれ認証だ。
認可(Authorization) が答えるのは「あなたには何が許可されていますか?」という問いだ。権限のステップだ。このユーザーはこのリソースを削除できるか?この管理エンドポイントにアクセスできるか?他のユーザーのデータを読めるか?
本番APIで最も多く見てきたセキュリティバグは、壊れたログインフローではない。欠落した認可チェックだ。ユーザーは認証されている — 有効なトークンを持っている — しかしAPIはリクエストしているアクションを実行する権限があるかどうかを確認していない。
JWT:構造と重要なミス#
JWTはあらゆる場所で使われている。そしてあらゆる場所で誤解されている。JWTはドットで区切られた3つのパートで構成される:
header.payload.signature
ヘッダーは使用されたアルゴリズムを示す。ペイロードにはクレーム(ユーザーID、ロール、有効期限)が含まれる。署名は最初の2つのパートが改ざんされていないことを証明する。
Node.jsでの適切なJWT検証は以下の通りだ:
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
interface TokenPayload {
sub: string;
role: "user" | "admin";
iss: string;
aud: string;
exp: number;
iat: number;
jti: string;
}
function verifyToken(token: string): TokenPayload {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ["HS256"], // "none"は絶対に許可しない
issuer: "api.yourapp.com",
audience: "yourapp.com",
clockTolerance: 30, // クロックスキューに対する30秒の許容範囲
}) as TokenPayload;
return payload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new ApiError(401, "Token expired");
}
if (error instanceof jwt.JsonWebTokenError) {
throw new ApiError(401, "Invalid token");
}
throw new ApiError(401, "Authentication failed");
}
}いくつか注目すべき点がある:
-
algorithms: ["HS256"]— これは極めて重要だ。アルゴリズムを指定しないと、攻撃者はヘッダーに"alg": "none"を含むトークンを送信し、検証をスキップできる。これがalg: none攻撃であり、実際の本番システムに影響を与えたことがある。 -
issuerとaudience— これらがないと、サービスA用に発行されたトークンがサービスBで機能する。同じシークレットを共有する複数のサービスを運用している場合(すべきではないが、実際にやる人はいる)、これがクロスサービストークン悪用の経路になる。 -
個別のエラーハンドリング — すべての失敗に対して
"invalid token"を返さないこと。期限切れと不正を区別することで、クライアントはリフレッシュすべきか再認証すべきかを判断できる。
リフレッシュトークンローテーション#
アクセストークンは短命であるべきだ — 15分が標準的だ。しかしユーザーに15分ごとにパスワードを再入力させたくはない。そこでリフレッシュトークンの出番だ。
本番環境で実際に機能するパターン:
import { randomBytes } from "crypto";
import { redis } from "./redis";
interface RefreshTokenData {
userId: string;
family: string; // ローテーション検出のためのトークンファミリー
createdAt: number;
}
async function rotateRefreshToken(
oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
if (!tokenData) {
// トークンが見つからない — 期限切れか、すでに使用済み。
// すでに使用済みの場合、リプレイ攻撃の可能性がある。
// トークンファミリー全体を無効化する。
const parsed = decodeRefreshToken(oldRefreshToken);
if (parsed?.family) {
await invalidateTokenFamily(parsed.family);
}
throw new ApiError(401, "Invalid refresh token");
}
const data: RefreshTokenData = JSON.parse(tokenData);
// 古いトークンを即座に削除 — 一回限りの使用
await redis.del(`refresh:${oldRefreshToken}`);
// 新しいトークンを生成
const newRefreshToken = randomBytes(64).toString("hex");
const newAccessToken = generateAccessToken(data.userId);
// 同じファミリーで新しいリフレッシュトークンを保存
await redis.setex(
`refresh:${newRefreshToken}`,
60 * 60 * 24 * 30, // 30日
JSON.stringify({
userId: data.userId,
family: data.family,
createdAt: Date.now(),
})
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
async function invalidateTokenFamily(family: string): Promise<void> {
// このファミリーのすべてのトークンをスキャンして削除する。
// これは最終手段 — リフレッシュトークンがリプレイされた場合、
// ファミリー内のすべてのトークンを無効化し、再認証を強制する。
const keys = await redis.keys(`refresh:*`);
for (const key of keys) {
const data = await redis.get(key);
if (data) {
const parsed = JSON.parse(data) as RefreshTokenData;
if (parsed.family === family) {
await redis.del(key);
}
}
}
}トークンファミリーの概念がセキュリティの要だ。すべてのリフレッシュトークンはファミリーに属する(ログイン時に作成される)。ローテーション時に新しいトークンはファミリーを継承する。攻撃者が古いリフレッシュトークンをリプレイすると、再利用を検出してファミリー全体を無効化する。正規ユーザーはログアウトされるが、攻撃者は侵入できない。
トークンの保存先:httpOnly Cookie vs localStorage論争#
この議論は何年も続いているが、答えは明確だ:リフレッシュトークンにはhttpOnly Cookieを使う。常にだ。
localStorageはページ上で実行されるあらゆるJavaScriptからアクセス可能だ。XSS脆弱性が1つでもあれば — 規模が大きくなれば遅かれ早かれ発生する — 攻撃者はトークンを読み取って持ち出せる。ゲームオーバーだ。
httpOnly Cookieはjavascriptからアクセスできない。XSS脆弱性があっても、ユーザーの代わりにリクエストを送信できるが(Cookieは自動的に送信されるため)、攻撃者はトークン自体を盗むことはできない。これは大きな違いだ。
// セキュアなリフレッシュトークンCookieの設定
function setRefreshTokenCookie(res: Response, token: string): void {
res.cookie("refresh_token", token, {
httpOnly: true, // JavaScriptからアクセス不可
secure: true, // HTTPSのみ
sameSite: "strict", // クロスサイトリクエスト不可
maxAge: 30 * 24 * 60 * 60 * 1000, // 30日
path: "/api/auth", // 認証エンドポイントにのみ送信
});
}path: "/api/auth"は多くの人が見落とすディテールだ。デフォルトでは、Cookieはドメイン上のすべてのエンドポイントに送信される。リフレッシュトークンは/api/usersや/api/productsに送る必要はない。パスを制限して攻撃面を減らそう。
アクセストークンについては、メモリ内(JavaScript変数)に保持する。localStorageでも、sessionStorageでも、Cookieでもない。メモリ内だ。短命(15分)であり、ページが更新されるとクライアントはサイレントにリフレッシュエンドポイントにアクセスして新しいトークンを取得する。はい、ページ読み込み時に追加のリクエストが発生する。その価値はある。
入力バリデーション:クライアントを信用するな#
クライアントは味方ではない。クライアントは家に入ってきて「ここにいていいんです」と言う見知らぬ人だ。身分証明書はどのみち確認する。
サーバーの外部から来るすべてのデータ — リクエストボディ、クエリパラメータ、URLパラメータ、ヘッダー — は信頼できない入力だ。Reactフォームにバリデーションがあっても関係ない。誰かがcurlでバイパスする。
Zodによる型安全なバリデーション#
ZodはNode.jsの入力バリデーションに起きた最良のことだ。ランタイムバリデーションとTypeScript型を無料で提供してくれる:
import { z } from "zod";
const CreateUserSchema = z.object({
email: z
.string()
.email("無効なメール形式です")
.max(254, "メールアドレスが長すぎます")
.transform((e) => e.toLowerCase().trim()),
password: z
.string()
.min(12, "パスワードは12文字以上である必要があります")
.max(128, "パスワードが長すぎます") // bcrypt DoS防止
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"パスワードには大文字、小文字、数字を含める必要があります"
),
name: z
.string()
.min(1, "名前は必須です")
.max(100, "名前が長すぎます")
.regex(/^[\p{L}\p{M}\s'-]+$/u, "名前に無効な文字が含まれています"),
role: z.enum(["user", "editor"]).default("user"),
// 注意:"admin"は意図的にオプションから除外。
// 管理者ロールの割り当ては別の特権エンドポイントを通じて行う。
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Expressハンドラーでの使用例
app.post("/api/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "バリデーション失敗",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
});
}
// result.dataはCreateUserInput型として完全に型付けされている
const user = await createUser(result.data);
return res.status(201).json({ id: user.id, email: user.email });
});セキュリティに関連するいくつかの詳細:
- パスワードの
max(128)— bcryptには72バイトの入力制限があり、一部の実装は暗黙的に切り詰める。しかしより重要なのは、10MBのパスワードを許可するとbcryptがハッシュ化に多大な時間を費やすことだ。これはDoSベクターになる。 - メールの
max(254)— RFC 5321はメールアドレスを254文字に制限している。それより長いものは有効なメールではない。 - adminなしのロールEnum — マスアサインメントは最も古いAPI脆弱性の1つだ。バリデーションなしでリクエストボディからロールを受け入れると、誰かが
"role": "admin"を送信して運を試す。
SQLインジェクションは解決済みではない#
「ORMを使えばいい」は、パフォーマンスのために生のクエリを書く場合には通用しない。そしてパフォーマンスのために生のクエリを書くことは誰もがいずれ経験する。
// 脆弱 — 文字列連結
const query = `SELECT * FROM users WHERE email = '${email}'`;
// 安全 — パラメータ化クエリ
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);Prismaを使えばほぼ安全だが、$queryRawには注意が必要だ:
// 脆弱 — $queryRaw内のテンプレートリテラル
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
// 安全 — パラメータ化のためにPrisma.sqlを使用
import { Prisma } from "@prisma/client";
const users = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);NoSQLインジェクション#
MongoDBはSQLを使わないが、インジェクションに対して免疫があるわけではない。未サニタイズのユーザー入力をクエリオブジェクトとして渡すと問題が起きる:
// 脆弱 — req.body.usernameが{ "$gt": "" }の場合
// コレクション内の最初のユーザーを返す
const user = await db.collection("users").findOne({
username: req.body.username,
});
// 安全 — 明示的に文字列に変換
const user = await db.collection("users").findOne({
username: String(req.body.username),
});
// より良い方法 — 先にZodでバリデーション
const LoginSchema = z.object({
username: z.string().min(1).max(50),
password: z.string().min(1).max(128),
});修正はシンプルだ:データベースドライバーに到達する前に入力の型をバリデーションする。usernameが文字列であるべきなら、文字列であることをアサートする。
パストラバーサル#
APIがファイルを配信するか、ユーザー入力を含むパスから読み取る場合、パストラバーサルは大問題になる:
import path from "path";
import { access, constants } from "fs/promises";
const ALLOWED_DIR = "/app/uploads";
async function resolveUserFilePath(userInput: string): Promise<string> {
// 正規化して絶対パスに解決
const resolved = path.resolve(ALLOWED_DIR, userInput);
// 重要:解決されたパスが許可ディレクトリ内にあることを検証
if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
throw new ApiError(403, "Access denied");
}
// ファイルが実際に存在することを検証
await access(resolved, constants.R_OK);
return resolved;
}
// このチェックがなければ:
// GET /api/files?name=../../../etc/passwd
// は /etc/passwd に解決されるpath.resolve + startsWithパターンが正しいアプローチだ。手動で../を除去しようとしてはいけない — 正規表現をバイパスするエンコーディングのトリック(..%2F、..%252F、....//)が多すぎる。
レート制限#
レート制限がなければ、APIはボットにとっての食べ放題ビュッフェだ。ブルートフォース攻撃、クレデンシャルスタッフィング、リソース枯渇 — レート制限はこれらすべてに対する最初の防御線だ。
トークンバケット vs スライディングウィンドウ#
トークンバケット:N個のトークンを保持するバケットがある。各リクエストは1つのトークンを消費する。トークンは固定レートで補充される。バケットが空になるとリクエストは拒否される。バーストが可能 — バケットが満杯なら、N個のリクエストを即座に送信できる。
スライディングウィンドウ:移動する時間ウィンドウ内のリクエスト数をカウントする。より予測可能で、バーストが通りにくい。
ほとんどの場合でスライディングウィンドウを使っている。動作が推論しやすく、チームに説明しやすいからだ:
import { Redis } from "ioredis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
async function slidingWindowRateLimit(
redis: Redis,
key: string,
limit: number,
windowMs: number
): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = now - windowMs;
const multi = redis.multi();
// ウィンドウ外のエントリーを削除
multi.zremrangebyscore(key, 0, windowStart);
// ウィンドウ内のエントリー数をカウント
multi.zcard(key);
// 現在のリクエストを追加(制限超過なら後で削除)
multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
// キーの有効期限を設定
multi.pexpire(key, windowMs);
const results = await multi.exec();
if (!results) {
throw new Error("Redisトランザクションが失敗しました");
}
const count = results[1][1] as number;
if (count >= limit) {
// 制限超過 — 追加したエントリーを削除
await redis.zremrangebyscore(key, now, now);
return {
allowed: false,
remaining: 0,
resetAt: windowStart + windowMs,
};
}
return {
allowed: true,
remaining: limit - count - 1,
resetAt: now + windowMs,
};
}階層化されたレート制限#
1つのグローバルレート制限では不十分だ。異なるエンドポイントは異なるリスクプロファイルを持つ:
interface RateLimitConfig {
window: number;
max: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
// 認証エンドポイント — 厳格な制限、ブルートフォースの標的
"POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
"POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
"POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
// データ読み取り — より寛容
"GET:/api/users": { window: 60 * 1000, max: 100 },
"GET:/api/products": { window: 60 * 1000, max: 200 },
// データ書き込み — 中程度
"POST:/api/posts": { window: 60 * 1000, max: 10 },
"PUT:/api/posts": { window: 60 * 1000, max: 30 },
// グローバルフォールバック
"*": { window: 60 * 1000, max: 60 },
};
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
const identifier = req.user?.id ?? getClientIp(req);
const endpoint = `${req.method}:${req.path}`;
return `ratelimit:${identifier}:${endpoint}`;
}注目:認証済みユーザーはIPではなくユーザーIDでレート制限される。これは多くの正規ユーザーがIPを共有しているため重要だ(企業ネットワーク、VPN、モバイルキャリア)。IPのみで制限すると、オフィス全体をブロックしてしまう。
レート制限ヘッダー#
クライアントに常に状況を伝えよう:
function setRateLimitHeaders(
res: Response,
result: RateLimitResult,
limit: number
): void {
res.set({
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": result.remaining.toString(),
"X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
"Retry-After": result.allowed
? undefined
: Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
});
if (!result.allowed) {
res.status(429).json({
error: "Too many requests",
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
});
}
}CORS設定#
CORSはおそらくWeb開発で最も誤解されているセキュリティメカニズムだ。Stack Overflowの回答の半分は「Access-Control-Allow-Origin: *を設定すれば動く」と言っている。技術的には正しい。しかし同時にインターネット上のすべての悪意あるサイトにAPIを開放する方法でもある。
CORSが実際にすること(そして、しないこと)#
CORSはブラウザのメカニズムだ。オリジンAのJavaScriptがオリジンBからのレスポンスを読み取ることを許可するかどうかをブラウザに伝える。それだけだ。
CORSがしないこと:
- curl、Postman、サーバー間リクエストからAPIを保護しない
- リクエストを認証しない
- 何も暗号化しない
- 単独ではCSRFを防止しない(他のメカニズムと組み合わせると役立つが)
CORSがすること:
- malicious-website.comがyour-api.comにfetchリクエストを送信して、ユーザーのブラウザでレスポンスを読み取ることを防ぐ
- 攻撃者のJavaScriptが被害者の認証済みセッションを通じてデータを持ち出すことを防ぐ
ワイルドカードの罠#
// 危険 — あらゆるWebサイトがAPIレスポンスを読み取り可能
app.use(cors({ origin: "*" }));
// これも危険 — よくある「動的」アプローチだが、実質*と同じ
app.use(
cors({
origin: (origin, callback) => {
callback(null, true); // すべてを許可
},
})
);*の問題は、任意のページ上のあらゆるJavaScriptがAPIレスポンスを読み取れるようになることだ。APIがユーザーデータを返し、ユーザーがCookieで認証されている場合、ユーザーが訪問するあらゆるWebサイトがそのデータを読み取れる。
さらに悪いことに、Access-Control-Allow-Origin: *はcredentials: trueと組み合わせることができない。つまりCookie(認証用)が必要な場合、文字通りワイルドカードを使えない。しかしOriginヘッダーを反映して返すことでこれを回避しようとする人がいる — これはクレデンシャル付きの*と同等で、最悪の組み合わせだ。
正しい設定#
import cors from "cors";
const ALLOWED_ORIGINS = new Set([
"https://yourapp.com",
"https://www.yourapp.com",
"https://admin.yourapp.com",
]);
if (process.env.NODE_ENV === "development") {
ALLOWED_ORIGINS.add("http://localhost:3000");
ALLOWED_ORIGINS.add("http://localhost:5173");
}
app.use(
cors({
origin: (origin, callback) => {
// オリジンなしのリクエストを許可(モバイルアプリ、curl、サーバー間通信)
if (!origin) {
return callback(null, true);
}
if (ALLOWED_ORIGINS.has(origin)) {
return callback(null, origin);
}
callback(new Error(`Origin ${origin} not allowed by CORS`));
},
credentials: true, // Cookieを許可
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
maxAge: 86400, // プリフライトを24時間キャッシュ
})
);重要な決定事項:
- 明示的なオリジンのセット、正規表現ではない。正規表現はトリッキーだ —
yourapp.comは、正規表現が適切にアンカーされていないとevilyourapp.comにマッチする可能性がある。 credentials: trueリフレッシュトークンにhttpOnly Cookieを使うため。maxAge: 86400— プリフライトリクエスト(OPTIONS)はレイテンシを追加する。CORSの結果を24時間キャッシュするようブラウザに指示することで、不要なラウンドトリップを削減する。exposedHeaders— デフォルトでは、ブラウザは少数の「シンプルな」レスポンスヘッダーのみをJavaScriptに公開する。クライアントにレート制限ヘッダーを読み取らせたい場合、明示的に公開する必要がある。
プリフライトリクエスト#
リクエストが「シンプル」でない場合(非標準ヘッダー、非標準メソッド、または非標準コンテンツタイプを使用する場合)、ブラウザはまずOPTIONSリクエストを送信して許可を求める。これがプリフライトだ。
CORS設定がOPTIONSを処理しないと、プリフライトリクエストは失敗し、実際のリクエストは送信されない。ほとんどのCORSライブラリはこれを自動的に処理するが、処理しないフレームワークを使っている場合は自分で処理する必要がある:
// 手動のプリフライト処理(ほとんどのフレームワークはこれを自動で行う)
app.options("*", (req, res) => {
res.set({
"Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
});
res.status(204).end();
});セキュリティヘッダー#
セキュリティヘッダーは最もコストパフォーマンスの高いセキュリティ改善だ。ブラウザにセキュリティ機能を有効にするよう指示するレスポンスヘッダーだ。ほとんどが1行の設定で、攻撃のクラス全体を防御する。
重要なヘッダー#
import helmet from "helmet";
// 1行。Expressアプリで最速のセキュリティ改善。
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // 多くのCSS-in-JSソリューションに必要
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000, // 1年
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
})
);各ヘッダーの役割:
Content-Security-Policy(CSP) — 最も強力なセキュリティヘッダー。スクリプト、スタイル、画像、フォントなどに許可されるソースをブラウザに正確に伝える。攻撃者がevil.comから読み込む<script>タグを注入しても、CSPがブロックする。XSSに対する最も効果的な防御手段だ。
Strict-Transport-Security(HSTS) — ユーザーがhttp://と入力しても常にHTTPSを使うようブラウザに指示する。preloadディレクティブにより、ドメインをブラウザ組み込みのHSTSリストに登録でき、最初のリクエストからHTTPSが強制される。
X-Frame-Options — サイトがiframeに埋め込まれることを防ぐ。攻撃者が不可視の要素でページを覆うクリックジャッキング攻撃を阻止する。HelmetはデフォルトでSAMEORIGINに設定する。モダンな代替手段はCSPのframe-ancestorsだ。
X-Content-Type-Options: nosniff — ブラウザがレスポンスのMIMEタイプを推測(スニッフィング)することを防ぐ。これがないと、間違ったContent-Typeでファイルを配信した場合、ブラウザがJavaScriptとして実行する可能性がある。
Referrer-Policy — Refererヘッダーで送信されるURL情報の量を制御する。strict-origin-when-cross-originは同一オリジンリクエストにはフルURLを送信するが、クロスオリジンリクエストにはオリジンのみを送信する。これにより機密のURLパラメータがサードパーティに漏洩することを防ぐ。
ヘッダーのテスト#
デプロイ後、securityheaders.comでスコアを確認しよう。A+評価を目指すべきだ。そこに到達するのに約5分の設定で済む。
プログラムでヘッダーを検証することもできる:
import { describe, it, expect } from "vitest";
describe("セキュリティヘッダー", () => {
it("必要なセキュリティヘッダーがすべて含まれていること", async () => {
const response = await fetch("https://api.yourapp.com/health");
expect(response.headers.get("strict-transport-security")).toBeTruthy();
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
expect(response.headers.get("content-security-policy")).toBeTruthy();
expect(response.headers.get("referrer-policy")).toBeTruthy();
expect(response.headers.get("x-powered-by")).toBeNull(); // Helmetはこれを削除する
});
});x-powered-byチェックは微妙だが重要だ。ExpressはデフォルトでX-Powered-By: Expressを設定し、攻撃者にどのフレームワークを使っているか正確に教えてしまう。Helmetはこれを削除する。
シークレット管理#
これは当然のことだが、プルリクエストでまだ見かける:ソースファイルにハードコードされたAPIキー、データベースパスワード、JWTシークレット。あるいは.gitignoreに含まれていない.envファイルにコミットされたもの。一度gitの履歴に入ったら、次のコミットでファイルを削除しても永久にそこに残る。
ルール#
-
シークレットをgitにコミットしない。 コード内にも、
.envにも、設定ファイルにも、Docker Composeファイルにも、「テスト用」のコメントにも。 -
.env.exampleをテンプレートとして使う。 必要な環境変数を実際の値なしでドキュメント化する:
# .env.example — これはコミットする
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
# .env — これは絶対にコミットしない
# .gitignoreに記載- 起動時に環境変数をバリデーションする。 データベースURLが必要なエンドポイントにリクエストが来るまで待たない。早期に失敗する:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWTシークレットは32文字以上必要です"),
REDIS_URL: z.string().url(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
export type Env = z.infer<typeof envSchema>;
function validateEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("無効な環境変数:");
console.error(result.error.format());
process.exit(1); // 不正な設定では起動しない
}
return result.data;
}
export const env = validateEnv();- 本番環境ではシークレットマネージャーを使う。 環境変数はシンプルな構成では機能するが、制限がある:プロセスリストで見える、メモリに残る、エラーログから漏洩する可能性がある。
本番システムでは適切なシークレットマネージャーを使う:
- AWS Secrets ManagerまたはSSM Parameter Store
- HashiCorp Vault
- Google Secret Manager
- Azure Key Vault
- Doppler(すべてのクラウドで動作するものが必要な場合)
パターンはどれを使っても同じだ:アプリケーションは起動時にシークレットマネージャーからシークレットを取得する、環境変数からではない。
- 定期的にシークレットをローテーションする。 同じJWTシークレットを2年間使い続けているなら、ローテーションの時期だ。キーローテーションを実装する:複数の有効な署名キーを同時にサポートし、新しいキーで新しいトークンに署名し、古いキーと新しいキーの両方で検証し、既存のすべてのトークンが期限切れになった後に古いキーを廃止する。
interface SigningKey {
id: string;
secret: string;
createdAt: Date;
active: boolean; // アクティブなキーのみが新しいトークンに署名する
}
async function verifyWithRotation(token: string): Promise<TokenPayload> {
const keys = await getSigningKeys(); // すべての有効なキーを返す
for (const key of keys) {
try {
return jwt.verify(token, key.secret, {
algorithms: ["HS256"],
}) as TokenPayload;
} catch {
continue; // 次のキーを試す
}
}
throw new ApiError(401, "Invalid token");
}
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
const activeKey = getActiveSigningKey();
return jwt.sign(payload, activeKey.secret, {
algorithm: "HS256",
expiresIn: "15m",
keyid: activeKey.id, // ヘッダーにキーIDを含める
});
}OWASP APIセキュリティ Top 10#
OWASP APIセキュリティ Top 10は、API脆弱性の業界標準リストだ。定期的に更新され、リスト上のすべての項目は実際のコードベースで見てきたものだ。それぞれを見ていこう。
API1:オブジェクトレベル認可の不備(BOLA)#
最も一般的なAPI脆弱性だ。ユーザーは認証されているが、APIはリクエストしている特定のオブジェクトにアクセス権があるかどうかをチェックしない。
// 脆弱 — 認証済みユーザーなら誰のデータにもアクセスできる
app.get("/api/users/:id", authenticate, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
});
// 修正 — ユーザーが自分自身のデータにアクセスしていることを確認(または管理者であること)
app.get("/api/users/:id", authenticate, async (req, res) => {
if (req.user.id !== req.params.id && req.user.role !== "admin") {
return res.status(403).json({ error: "Access denied" });
}
const user = await db.users.findById(req.params.id);
return res.json(user);
});脆弱なバージョンはどこにでもある。すべての認証チェックを通過する — ユーザーは有効なトークンを持っている — しかしこの特定のリソースにアクセスする権限があるかは検証しない。URLのIDを変更すれば、他人のデータが取得できる。
API2:認証の不備#
弱いログインメカニズム、MFAの欠如、期限が切れないトークン、平文で保存されたパスワード。これは認証レイヤー自体をカバーする。
修正は認証セクションで議論したすべてだ:強力なパスワード要件、十分なラウンド数のbcrypt、短命のアクセストークン、リフレッシュトークンのローテーション、失敗した試行後のアカウントロックアウト。
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15分
async function handleLogin(email: string, password: string): Promise<AuthResult> {
const lockoutKey = `lockout:${email}`;
const attempts = await redis.get(lockoutKey);
if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
const ttl = await redis.pttl(lockoutKey);
throw new ApiError(
429,
`アカウントがロックされています。${Math.ceil(ttl / 60000)}分後にお試しください。`
);
}
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// 失敗回数をインクリメント
await redis.multi()
.incr(lockoutKey)
.pexpire(lockoutKey, LOCKOUT_DURATION)
.exec();
// 両方のケースで同じエラーメッセージ — メールが存在するかどうかを明かさない
throw new ApiError(401, "メールアドレスまたはパスワードが無効です");
}
// ログイン成功時に失敗回数をリセット
await redis.del(lockoutKey);
return generateTokens(user);
}「同じエラーメッセージ」に関するコメントは重要だ。無効なメールに対して「ユーザーが見つかりません」、正しいメールで間違ったパスワードに対して「パスワードが間違っています」を返すと、システムにどのメールが存在するかを攻撃者に教えることになる。
API3:オブジェクトプロパティレベル認可の不備#
必要以上のデータを返す、またはユーザーが変更すべきでないプロパティを変更できてしまう。
// 脆弱 — 内部フィールドを含むユーザーオブジェクト全体を返す
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
// レスポンスに含まれる:passwordHash、internalNotes、billingId ...
});
// 修正 — 返すフィールドの明示的なホワイトリスト
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json({
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
createdAt: user.createdAt,
});
});データベースオブジェクト全体を返してはいけない。常に公開したいフィールドを選択する。これは書き込みにも適用される — リクエストボディ全体を更新クエリにスプレッドしてはいけない:
// 脆弱 — マスアサインメント
app.put("/api/users/:id", authenticate, async (req, res) => {
await db.users.update(req.params.id, req.body);
// 攻撃者が送信:{ "role": "admin", "verified": true }
});
// 修正 — 許可されたフィールドを選択
const UpdateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
avatar: z.string().url().optional(),
});
app.put("/api/users/:id", authenticate, async (req, res) => {
const data = UpdateUserSchema.parse(req.body);
await db.users.update(req.params.id, data);
});API4:無制限のリソース消費#
APIはリソースだ。CPU、メモリ、帯域幅、データベース接続 — すべて有限だ。制限がなければ、単一のクライアントがすべてを使い果たせる。
これはレート制限を超える。以下が含まれる:
// リクエストボディサイズの制限
app.use(express.json({ limit: "1mb" }));
// クエリの複雑さを制限
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce
.number()
.int()
.positive()
.max(MAX_PAGE_SIZE)
.default(DEFAULT_PAGE_SIZE),
});
// ファイルアップロードサイズの制限
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 1,
},
fileFilter: (req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("無効なファイルタイプです"));
}
},
});
// 長時間実行リクエストのタイムアウト
app.use((req, res, next) => {
res.setTimeout(30000, () => {
res.status(408).json({ error: "Request timeout" });
});
next();
});API5:機能レベル認可の不備#
BOLAとは異なる。これはオブジェクトではなく、アクセスすべきでない機能(エンドポイント)にアクセスすることだ。典型的な例:一般ユーザーが管理者エンドポイントを発見する。
// ロールベースのアクセスをチェックするミドルウェア
function requireRole(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: "Not authenticated" });
}
if (!allowedRoles.includes(req.user.role)) {
// 試行をログに記録 — 攻撃の可能性
logger.warn("不正アクセスの試行", {
userId: req.user.id,
role: req.user.role,
requiredRoles: allowedRoles,
endpoint: `${req.method} ${req.path}`,
ip: req.ip,
});
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}
// ルートに適用
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);エンドポイントを隠すことに頼ってはいけない。「隠蔽によるセキュリティ」はセキュリティではない。管理パネルのURLがどこにもリンクされていなくても、誰かがファジングで/api/admin/usersを見つける。
API6:機密ビジネスフローへの無制限アクセス#
正当なビジネス機能の自動化された悪用。例えば:限定在庫商品を購入するボット、スパム目的の自動アカウント作成、商品価格のスクレイピング。
緩和策はコンテキストに依存する:CAPTCHA、デバイスフィンガープリンティング、行動分析、機密操作に対するステップアップ認証。万能なコードスニペットはない。
API7:サーバーサイドリクエストフォージェリ(SSRF)#
APIがユーザー提供のURLを取得する場合(Webhook、プロフィール画像URL、リンクプレビュー)、攻撃者はサーバーに内部リソースをリクエストさせることができる:
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
async function safeFetch(userProvidedUrl: string): Promise<Response> {
let parsed: URL;
try {
parsed = new URL(userProvidedUrl);
} catch {
throw new ApiError(400, "無効なURLです");
}
// HTTP(S)のみ許可
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new ApiError(400, "HTTP(S) URLのみ許可されています");
}
// ホスト名を解決してプライベートIPかチェック
const addresses = await dns.resolve4(parsed.hostname);
for (const addr of addresses) {
if (isPrivateIP(addr)) {
throw new ApiError(400, "内部アドレスは許可されていません");
}
}
// タイムアウトとサイズ制限付きでフェッチ
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(userProvidedUrl, {
signal: controller.signal,
redirect: "error", // リダイレクトを追わない(内部IPにリダイレクトされる可能性)
});
return response;
} finally {
clearTimeout(timeout);
}
}重要なポイント:まずDNSを解決してリクエストを行う前にIPをチェックする。リダイレクトをブロックする — 攻撃者はhttp://169.254.169.254/(AWSメタデータエンドポイント)にリダイレクトするURLをホストして、URLレベルのチェックをバイパスできる。
API8:セキュリティ設定の不備#
変更されていないデフォルトクレデンシャル、不要なHTTPメソッドの有効化、本番環境での冗長なエラーメッセージ、有効なディレクトリリスティング、誤ったCORS設定。これは「ドアの鍵を閉め忘れた」カテゴリーだ。
// 本番環境でスタックトレースを漏らさない
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error("未処理のエラー", {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
if (process.env.NODE_ENV === "production") {
// 汎用的なエラーメッセージ — 内部情報を明かさない
res.status(500).json({
error: "Internal server error",
requestId: req.id, // デバッグ用のリクエストIDを含める
});
} else {
// 開発環境では完全なエラーを表示
res.status(500).json({
error: err.message,
stack: err.stack,
});
}
});
// 不要なHTTPメソッドを無効化
app.use((req, res, next) => {
const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
if (!allowed.includes(req.method)) {
return res.status(405).json({ error: "Method not allowed" });
}
next();
});API9:不適切なインベントリ管理#
APIのv2をデプロイしたがv1を停止し忘れた。あるいは開発中に便利だった/debug/エンドポイントが本番でまだ動いている。あるいは本番データを使った公開アクセス可能なステージングサーバー。
これはコードの修正ではなく、運用の規律だ。すべてのAPIエンドポイント、デプロイされたすべてのバージョン、すべての環境のリストを維持する。自動スキャンを使って公開されたサービスを見つける。不要なものは停止する。
API10:安全でないAPIの利用#
APIがサードパーティAPIを利用している。レスポンスをバリデーションしているか?Stripeからのwebhookペイロードが実は攻撃者からのものだったらどうなるか?
import crypto from "crypto";
// Stripe webhook署名の検証
function verifyStripeWebhook(
payload: string,
signature: string,
secret: string
): boolean {
const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
if (!timestamp || !expectedSig) return false;
// 古いタイムスタンプを拒否(リプレイ攻撃防止)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) return false; // 5分の許容範囲
const signedPayload = `${timestamp}.${payload}`;
const computedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(computedSig),
Buffer.from(expectedSig)
);
}Webhookの署名は常に検証する。サードパーティAPIレスポンスの構造は常にバリデーションする。送信リクエストにはタイムアウトを常に設定する。「信頼できるパートナー」から来たというだけでデータを信頼してはいけない。
監査ログ#
何か問題が発生した場合 — そしていつか必ず発生する — 監査ログが何が起きたかを解明する手段だ。しかしログは諸刃の剣だ。記録が少なすぎると盲目になる。記録が多すぎるとプライバシーの負債になる。
ログに記録すべきもの#
interface AuditLogEntry {
timestamp: string;
action: string; // "user.login"、"post.delete"、"admin.role_change"
actor: {
id: string;
ip: string;
userAgent: string;
};
target: {
type: string; // "user"、"post"、"setting"
id: string;
};
result: "success" | "failure";
metadata: Record<string, unknown>; // 追加のコンテキスト
requestId: string; // アプリケーションログとの相関用
}
async function auditLog(entry: AuditLogEntry): Promise<void> {
// 別の追記専用データストアに書き込む
// アプリケーションが使用するのと同じデータベースであってはならない
await auditDb.collection("audit_logs").insertOne({
...entry,
timestamp: new Date().toISOString(),
});
// 重要なアクションについては、不変の外部ログにも書き込む
if (isCriticalAction(entry.action)) {
await externalLogger.send(entry);
}
}以下のイベントをログに記録する:
- 認証:ログイン、ログアウト、失敗した試行、トークンリフレッシュ
- 認可:アクセス拒否イベント(これらはしばしば攻撃の指標)
- データ変更:作成、更新、削除 — 誰が何をいつ変更したか
- 管理アクション:ロール変更、ユーザー管理、設定変更
- セキュリティイベント:レート制限のトリガー、CORS違反、不正なリクエスト
ログに記録してはいけないもの#
絶対にログに記録しないもの:
- パスワード(ハッシュ化されたものでも — ハッシュはクレデンシャルだ)
- クレジットカード番号の全桁(下4桁のみ記録)
- マイナンバーや政府発行の身分証明書番号
- APIキーやトークン(プレフィックスのみ記録:
sk_live_...abc) - 個人の健康情報
- PIIを含む可能性のあるリクエストボディ全体
function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
const sensitiveKeys = new Set([
"password",
"passwordHash",
"token",
"secret",
"apiKey",
"creditCard",
"ssn",
"authorization",
]);
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (sensitiveKeys.has(key.toLowerCase())) {
sanitized[key] = "[REDACTED]";
} else if (typeof value === "object" && value !== null) {
sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
} else {
sanitized[key] = value;
}
}
return sanitized;
}改ざん検知ログ#
攻撃者がシステムにアクセスした場合、最初にすることの1つは痕跡を消すためにログを改変することだ。改ざん検知ログはこれを検出可能にする:
import crypto from "crypto";
let previousHash = "GENESIS"; // チェーンの初期ハッシュ
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
const content = JSON.stringify(entry) + previousHash;
const hash = crypto.createHash("sha256").update(content).digest("hex");
previousHash = hash;
return { ...entry, hash };
}
// チェーンの整合性を検証するには:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
let expectedPreviousHash = "GENESIS";
for (const entry of entries) {
const { hash, ...rest } = entry;
const content = JSON.stringify(rest) + expectedPreviousHash;
const computedHash = crypto.createHash("sha256").update(content).digest("hex");
if (computedHash !== hash) {
return false; // チェーンが壊れている — ログが改ざんされた
}
expectedPreviousHash = hash;
}
return true;
}これはブロックチェーンと同じコンセプトだ — 各ログエントリのハッシュは前のエントリに依存する。誰かがエントリを変更または削除すると、チェーンが壊れる。
依存関係のセキュリティ#
自分のコードはセキュアかもしれない。しかしnode_modules内の847個のnpmパッケージはどうだろう?サプライチェーン問題は現実であり、年々悪化している。
npm auditは最低限#
# CIで実行し、高/重大な脆弱性でビルドを失敗させる
npm audit --audit-level=high
# 自動修正可能なものを修正
npm audit fix
# 実際に何を取り込んでいるか確認
npm ls --allしかしnpm auditには制限がある。npmアドバイザリデータベースのみをチェックし、重大度の評価が常に正確とは限らない。追加のツールを重ねよう:
自動依存関係スキャン#
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "your-team"
labels:
- "dependencies"
# マイナーとパッチの更新をグループ化してPRノイズを削減
groups:
production-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"ロックファイルはセキュリティツール#
package-lock.json(またはpnpm-lock.yaml、yarn.lock)は必ずコミットする。ロックファイルは推移的依存関係を含むすべての依存関係の正確なバージョンを固定する。これがないと、npm installはテストしたものとは異なるバージョンを取得する可能性があり — その異なるバージョンが侵害されているかもしれない。
# CIではinstallの代わりにciを使用 — ロックファイルを厳密に尊重する
npm cinpm ciはロックファイルがpackage.jsonと一致しない場合、暗黙的に更新するのではなく失敗する。これによりpackage.jsonを変更したがロックファイルの更新を忘れたケースを検出できる。
インストール前に評価する#
依存関係を追加する前に問う:
- 本当に必要か? パッケージを追加する代わりに20行で書けないか?
- ダウンロード数はどのくらいか? ダウンロード数が少ないことは必ずしも悪くないが、コードをレビューする目が少ないことを意味する。
- 最後に更新されたのはいつか? 3年間更新されていないパッケージにはパッチ未適用の脆弱性があるかもしれない。
- どれだけの依存関係を引き込むか?
is-oddはis-numberに依存し、is-numberはkind-ofに依存する。1行のコードでできることに3つのパッケージだ。 - 誰がメンテナンスしているか? メンテナーが1人なら、侵害の単一障害点だ。
// これにはパッケージは不要だ:
const isEven = (n: number): boolean => n % 2 === 0;
// これにも:
const leftPad = (str: string, len: number, char = " "): string =>
str.padStart(len, char);
// これにも:
const isNil = (value: unknown): value is null | undefined =>
value === null || value === undefined;デプロイ前チェックリスト#
これは本番デプロイ前に毎回使っている実際のチェックリストだ。網羅的ではない — セキュリティは決して「完了」しない — が、最も重要なミスを捕捉する。
| # | チェック項目 | 合格基準 | 優先度 |
|---|---|---|---|
| 1 | 認証 | JWTがアルゴリズム、発行者、オーディエンスを明示的に指定して検証されている。alg: noneなし。 | 重大 |
| 2 | トークン有効期限 | アクセストークンは15分以内に期限切れ。リフレッシュトークンは使用時にローテーション。 | 重大 |
| 3 | トークン保存 | リフレッシュトークンはhttpOnly secure Cookie内。localStorageにトークンなし。 | 重大 |
| 4 | 全エンドポイントでの認可 | すべてのデータアクセスエンドポイントがオブジェクトレベルの権限をチェック。BOLAテスト済み。 | 重大 |
| 5 | 入力バリデーション | すべてのユーザー入力がZodまたは同等のものでバリデーション済み。クエリにraw req.bodyなし。 | 重大 |
| 6 | SQL/NoSQLインジェクション | すべてのデータベースクエリがパラメータ化クエリまたはORMメソッドを使用。文字列連結なし。 | 重大 |
| 7 | レート制限 | 認証エンドポイント:5/15分。一般API:60/分。レート制限ヘッダーを返す。 | 高 |
| 8 | CORS | 明示的なオリジンホワイトリスト。クレデンシャル付きワイルドカードなし。プリフライトキャッシュ済み。 | 高 |
| 9 | セキュリティヘッダー | CSP、HSTS、X-Frame-Options、X-Content-Type-Options、Referrer-Policyすべて存在。 | 高 |
| 10 | エラーハンドリング | 本番エラーは汎用メッセージを返す。スタックトレースやSQLエラーの露出なし。 | 高 |
| 11 | シークレット | コードやgit履歴にシークレットなし。.envは.gitignoreに。起動時にバリデーション済み。 | 重大 |
| 12 | 依存関係 | npm auditクリーン(高/重大なし)。ロックファイルコミット済み。CIでnpm ci。 | 高 |
| 13 | HTTPSのみ | HSTSをプリロードで有効化。HTTPはHTTPSにリダイレクト。secure Cookieフラグ設定済み。 | 重大 |
| 14 | ログ | 認証イベント、アクセス拒否、データ変更がログ記録済み。ログにPIIなし。 | 中 |
| 15 | リクエストサイズ制限 | ボディパーサー制限済み(デフォルト1MB)。ファイルアップロード制限済み。クエリのページネーション強制。 | 中 |
| 16 | SSRF保護 | ユーザー提供URLをバリデーション済み。プライベートIPをブロック。リダイレクト無効化またはバリデーション済み。 | 中 |
| 17 | アカウントロックアウト | 5回の失敗でログイン試行がロックアウトをトリガー。ロックアウトをログ記録。 | 高 |
| 18 | Webhook検証 | すべての受信Webhookが署名で検証済み。タイムスタンプによるリプレイ保護。 | 高 |
| 19 | 管理者エンドポイント | すべての管理ルートでロールベースアクセス制御。試行をログ記録。 | 重大 |
| 20 | マスアサインメント | 更新エンドポイントがホワイトリストフィールドのZodスキーマを使用。raw bodyのスプレッドなし。 | 高 |
これをGitHub Issueテンプレートとして保存している。リリースのタグ付け前に、チームの誰かがすべての行をチェックしてサインオフする必要がある。華やかではないが、効果がある。
マインドセットの転換#
セキュリティは最後に追加する機能ではない。年に一度行うスプリントでもない。書くすべてのコードに対する考え方だ。
エンドポイントを書くとき、「予期しないデータを送られたらどうなるか?」と考える。パラメータを追加するとき、「誰かがこれを他人のIDに変更したらどうなるか?」と考える。依存関係を追加するとき、「来週火曜日にこのパッケージが侵害されたらどうなるか?」と考える。
すべてを捕捉することはできない。誰にもできない。しかしこのチェックリストを — 系統的に、毎回のデプロイ前に — 実行することで、最も重要なことを捕捉できる。簡単な勝利。明らかな穴。悪い日をデータ漏洩に変えてしまうミス。
習慣を作ろう。チェックリストを実行しよう。自信を持ってリリースしよう。