mcpField
S
secure-error-handling

secure-error-handling

github.com/harperaaセキュリティ19196 回インストール45 週間前に更新
リポジトリ

secure-error-handling について

API のエラー処理における情報漏えいを抑えるセキュリティスキルです。開発環境と本番環境で応答を分け、一般的な HTTP エラー用ハンドラーと、詳細をサーバー側に記録する方針を提供します。

主な機能

  1. 01開発・本番環境で応答を分離
  2. 025 種類の HTTP エラーハンドラー
  3. 03サーバー側でエラー詳細を記録
  4. 04ログ内の機密フィールドを回避
  5. 05入力検証エラーの詳細を整形

活用シーン

  1. 01API の予期しない失敗への対応
  2. 02認証・認可エラーの返却
  3. 03入力検証の問題の報告
  4. 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 ステータスコードを使用してください ✅ クライアントに送信する前にエラーメッセージをサニタイズしてください

参考資料

次のステップ

  • 入力検証エラーの場合:input-validation スキルで validateRequest() を使用します
  • 認証エラーの場合:auth-security スキルを使用します
  • エラーレスポンスをテストする場合:security-testing スキルを使用します
  • API セキュリティを完全に確保する場合:すべてのエラーハンドラーを適切に組み合わせます

コメント