0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Model Context Protocol完全解説 30日間シリーズ - Day 23【MCP実戦 #23】セキュリティ基礎:安全なMCPサーバー設計の5つのポイント

Posted at

はじめに

この記事は、QiitaのModel Context Protocol(以下、MCP)解説シリーズの第23回です。
今回は、MCPサーバーを本番環境で運用する上で最も重要なテーマの一つであるセキュリティについて解説します。安全なMCPサーバーを設計するための5つの基本的なポイントを学びましょう。

🔒 なぜMCPサーバーのセキュリティが重要なのか?

MCPサーバーは、Claude(LLM)とローカルシステムやクラウドサービスを繋ぐ「ブリッジ」の役割を果たします。MCPサーバーはClaude Desktopから直接起動され、stdio(標準入出力)を通じて通信しますが、この特殊な構成においても重要なセキュリティリスクが存在します。

MCPサーバー特有のリスクシナリオ

  1. プロンプトインジェクション攻撃

    • Claudeが処理するユーザー入力に悪意のあるプロンプトが含まれ、MCPサーバーが意図しない動作を実行する
    • 例:「すべてのファイルを削除して」といった指示をClaudeが実行してしまう
  2. 権限昇格

    • MCPサーバーがシステムの管理者権限で動作している場合、Claudeを通じて危険な操作が実行される
    • 例:システムファイルの変更、機密データへの不正アクセス
  3. 外部サービスへの不正アクセス

    • MCPサーバーがAPIキーを持っている場合、それを悪用した外部サービスへの不正リクエスト
    • 例:AWS、Google Cloud等のクラウドリソースの不正操作
  4. 情報漏洩

    • MCPサーバーが機密ファイルや環境変数にアクセスできる場合の情報流出
    • 例:データベース認証情報、APIキーの漏洩

🛡️ 安全なMCPサーバー設計の5つのポイント

1. 最小権限の原則(Principle of Least Privilege)

MCPサーバーには、動作に必要な最小限の権限のみを与えます。

実装例

// ❌ 危険な例:すべてのファイル操作を許可
const server = new Server(
  { name: "file-manager", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "read_file",
      description: "Read any file on the system",
      inputSchema: {
        type: "object",
        properties: { path: { type: "string" } }
      }
    },
    {
      name: "delete_file", // 危険:削除権限も付与
      description: "Delete any file",
      inputSchema: {
        type: "object",
        properties: { path: { type: "string" } }
      }
    }
  ]
}));

// ✅ 安全な例:制限された操作のみ許可
const ALLOWED_DIRECTORIES = ['/home/user/documents', '/tmp/mcp-workspace'];
const READONLY_MODE = true;

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "read_file",
      description: "Read files from allowed directories only",
      inputSchema: {
        type: "object",
        properties: { 
          path: { 
            type: "string",
            pattern: "^(/home/user/documents|/tmp/mcp-workspace)" 
          } 
        },
        required: ["path"]
      }
    }
    // deleteツールは提供しない
  ]
}));

システムレベルでの権限制限

# 専用ユーザーでMCPサーバーを実行
sudo useradd --system --shell /bin/false mcpserver
sudo chown mcpserver:mcpserver /opt/mcp-server/

# Claude Desktop設定での権限制限
# ~/.config/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "secure-file-server": {
      "command": "sudo",
      "args": ["-u", "mcpserver", "node", "/opt/mcp-server/dist/index.js"],
      "env": {
        "HOME": "/opt/mcp-server",
        "PATH": "/usr/bin:/bin"
      }
    }
  }
}

2. 厳格な入力検証(Input Validation)

Claudeからの入力は必ず検証し、不正な値や危険な操作を防ぎます。

import { z } from 'zod';
import path from 'path';

// 厳格なスキーマ定義
const FilePathSchema = z.string()
  .min(1)
  .max(255)
  .refine((filepath) => {
    // パストラバーサル攻撃を防ぐ
    const normalizedPath = path.normalize(filepath);
    return !normalizedPath.includes('../') && !normalizedPath.startsWith('/');
  }, "Invalid file path")
  .refine((filepath) => {
    // 危険な拡張子をブロック
    const dangerousExtensions = ['.exe', '.bat', '.sh', '.ps1'];
    return !dangerousExtensions.some(ext => 
      filepath.toLowerCase().endsWith(ext)
    );
  }, "Dangerous file extension");

const SQLQuerySchema = z.string()
  .max(1000)
  .refine((query) => {
    // SQLインジェクションを防ぐ基本的なチェック
    const dangerous = ['DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', '--', ';'];
    const upperQuery = query.toUpperCase();
    return !dangerous.some(keyword => upperQuery.includes(keyword));
  }, "Potentially dangerous SQL query");

