secure-error-handling について
API のエラー処理における情報漏えいを抑えるセキュリティスキルです。開発環境と本番環境で応答を分け、一般的な HTTP エラー用ハンドラーと、詳細をサーバー側に記録する方針を提供します。
主な機能
- 01開発・本番環境で応答を分離
- 025 種類の HTTP エラーハンドラー
- 03サーバー側でエラー詳細を記録
- 04ログ内の機密フィールドを回避
- 05入力検証エラーの詳細を整形
活用シーン
- 01API の予期しない失敗への対応
- 02認証・認可エラーの返却
- 03入力検証の問題の報告
- 04本番応答から内部情報を除去
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ harperaa/ secure-claude-skills --skill secure-error-handlingインストール後、Claude Code で/secure-error-handlingを入力して読み込まれたか確認します。
詳細
セキュアなエラー処理 - 情報漏えいの防止
エラーメッセージの問題
エラーメッセージは、開発者のデバッグを支援するためのものです。しかし本番環境では、詳細なエラーはユーザーよりも攻撃者の役に立ちます。
攻撃者がエラーメッセージから得る情報
データベース構造:
Error: column 'credit_cards.number' does not exist
→ 攻撃者は credit_cards テーブルがあることを知ります
ファイルパス:
Error at /var/www/app/lib/payment.js:47
→ 攻撃者はディレクトリ構造を知ります
依存関係:
Stripe API error: Invalid API key format
→ 攻撃者は Stripe を使用していることを知ります
システム情報:
PostgreSQL 9.4 connection failed
→ 攻撃者はデータベースのバージョンを知り、既知の脆弱性を調べられるようになります
実際に起きた情報漏えい
SANS Institute の調査によると、攻撃者が標的システムに関する情報を収集する偵察フェーズから、成功した攻撃の 74% が始まります。エラーメッセージは、こうした情報の主な入手元です。
Equifax の情報漏えい(2017年): 詳細なエラーメッセージから、Apache Struts の既知の脆弱性を使用していることが明らかになりました。攻撃者はこの情報を悪用しました。
エラー処理アーキテクチャ
環境に応じたエラーレスポンス
開発モード:
{
error: "Database connection failed",
stack: "Error: connection timeout at db.connect (database.js:42:15)...",
context: "user-profile-update",
timestamp: "2025-10-15T10:30:00Z"
}
→ 開発者はデバッグに必要な詳細情報をすべて確認できます
本番モード:
{
error: "Internal server error",
message: "An unexpected error occurred. Please try again later."
}
→ ユーザーには安全で一般的なメッセージが表示されます
ログ記録の方針
すべてのエラーは詳細情報とともにサーバー側でログに記録されます(調査用)。一方、本番環境でクライアントに送信するのは一般的なメッセージだけです。これにより、情報漏えいを防ぎながらデバッグできます。
実装ファイル
lib/errorHandler.ts- さまざまな状況に対応する 5 つのエラーハンドラー
使用可能なエラーハンドラー
1. handleApiError(error, context)
用途: 想定外のエラー(HTTP 500)
import { handleApiError } from '@/lib/errorHandler';
async function handler(request: NextRequest) {
try {
// Risky operation
await processPayment(data);
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error, 'payment-processing');
// Production: "Internal server error"
// Development: Full stack trace
}
}
戻り値:
- 開発環境: スタックトレースを含むエラーの詳細
- 本番環境: 一般的な「Internal server error」メッセージ
- HTTP ステータス: 500
2. handleValidationError(message, details)
用途: 入力値の検証エラー(HTTP 400)
import { handleValidationError } from '@/lib/errorHandler';
if (!isValidEmail(email)) {
return handleValidationError(
'Validation failed',
{ email: 'Invalid email format' }
);
}
戻り値:
{
"error": "Validation failed",
"details": {
"email": "Invalid email format"
}
}
- HTTP ステータス: 400
- 開発環境と本番環境の両方: 詳細なフィールドエラーを返します(ユーザーが入力を修正しやすくなります)
3. handleForbiddenError(message)
用途: 認可エラー(HTTP 403)
import { handleForbiddenError } from '@/lib/errorHandler';
// Check if user owns this resource
if (resource.userId !== userId) {
return handleForbiddenError('You do not have access to this resource');
}
戻り値:
{
"error": "Forbidden",
"message": "You do not have access to this resource"
}
- HTTP ステータス: 403
- 開発環境と本番環境の両方: 指定されたメッセージを返します
4. handleUnauthorizedError(message)
用途: 認証エラー(HTTP 401)
import { handleUnauthorizedError } from '@/lib/errorHandler';
import { auth } from '@clerk/nextjs/server';
const { userId } = await auth();
if (!userId) {
return handleUnauthorizedError('Authentication required');
}
戻り値:
{
"error": "Unauthorized",
"message": "Authentication required"
}
- HTTP ステータス: 401
- 開発環境と本番環境の両方: 指定されたメッセージを返します
- デフォルトのメッセージ: メッセージが指定されていない場合は「Authentication required」
5. handleNotFoundError(resource)
用途: リソースが見つからない場合(HTTP 404)
import { handleNotFoundError } from '@/lib/errorHandler';
const post = await db.posts.findOne({ id: postId });
if (!post) {
return handleNotFoundError('Post');
}
戻り値:
{
"error": "Not found",
"message": "Post not found"
}
- HTTP ステータス: 404
- 開発環境と本番環境の両方: リソースに応じたメッセージを返します
エラー処理の完全な例
例 1:すべてのエラー処理を含む保護された API ルート
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { validateRequest } from '@/lib/validateRequest';
import { idSchema } from '@/lib/validation';
import {
handleApiError,
handleUnauthorizedError,
handleForbiddenError,
handleNotFoundError,
handleValidationError
} from '@/lib/errorHandler';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Authentication check
const { userId } = await auth();
if (!userId) {
return handleUnauthorizedError('Please sign in to view posts');
}
// Validate ID parameter
const validation = validateRequest(idSchema, params.id);
if (!validation.success) {
return handleValidationError('Invalid post ID', { id: 'Must be valid ID' });
}
const postId = validation.data;
// Fetch post
const post = await db.posts.findOne({ id: postId });
// Handle not found
if (!post) {
return handleNotFoundError('Post');
}
// Check authorization
if (post.userId !== userId && !post.isPublic) {
return handleForbiddenError('You do not have access to this post');
}
return NextResponse.json({ post });
} catch (error) {
// Catch unexpected errors
return handleApiError(error, 'get-post');
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { userId } = await auth();
if (!userId) {
return handleUnauthorizedError();
}
const validation = validateRequest(idSchema, params.id);
if (!validation.success) {
return handleValidationError('Invalid post ID', validation.error);
}
const postId = validation.data;
const post = await db.posts.findOne({ id: postId });
if (!post) {
return handleNotFoundError('Post');
}
// Only post owner can delete
if (post.userId !== userId) {
return handleForbiddenError('Only the post author can delete this post');
}
await db.posts.delete({ id: postId });
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error, 'delete-post');
}
}
例 2:詳細なエラー処理を含む支払い処理
// app/api/process-payment/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withRateLimit } from '@/lib/withRateLimit';
import { withCsrf } from '@/lib/withCsrf';
import { auth } from '@clerk/nextjs/server';
import { handleApiError, handleUnauthorizedError, handleValidationError } from '@/lib/errorHandler';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function paymentHandler(request: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
return handleUnauthorizedError();
}
const body = await request.json();
const { amount, paymentMethodId } = body;
// Validate amount
if (!amount || amount < 50) {
return handleValidationError('Invalid amount', {
amount: 'Amount must be at least $0.50'
});
}
// Process payment
try {
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: 'usd',
payment_method: paymentMethodId,
confirm: true,
metadata: { userId }
});
return NextResponse.json({
success: true,
paymentIntentId: paymentIntent.id
});
} catch (stripeError: any) {
// Handle Stripe-specific errors
console.error('Stripe error:', stripeError);
// Don't expose Stripe error details to client
if (stripeError.type === 'StripeCardError') {
return NextResponse.json(
{
error: 'Payment failed',
message: 'Your card was declined. Please try a different payment method.'
},
{ status: 400 }
);
}
// Generic error for other Stripe issues
return NextResponse.json(
{
error: 'Payment processing failed',
message: 'Unable to process payment. Please try again later.'
},
{ status: 500 }
);
}
} catch (error) {
// Catch-all for unexpected errors
return handleApiError(error, 'process-payment');
}
}
export const POST = withRateLimit(withCsrf(paymentHandler));
export const config = {
runtime: 'nodejs',
};
例 3:エラー処理を含むデータベース操作
// app/api/users/[id]/profile/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { validateRequest } from '@/lib/validateRequest';
import { updateProfileSchema } from '@/lib/validation';
import {
handleApiError,
handleUnauthorizedError,
handleForbiddenError,
handleNotFoundError
} from '@/lib/errorHandler';
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { userId } = await auth();
if (!userId) {
return handleUnauthorizedError();
}
// Users can only update their own profile
if (params.id !== userId) {
return handleForbiddenError('You can only update your own profile');
}
const body = await request.json();
// Validate input
const validation = validateRequest(updateProfileSchema, body);
if (!validation.success) {
return validation.response;
}
const { displayName, bio, website } = validation.data;
// Update profile
try {
const updatedProfile = await db.profiles.update(
{ userId },
{
displayName,
bio,
website,
updatedAt: Date.now()
}
);
if (!updatedProfile) {
return handleNotFoundError('Profile');
}
return NextResponse.json({ profile: updatedProfile });
} catch (dbError: any) {
// Log database error for debugging
console.error('Database error:', dbError);
// Don't expose database structure to client
if (dbError.code === 'UNIQUE_VIOLATION') {
return NextResponse.json(
{
error: 'Update failed',
message: 'This username is already taken'
},
{ status: 409 }
);
}
// Generic database error
return NextResponse.json(
{
error: 'Database error',
message: 'Failed to update profile. Please try again.'
},
{ status: 500 }
);
}
} catch (error) {
return handleApiError(error, 'update-profile');
}
}
エラーハンドラーの実装
lib/errorHandler.ts
import { NextResponse } from 'next/server';
export function handleApiError(error: unknown, context: string) {
console.error(`[${context}] Error:`, error);
if (process.env.NODE_ENV === 'production') {
// Production: Generic error
return NextResponse.json(
{
error: 'Internal server error',
message: 'An unexpected error occurred. Please try again later.'
},
{ status: 500 }
);
} else {
// Development: Full error details
return NextResponse.json(
{
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
context,
timestamp: new Date().toISOString()
},
{ status: 500 }
);
}
}
export function handleValidationError(
message: string,
details: Record<string, string>
) {
return NextResponse.json(
{
error: 'Validation failed',
message,
details
},
{ status: 400 }
);
}
export function handleForbiddenError(message?: string) {
return NextResponse.json(
{
error: 'Forbidden',
message: message || 'Access denied'
},
{ status: 403 }
);
}
export function handleUnauthorizedError(message?: string) {
return NextResponse.json(
{
error: 'Unauthorized',
message: message || 'Authentication required'
},
{ status: 401 }
);
}
export function handleNotFoundError(resource: string) {
return NextResponse.json(
{
error: 'Not found',
message: `${resource} not found`
},
{ status: 404 }
);
}
ログ記録のベストプラクティス
ログに記録する内容
✅ ログに記録しても安全な情報:
- エラーの種類/コード
- コンテキスト(失敗した操作)
- ユーザー ID(問題の追跡用)
- タイムスタンプ
- リクエストパス
- HTTP ステータスコード
- IP アドレス(セキュリティ監視用)
- 操作名
- カード番号の下 4 桁(参照用のみ)
- トランザクション ID
❌ 絶対にログに記録しない情報:
- パスワード(ハッシュ化されたものも含む)
- クレジットカード番号(全桁)
- CVV コード
- API キー/シークレット/トークン
- 個人を特定できる情報(住所全体、SSN など)
- セッショントークン
- 暗号化キー
- リクエスト/レスポンス本文全体(機密データが含まれる可能性があります)
- 環境変数(
process.env) - エラーのスタックトレース全体(本番環境)
安全なログ記録の例
// ✅ Good logging
console.error('Payment failed', {
userId,
errorCode: error.code,
errorType: error.type,
timestamp: new Date().toISOString(),
path: request.nextUrl.pathname
});
// ❌ Bad logging
console.error('Payment failed', {
userId,
creditCard: cardNumber, // ❌ Never log payment info
apiKey: stripeKey, // ❌ Never log secrets
request: req.body // ❌ May contain sensitive data
});
機密フィールドのマスキング
ログに記録する前に、必ず機密データをマスキングしてください。
const SENSITIVE_FIELDS = [
'password', 'token', 'secret', 'apiKey', 'ssn',
'creditCard', 'cvv', 'cardNumber'
];
function safelog(data: any) {
const sanitized = { ...data };
SENSITIVE_FIELDS.forEach(field => {
if (field in sanitized) {
sanitized[field] = '[REDACTED]';
}
});
console.log(sanitized);
}
// Usage
safelog({
userId: 'user123',
email: '[email protected]',
password: 'secret123' // Will be [REDACTED]
});
本番環境のログ設定
// lib/logger.ts
export function logSecurityEvent(event: {
type: string;
userId?: string;
ip?: string;
details?: Record<string, any>;
}) {
const logEntry = {
...event,
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV
};
if (process.env.NODE_ENV === 'production') {
// Send to logging service (Vercel logs, Datadog, etc.)
console.log(JSON.stringify(logEntry));
} else {
// Pretty print in development
console.log('Security Event:', logEntry);
}
}
// Usage
logSecurityEvent({
type: 'UNAUTHORIZED_ACCESS_ATTEMPT',
userId,
ip: request.ip,
details: {
path: request.nextUrl.pathname,
method: request.method
}
});
クライアント側のエラー処理
ユーザーに配慮したエラー表示
// components/ErrorDisplay.tsx
export function ErrorDisplay({ error }: { error: ApiError }) {
const getMessage = () => {
switch (error.status) {
case 400:
return error.details
? Object.entries(error.details).map(([field, msg]) =>
`${field}: ${msg}`
).join(', ')
: 'Invalid input. Please check your data.';
case 401:
return 'Please sign in to continue.';
case 403:
return 'You don\'t have permission to do that.';
case 404:
return 'The requested resource was not found.';
case 429:
return 'Too many requests. Please wait a moment.';
case 500:
return 'Something went wrong. Please try again later.';
default:
return 'An error occurred. Please try again.';
}
};
return (
<div className="error-message">
{getMessage()}
</div>
);
}
エラー処理を伴う取得
async function createPost(data: PostData) {
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
// Handle different error types
switch (response.status) {
case 400:
// Validation error - show field errors
if (error.details) {
showFieldErrors(error.details);
}
break;
case 401:
// Redirect to login
router.push('/sign-in');
break;
case 403:
// Show access denied message
alert(error.message);
break;
case 429:
// Rate limited - show retry message
alert(`Too many requests. Please wait ${error.retryAfter} seconds.`);
break;
default:
// Generic error
alert('An error occurred. Please try again.');
}
return null;
}
return await response.json();
} catch (error) {
console.error('Network error:', error);
alert('Network error. Please check your connection.');
return null;
}
}
安全なエラー処理で防げること
✅ 情報漏えい - システムの詳細は公開されません ✅ システムのフィンガープリンティング - 技術スタックを特定できません ✅ データベース構造の漏えい - エラーにスキーマの詳細は含まれません ✅ 技術スタックの特定 - 汎用的なエラーのみを返します ✅ 攻撃対象領域の調査 - 情報漏えいを最小限に抑えます ✅ パスの漏えい - ファイルシステムのパスは公開されません ✅ バージョンの漏えい - ソフトウェアのバージョンは明らかになりません
よくある避けるべき間違い
❌ クライアントに error.message を直接返さないでください ❌ 本番環境のレスポンスにスタックトレースを含めないでください ❌ クライアントにデータベースエラーを公開しないでください ❌ 機密データ(パスワード、トークン、カード情報)をログに記録しないでください ❌ 開発環境と本番環境で同じエラーメッセージを使用しないでください ❌ デバッグ用にサーバー側でエラーをログに記録するのを忘れないでください
✅ 予期しないエラーには handleApiError() を使用してください ✅ 既知のエラータイプには専用のハンドラーを使用してください ✅ コンテキストを添えて、サーバー側でエラーをログに記録してください ✅ ユーザーに役立つ(ただし安全な)メッセージを返してください ✅ 適切な HTTP ステータスコードを使用してください ✅ クライアントに送信する前にエラーメッセージをサニタイズしてください
参考資料
- OWASP エラー処理チートシート:https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html
- OWASP Top 10 2021 - A04 安全でない設計:https://owasp.org/Top10/A04_2021-Insecure_Design/
- HTTP ステータスコード:https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
- Node.js のエラー処理:https://nodejs.org/api/errors.html
次のステップ
- 入力検証エラーの場合:
input-validationスキルでvalidateRequest()を使用します - 認証エラーの場合:
auth-securityスキルを使用します - エラーレスポンスをテストする場合:
security-testingスキルを使用します - API セキュリティを完全に確保する場合:すべてのエラーハンドラーを適切に組み合わせます