DEV Community

myougaTheAxo
myougaTheAxo

Posted on • Originally published at zenn.dev

Claude CodeでSSRF攻撃を防ぐ:URLバリデーション・プライベートIP遮断・Allowlist設計

Claude CodeでSSRF攻撃を防ぐ:URLバリデーション・プライベートIP遮断・Allowlist設計

SSRF(Server-Side Request Forgery)は、サーバーを踏み台にして内部ネットワークへ不正アクセスする攻撃です。Claude Codeでツールを自動生成する際、URLを受け取るAPIや外部リソースを取得する処理を実装するケースは多い。適切な防御がなければ、AWSメタデータエンドポイントや社内サービスへのアクセスを許してしまいます。

SSRFの脅威モデル

攻撃者がURLパラメータとして以下を送り込んでくるケースを想定します。

  • http://169.254.169.254/latest/meta-data/iam/security-credentials/ — AWS EC2メタデータ
  • http://192.168.0.1/admin — 社内ルーターの管理画面
  • http://10.0.0.1:8080/internal-api — 内部マイクロサービス
  • DNS Rebinding: 最初は正規IPを返し、リクエスト時に内部IPにすり替える

プライベートIPレンジの遮断

IPv4のプライベートアドレス空間をすべてブロックします。

import dns from 'dns/promises';
import net from 'net';

const PRIVATE_RANGES = [
  { start: '10.0.0.0',     end: '10.255.255.255'  }, // RFC1918 Class A
  { start: '172.16.0.0',  end: '172.31.255.255'  }, // RFC1918 Class B
  { start: '192.168.0.0', end: '192.168.255.255' }, // RFC1918 Class C
  { start: '169.254.0.0', end: '169.254.255.255' }, // Link-local (AWS metadata)
  { start: '127.0.0.0',   end: '127.255.255.255' }, // Loopback
  { start: '0.0.0.0',     end: '0.255.255.255'   }, // This network
  { start: '100.64.0.0',  end: '100.127.255.255' }, // CGNAT
];

function ipToNumber(ip: string): number {
  return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
}

export function isPrivateIP(ip: string): boolean {
  if (!net.isIPv4(ip)) return true; // IPv6は別途対応が必要なため一律ブロック
  const num = ipToNumber(ip);
  return PRIVATE_RANGES.some(
    ({ start, end }) => num >= ipToNumber(start) && num <= ipToNumber(end)
  );
}
Enter fullscreen mode Exit fullscreen mode

DNS Rebinding攻撃の防止

DNS解決後に再度IPをチェックします。最初の名前解決と実際のHTTPリクエスト間にIPが変わる攻撃を防ぎます。

async function resolveAndValidate(hostname: string): Promise<string[]> {
  let addresses: string[];
  try {
    const result = await dns.resolve4(hostname);
    addresses = result;
  } catch {
    throw new SSRFError(`DNS解決失敗: ${hostname}`);
  }

  for (const ip of addresses) {
    if (isPrivateIP(ip)) {
      throw new SSRFError(
        `プライベートIPアドレスへのアクセスは禁止されています: ${ip} (${hostname})`
      );
    }
  }

  return addresses;
}
Enter fullscreen mode Exit fullscreen mode

SSRFValidatorクラスの全実装

import { URL } from 'url';

class SSRFError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'SSRFError';
  }
}

interface SSRFValidatorOptions {
  allowedHosts?: string[];       // Allowlistホスト名
  allowedProtocols?: string[];   // デフォルト: ['https:']
  maxRedirects?: number;         // リダイレクト追跡の上限
}

export class SSRFValidator {
  private allowedHosts: Set<string>;
  private allowedProtocols: Set<string>;
  private maxRedirects: number;

  constructor(options: SSRFValidatorOptions = {}) {
    this.allowedHosts = new Set(options.allowedHosts ?? []);
    this.allowedProtocols = new Set(options.allowedProtocols ?? ['https:']);
    this.maxRedirects = options.maxRedirects ?? 5;
  }