// Tool実装での検証
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "read_file") {
    try {
      const validatedPath = FilePathSchema.parse(args.path);
      // 追加の安全性チェック
      const allowedDir = '/home/user/documents';
      const fullPath = path.join(allowedDir, validatedPath);
      
      if (!fullPath.startsWith(allowedDir)) {
        throw new Error("Path outside allowed directory");
      }
      
      // ファイル読み取り処理...
    } catch (error) {
      return {
        content: [{ type: "text", text: `Validation error: ${error.message}` }],
        isError: true
      };
    }
  }
});

3. 機密情報の安全な管理(Secure Secret Management)

APIキーや認証情報は、コードに直接埋め込まず、安全に管理します。

import * as fs from 'fs';
import * as crypto from 'crypto';

class SecretManager {
  private secrets: Map<string, string> = new Map();

  constructor() {
    this.loadSecrets();
  }

  private loadSecrets() {
    try {
      // 環境変数から読み込み(推奨)
      const apiKey = process.env.MCP_API_KEY;
      if (apiKey) {
        this.secrets.set('api_key', apiKey);
      }

      // 暗号化されたファイルから読み込み
      const secretsPath = process.env.MCP_SECRETS_PATH;
      if (secretsPath && fs.existsSync(secretsPath)) {
        const encryptedSecrets = fs.readFileSync(secretsPath, 'utf8');
        const decrypted = this.decrypt(encryptedSecrets);
        const secrets = JSON.parse(decrypted);
        
        Object.entries(secrets).forEach(([key, value]) => {
          this.secrets.set(key, value as string);
        });
      }
    } catch (error) {
      console.error('Failed to load secrets:', error);
      process.exit(1);
    }
  }

  private decrypt(encryptedData: string): string {
    const key = process.env.MCP_ENCRYPTION_KEY;
    if (!key) throw new Error('Encryption key not found');
    
    // 実際の復号化処理を実装
    // ここでは簡単な例として示しています
    return Buffer.from(encryptedData, 'base64').toString('utf8');
  }

  getSecret(name: string): string | undefined {
    return this.secrets.get(name);
  }

  // 使用例
  async makeApiCall(endpoint: string, data: any) {
    const apiKey = this.getSecret('api_key');
    if (!apiKey) {
      throw new Error('API key not available');
    }

    const response = await fetch(endpoint, {
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    });

    return response;
  }
}

4. 包括的なロギングと監視(Comprehensive Logging & Monitoring)

すべての重要な操作をログに記録し、異常な活動を検知できるようにします。

import winston from 'winston';
import rateLimit from 'express-rate-limit';

// 構造化ログの設定
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ 
      filename: '/var/log/mcp/error.log', 
      level: 'error' 
    }),
    new winston.transports.File({ 
      filename: '/var/log/mcp/combined.log' 
    }),
    new winston.transports.Console({
      format: winston.format.simple()
    })
  ]
});

// セキュリティイベントの監視
class SecurityMonitor {
  private suspiciousPatterns = [
    /\.\.\//g,  // パストラバーサル
    /;\s*(rm|del|format)/i,  // 危険なコマンド
    /(union|select|drop|insert|update|delete)\s+/i,  // SQLインジェクション
    /script[\s\S]*?>/i,  // XSS攻撃
  ];

  private attemptCounts = new Map<string, number>();

  logToolCall(toolName: string, args: any, result: any, duration: number) {
    const logEntry = {
      event: 'tool_call',
      tool: toolName,
      args: this.sanitizeArgs(args),
      success: !result.isError,
      duration_ms: duration,
      timestamp: new Date().toISOString()
    };

    logger.info('Tool executed', logEntry);

    // 疑わしいパターンをチェック
    this.checkSuspiciousActivity(toolName, args);
  }

  private sanitizeArgs(args: any): any {
    // 機密情報をマスク
    const sanitized = JSON.parse(JSON.stringify(args));
    
    const sensitiveFields = ['password', 'token', 'key', 'secret'];
    const maskSensitive = (obj: any) => {
      Object.keys(obj).forEach(key => {
        if (sensitiveFields.some(field => key.toLowerCase().includes(field))) {
          obj[key] = '***MASKED***';
        } else if (typeof obj[key] === 'object' && obj[key] !== null) {
          maskSensitive(obj[key]);
        }
      });
    };

    maskSensitive(sanitized);
    return sanitized;
  }