  async validate(rawUrl: string): Promise<void> {
    // 1. URL構文チェック
    let parsed: URL;
    try {
      parsed = new URL(rawUrl);
    } catch {
      throw new SSRFError(`無効なURL形式: ${rawUrl}`);
    }

    // 2. プロトコルチェック(HTTPS only)
    if (!this.allowedProtocols.has(parsed.protocol)) {
      throw new SSRFError(
        `許可されていないプロトコル: ${parsed.protocol}。使用可能: ${[...this.allowedProtocols].join(', ')}`
      );
    }

    // 3. Allowlistチェック(設定されている場合)
    if (this.allowedHosts.size > 0 && !this.allowedHosts.has(parsed.hostname)) {
      throw new SSRFError(`許可リスト外のホスト: ${parsed.hostname}`);
    }

    // 4. IPアドレス直指定チェック
    if (net.isIPv4(parsed.hostname)) {
      if (isPrivateIP(parsed.hostname)) {
        throw new SSRFError(`プライベートIPへの直接アクセス禁止: ${parsed.hostname}`);
      }
      return; // IPアドレス直指定でパブリックIPなら許可
    }

    // 5. DNS解決 + IPチェック(Rebinding対策)
    await resolveAndValidate(parsed.hostname);
  }

  async safeFetch(
    rawUrl: string,
    options: RequestInit = {}
  ): Promise<Response> {
    await this.validate(rawUrl);

    // リダイレクト追跡(各リダイレクト先も再検証)
    let currentUrl = rawUrl;
    let redirectCount = 0;

    while (redirectCount <= this.maxRedirects) {
      const response = await fetch(currentUrl, {
        ...options,
        redirect: 'manual', // リダイレクトを手動制御
      });

      if (response.status >= 300 && response.status < 400) {
        const location = response.headers.get('location');
        if (!location) throw new SSRFError('リダイレクト先URLが不明');

        // リダイレクト先も検証
        const nextUrl = new URL(location, currentUrl).toString();
        await this.validate(nextUrl); // 再検証
        currentUrl = nextUrl;
        redirectCount++;
        continue;
      }

      return response;
    }

    throw new SSRFError(`リダイレクト回数上限超過: ${this.maxRedirects}回`);
  }
}
Enter fullscreen mode Exit fullscreen mode

使い方

const validator = new SSRFValidator({
  allowedHosts: ['api.github.com', 'hooks.slack.com'],
  allowedProtocols: ['https:'],
  maxRedirects: 3,
});

// 安全なフェッチ(プライベートIPやAllowlist外は例外)
try {
  const response = await validator.safeFetch('https://api.github.com/repos/foo/bar');
  const data = await response.json();
} catch (err) {
  if (err instanceof SSRFError) {
    console.error('SSRF検知:', err.message);
    // 403を返すなど適切なエラーハンドリング
  }
}
Enter fullscreen mode Exit fullscreen mode

Claude Codeへの指示パターン

Claude Codeに実装を依頼する際は以下の形式が効果的です。

外部URLを受け取るAPIエンドポイントを実装してください
要件:
- プライベートIPレンジRFC1918 + AWS metadata 169.254.0.0/16を遮断
- HTTPS onみ許可
- リダイレクト先も再検証
- Allowlist: api.github.com, hooks.slack.com のみ
SSRFValidatorクラスとして実装しsafeFetchメソッドで使えるようにすること
Enter fullscreen mode Exit fullscreen mode

まとめ

  • プライベートIPレンジをすべて列挙して遮断:RFC1918 + AWS metadata (169.254.x.x) + Loopback
  • DNS解決後にIPを再検証:DNS Rebinding攻撃をHTTPリクエスト直前のIPチェックで防ぐ
  • HTTPS onlyを強制http:file: プロトコルによる意図しないアクセスを排除
  • リダイレクト追跡時も再検証:各リダイレクト先URLをSSRFValidatorに通してから進む

Security Pack ¥1,480/security-check を含む5プロンプトセット。OWASP Top 10対応のセキュリティレビューをClaude Codeで自動化できます。
prompt-works.jp で販売中。

Top comments (0)