  private checkSuspiciousActivity(toolName: string, args: any) {
    const argsString = JSON.stringify(args);
    
    // 疑わしいパターンをチェック
    for (const pattern of this.suspiciousPatterns) {
      if (pattern.test(argsString)) {
        logger.warn('Suspicious activity detected', {
          event: 'security_alert',
          tool: toolName,
          pattern: pattern.source,
          args: this.sanitizeArgs(args),
          timestamp: new Date().toISOString()
        });

        // アラート送信やアクセス制限の実装
        this.handleSecurityIncident(toolName, pattern.source);
        break;
      }
    }
  }

  private handleSecurityIncident(toolName: string, pattern: string) {
    // インシデント対応の実装
    // 例:一時的な機能停止、管理者への通知など
    const key = `${toolName}_${pattern}`;
    const count = (this.attemptCounts.get(key) || 0) + 1;
    this.attemptCounts.set(key, count);

    if (count >= 3) {
      logger.error('Multiple security violations detected', {
        event: 'security_incident',
        tool: toolName,
        pattern: pattern,
        count: count
      });
      
      // 実際の環境では、ここで管理者への通知や
      // サーバーの一時停止などを実装
    }
  }
}

const securityMonitor = new SecurityMonitor();

// Tool実行時の監視
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const startTime = Date.now();
  const { name, arguments: args } = request.params;

  try {
    // Tool実行
    const result = await executeTool(name, args);
    const duration = Date.now() - startTime;
    
    securityMonitor.logToolCall(name, args, result, duration);
    return result;
  } catch (error) {
    const duration = Date.now() - startTime;
    const errorResult = { isError: true, message: error.message };
    
    securityMonitor.logToolCall(name, args, errorResult, duration);
    throw error;
  }
});

5. サンドボックス化と分離(Sandboxing & Isolation)

MCPサーバーを分離された環境で実行し、システム全体への影響を最小限に抑えます。

# マルチステージビルドによる最小限のランタイム環境
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:20-alpine AS runtime
RUN addgroup -g 1001 -S mcpserver && \
    adduser -S mcpserver -u 1001

# 必要最小限のファイルのみコピー
COPY --from=builder --chown=mcpserver:mcpserver /app/node_modules ./node_modules
COPY --chown=mcpserver:mcpserver dist/ ./dist/

# システムファイルへのアクセスを制限
RUN mkdir -p /app/workspace && \
    chown mcpserver:mcpserver /app/workspace && \
    chmod 755 /app/workspace

# セキュリティ強化
RUN apk --no-cache add dumb-init && \
    rm -rf /var/cache/apk/*

USER mcpserver
WORKDIR /app

# 非特権ユーザーで実行
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "dist/index.js"]
// Claude Desktop設定でのセキュリティ強化
{
  "mcpServers": {
    "secure-mcp-server": {
      "command": "docker",
      "args": [
        "run",
        "--rm",
        "-i",
        "--init",
        "--read-only",
        "--tmpfs", "/tmp:noexec,nosuid,size=100m",
        "--security-opt", "no-new-privileges:true",
        "--cap-drop", "ALL",
        "--network", "none",
        "-v", "/home/user/mcp-workspace:/app/workspace:rw",
        "-e", "NODE_ENV=production",
        "secure-mcp-server:latest"
      ]
    }
  }
}

🔧 セキュリティ設定のチェックリスト

開発・運用時に確認すべきセキュリティポイント:

開発時

  • 最小権限の原則に従ったTool設計
  • 入力値検証スキーマの実装
  • 機密情報のハードコーディング禁止
  • エラーメッセージでの情報漏洩防止
  • セキュリティテストケースの作成

運用時

  • 非特権ユーザーでの実行
  • ログファイルの適切な権限設定
  • 定期的なセキュリティ監査
  • インシデント対応手順の準備
  • バックアップとリカバリ計画

監視項目

  • 異常なTool呼び出しパターン
  • 高頻度のエラー発生
  • 予期しないファイルアクセス
  • リソース使用量の急激な変化
  • 外部API呼び出しの異常

🎯 まとめ:多層防御でセキュリティを確保

安全なMCPサーバーを構築するためには、単一のセキュリティ手法に依存するのではなく、多層防御の考え方が重要です。

5つのポイントの相互作用

  1. 最小権限:被害の範囲を制限
  2. 入力検証:攻撃の入り口を塞ぐ
  3. 機密管理:重要情報の漏洩を防ぐ
  4. ログ監視:異常を早期発見
  5. 分離:影響を局所化

これらを組み合わせることで、一つの防御が突破されても他の防御が機能する、堅牢なセキュリティ体制を構築できます。

セキュリティは継続的なプロセス

セキュリティは一度設定すれば終わりではありません。定期的な見直し、新しい脅威への対応、インシデント発生時の迅速な対処が必要です。

次回は、パフォーマンス最適化に焦点を当て、大量のデータや複雑な処理に対応するMCPサーバーの設計手法を解説します。お楽しみに!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?