mcpField
A
api-hardening

api-hardening

github.com/jamditisセキュリティ438174 回インストール4 日前に更新
リポジトリ

api-hardening について

OWASP API Security Top 10:2023 に沿った API 防御パターンを扱うセキュリティスキルです。最新の脅威を調査してから、レート制限や入力検証などの手順を適用します。

主な機能

  1. 01OWASP API Top 10:2023 との対応
  2. 02調査を先行するセキュリティ手順
  3. 03レート制限とアカウント別クォータ
  4. 04入力検証のガイダンス
  5. 05セキュリティ事例に基づく脅威解説

活用シーン

  1. 01新しい API の防御策の計画
  2. 02不正または高負荷なリクエストの制限
  3. 03API の信頼境界でのデータ検証

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/jamditis/claude-skills-journalism --skill api-hardening

インストール後、Claude Code で/api-hardeningを入力して読み込まれたか確認します。

同梱ファイルagents/openai.yaml

詳細

API の堅牢化

API を悪用、インジェクション攻撃、データ漏えいから保護するための多層防御パターンです。レシピは OWASP API Security Top 10:2023 を中心に構成されており、最終確認日は 2026-05-08 です。

ステップ 0:現在のセキュリティ動向を調査する(最初に実施してください)

セキュリティに関する知識の半減期は 6〜12 か月です。以下のレシピは 2026-05-08 に最終確認されており、これを読む時点では古くなっている可能性があります。このスキルのパターンを適用する前に、追加する API サーフェスまたは Web 防御を対象に調査を広く行い、このファイルの時点情報ではなく、現在の信頼できる情報源に照らしてレシピを解釈してください。

デフォルトで実施し、スキップした場合は記録する

デフォルトでは、以下の 4 つの観点で調査を実施してください。次の条件をすべて満たす場合に限り、スキップできます。

  • (a)現在のセッションの直近 4 時間以内に、同じプリミティブに対してこのスキルを実行し、同じ調査を行った。
  • (b)その調査で、追加する API サーフェスまたは Web 防御に関する緊急の勧告が見つからなかった。
  • (c)回答に Research skipped because <reason> という一行のメモを記録する。

「知っているつもり」「急いでいる」「ユーザーが早く済ませたがっている」「すでに詳しい」は、スキップの正当な理由にはなりません。この前置きの目的は、現在の状況を確認するまでは、未来の自分がこのスキル本文の既定値を信用しないようにすることです。

4 つのサブエージェントを並行して起動する

各サブエージェントは、出典を添えた 300 語以内の箇条書きで回答します。並行して実行できるよう、4 つすべてを 1 つのメッセージで起動してください。

観点 1:信頼できる標準。 NIST / OWASP / IETF(RFC と Internet-Draft)/ W3C / CISA は、過去 6〜12 か月に追加する API サーフェスまたは Web 防御について何か新たに公開しましたか?仕様の確定、廃止、後継仕様、RFC の発行、ドラフトの改訂、NIST SP の更新、OWASP プロジェクトのバージョン更新を調べてください。文書番号と公開日を添えてください。

観点 2:現在進行中の悪用。 追加する API サーフェスまたは Web 防御を標的とした、現在進行中の悪用には何がありますか?CISA の Known Exploited Vulnerabilities(KEV)カタログ(過去 6〜12 か月に絞り込み)、CVSS が高い、または実環境での悪用が報告された最近の CVE / GHSA、侵害の事後分析やインシデント報告(CSRB、ベンダーの RCA、セキュリティベンダーの調査)を調べてください。最近 KEV に追加された項目で多く見られる CWE パターンを示してください。CVE 番号と勧告の URL を添えてください。

観点 3:ツールとライブラリの状況。 このスキルが推奨するライブラリは現在も最新ですか?関連するパッケージレジストリ(npm / PyPI / RubyGems / crates.io)における最新のメジャーバージョンは何ですか?非推奨になった、置き換えられた、または別のプロジェクトに統合されたものはありますか?セキュアなデフォルト設定が変更されたものはありますか?最新バージョンを次の場所で調べてください:registry.npmjs.org、pypi.org、rubygems.org、crates.io、pkg.go.dev。パッケージ名、バージョン、リリース日を添えてください。

観点 4:実務者の議論。 過去 6 か月に、実務者やセキュリティチームは何を話題にしていますか?OWASP Cheat Sheet Series(最終更新日が重要です)、GitHub Security Lab の投稿、ベンダーのセキュリティブログ(Cloudflare、Fastly、Snyk、Datadog、Wiz、GitGuardian)、カンファレンストーク(Black Hat、DEF CON、OWASP Global AppSec、USENIX Security)、SANS ISC、Krebs、最近再公開された OWASP プロジェクトを調べてください。採用されているパターンと、問題視されているアンチパターンを示してください。投稿の URL、著者、日付を添えてください。

レシピを適用する前に統合する

4 件の調査結果がそろったら、「本日現在、追加対象の API サーフェスまたはウェブ防御の現状」と題した 1 段落を作成し、次の内容を明記してください。

  • 現在の規範的な上限(2026 年時点で、仕様がデフォルトとして SHOULD と定めている内容)。
  • 追加対象の API サーフェスまたはウェブ防御に固有の、過去 6~12 か月間の活発な脅威を 1~2 件。
  • ツールの変化(非推奨になったライブラリ、フレームワークで新たに標準となった設定、統合または置き換えられたパッケージ)。
  • 最近のチートシートやブログの更新から見て取れる、実務者のコンセンサスの変化。

統合結果から、このスキル本文のレシピに変化があると判明した場合(例:2026-05-08 より後に仕様が確定した、ライブラリが非推奨になった、デフォルト設定が変更されたなど)、回答でその点を明示し、内容が矛盾する箇所ではスキル本文の指示より優先してください。統合結果が優先されます。スキル本文は足場であり、絶対的な規範ではありません。

サブエージェントを実行できない場合

実行環境でサブエージェントを利用できない場合も、同じ形式でインライン実行してください。対象を絞った検索を順番に 4 回行います(標準規格のウェブ検索、KEV カタログの参照、パッケージレジストリでのバージョン確認、最新のチートシートの差分確認)。同じ 1 段落の統合結果を作成してください。コストは増えますが、防御の水準は変わりません。

OWASP API Security Top 10:2023 との対応

API 固有の脅威モデリングで有効な版は、OWASP API Security Top 10:2023 です(https://owasp.org/API-Security/editions/2023/en/0x00-header/)。より広範なウェブアプリケーションを対象とする一般的な OWASP Top 10:2025(2025 年後半にリリースされ、2021 年版を引き継いだもの)ではなく、API サーフェスには API 固有の 2023 年版が適切な枠組みです。

2023 年版のカテゴリと、このスキルの各項目との対応は次のとおりです。

  • API1:2023 オブジェクトレベルの認可不備:オブジェクトごとのアクセスチェックを適用します。API キー管理およびユーザーごとのレート制限とあわせて扱います。
  • API2:2023 認証の不備:認証の仕組みそのものについては secure-auth スキルを参照してください。このスキルでは、認証エンドポイントを保護するレート制限、リクエストサイズ、タイムアウト対策を扱います。
  • API3:2023 オブジェクトプロパティレベルの認可不備:入力検証(Zod / Pydantic)と、書き込み可能なフィールドの明示的な許可リストに対応します。
  • API4:2023 無制限のリソース消費:レート制限、リクエストサイズ制限、タイムアウト保護、ファイルアップロード制限に対応します。
  • API5:2023 機能レベルの認可不備:ここでは対象外です。ルートレベルの認証はフレームワークの役割です。
  • API6:2023 機密性の高いビジネスフローへの無制限なアクセス:グラフ探索のクォータ、アカウントごとの読み取り上限、クレデンシャルスタッフィングを検知するための行動シグナルに対応します。
  • API7:2023 サーバーサイドリクエストフォージェリ:送信先 URL の許可リストに対応します。タイムアウト保護と外部 API 呼び出しの項目で簡単に扱います。
  • API8:2023 セキュリティ設定の不備:セキュリティヘッダー、TLS の構成、CORS の設定に対応します。
  • API9:2023 不適切なインベントリ管理:ここでは対象外です。API ゲートウェイ/カタログの問題です。
  • API10:2023 安全でない API の利用:上流から取得したデータの入力検証と、安全なデシリアライズに対応します。

より広範なウェブの文脈では、OWASP Top 10:2025 で 2021 年版の順序が変更されています。特筆すべき変更点として、A03:2025「ソフトウェアサプライチェーンの障害」は旧 2021 年版の A06「脆弱で古いコンポーネント」を取り込み、2021 年版のこのカテゴリはサプライチェーンのカテゴリに統合されました。A09:2025 は「セキュリティログ記録とアラートの障害」(以前は「ログ記録と監視」)です。2025 年版の順序は、A01 アクセス制御の不備/A02 セキュリティ設定の不備/A03 ソフトウェアサプライチェーンの障害/A04 暗号化の失敗/A05 インジェクション/A06 安全でない設計/A07 認証の失敗/A08 ソフトウェアまたはデータの整合性の障害/A09 セキュリティログ記録とアラートの障害/A10 例外的状況への不適切な対処です。

脅威の実例

このスキルで紹介するパターンの根拠となる、2023~2024年の実際のインシデントです。ステークホルダーに「なぜ」必要なのか説明する際に引用してください。

  • Polyfill.io サプライチェーン攻撃(2024-06-25)。 Funnull は2024年2月に polyfill.io ドメインを取得し、このスクリプトを読み込んだ約110,000のサイトにマルウェアを注入しました(https://sansec.io/research/polyfill-supply-chain-attack による)。教訓:サードパーティの <script> と <link rel="stylesheet"> にはすべて Subresource Integrity ハッシュと厳格な CSP が必要です。「信頼できる CDN」だからといって保証にはなりません。
  • 23andMe のクレデンシャルスタッフィング(2023-10 に公表)。 攻撃者は流出した認証情報を23andMeアカウントに対して再利用し、その後、DNA Relatives 機能を利用して、最初に侵害した少数のアカウントから約6.9百万人のユーザーを列挙しました(https://blog.23andme.com/articles/addressing-data-security-concerns による)。教訓:IPごとのレート制限では分散型クレデンシャルスタッフィングを検出できません。また、被害範囲を抑えるには、グラフまたは関係性のエンドポイントにアカウントごとの読み取りクォータが必要です。
  • MOVEit Transfer CVE-2023-34362(2023-05-31 に公表)。 Progress MOVEit Transfer の認証前 SQL インジェクション(https://nvd.nist.gov/vuln/detail/CVE-2023-34362 による)が Cl0p によって利用され、数千の組織からデータが流出し、この攻撃チェーンは CISA KEV カタログにも登録されました。教訓:「ユーザー向け API」に見えないファイル転送経路でも、パラメーター化クエリは必須です。
  • Ivanti Connect Secure のゼロデイ攻撃チェーン(2024-01 に公表)。 CVE-2023-46805(認証バイパス)と CVE-2024-21887(コマンドインジェクション)が連鎖し、認証なしのリモートコード実行が可能になりました(https://www.cisa.gov/news-events/cybersecurity-advisories/aa24-060b による)。教訓:コマンドインジェクション(CWE-78)と認証バイパスの組み合わせは、現代における認証なし RCE の典型的な手口です。どちらの脆弱性にも対策が必要です。

レート制限

API4:2023(リソース消費の無制限)および API6:2023(機微なビジネスフロー)に対応します。

重要な理由

レート制限がないと:

  • ブルートフォース攻撃が成功します
  • 意図的か偶発的かを問わず、API が DDoS 攻撃を受けます
  • 1人の悪意あるユーザーがすべてのユーザーに影響を及ぼします
  • クラウドプロバイダーから予想外の請求が届きます

分散型クレデンシャルスタッフィングは IP ごとの制限を回避します。攻撃者はレジデンシャルプロキシネットワークを次々と切り替えるため、1つの IP がしきい値に達することはほとんどありません。IP ごとの制限に加え、アカウントごとのクォータ、行動シグナル(あり得ない移動、デバイスフィンガープリントの異常)、およびボットネットのパターンに対応するための前段の WAF を組み合わせてください。

express-rate-limit を使った Express.js

2026-05-08 時点のライブラリバージョン:express-rate-limit および rate-limit-redis。バージョンを固定する前に registry.npmjs.org で確認してください。

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis').default;
const { createClient } = require('redis');

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();

// General API rate limit
const apiLimiter = rateLimit({
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later' },
  skip: (req) => {
    // Skip rate limiting for health checks
    return req.path === '/health';
  }
});

// Strict limit for auth endpoints
const authLimiter = rateLimit({
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts
  message: { error: 'Too many login attempts, please try again in 15 minutes' },
  keyGenerator: (req) => {
    // Rate limit by IP + email to prevent distributed attacks
    return `${req.ip}-${req.body?.email || 'unknown'}`;
  }
});

// Very strict limit for password reset
const passwordResetLimiter = rateLimit({
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3, // 3 requests per hour
  message: { error: 'Too many password reset requests' }
});

// Apply limiters
app.use('/api/', apiLimiter);
app.use('/auth/login', authLimiter);
app.use('/auth/forgot-password', passwordResetLimiter);
スライディングウィンドウの実装(カスタム)
// Redis-based sliding window rate limiter
class SlidingWindowRateLimiter {
  constructor(redisClient, options = {}) {
    this.redis = redisClient;
    this.windowMs = options.windowMs || 60000; // 1 minute default
    this.maxRequests = options.maxRequests || 100;
    this.keyPrefix = options.keyPrefix || 'ratelimit';
  }

  async isAllowed(identifier) {
    const now = Date.now();
    const windowStart = now - this.windowMs;
    const key = `${this.keyPrefix}:${identifier}`;

    // Remove old entries and count recent ones
    const multi = this.redis.multi();
    multi.zRemRangeByScore(key, 0, windowStart);
    multi.zCard(key);
    multi.zAdd(key, { score: now, value: `${now}-${Math.random()}` });
    multi.expire(key, Math.ceil(this.windowMs / 1000));

    const results = await multi.exec();
    const requestCount = results[1];

    return {
      allowed: requestCount < this.maxRequests,
      remaining: Math.max(0, this.maxRequests - requestCount - 1),
      resetAt: now + this.windowMs
    };
  }
}

// Express middleware
function createRateLimitMiddleware(limiter) {
  return async (req, res, next) => {
    const identifier = req.ip;
    const result = await limiter.isAllowed(identifier);

    res.setHeader('X-RateLimit-Limit', limiter.maxRequests);
    res.setHeader('X-RateLimit-Remaining', result.remaining);
    res.setHeader('X-RateLimit-Reset', result.resetAt);

    if (!result.allowed) {
      return res.status(429).json({ error: 'Rate limit exceeded' });
    }

    next();
  };
}
API キーを使ったユーザーごとのレート制限
// Different limits based on tier
const tierLimits = {
  free: { windowMs: 60000, max: 10 },
  pro: { windowMs: 60000, max: 100 },
  enterprise: { windowMs: 60000, max: 1000 }
};

async function apiKeyRateLimiter(req, res, next) {
  const apiKey = req.headers['x-api-key'];

  if (!apiKey) {
    return res.status(401).json({ error: 'API key required' });
  }

  // Look up API key
  const keyData = await db.query(
    'SELECT user_id, tier, revoked FROM api_keys WHERE key_hash = $1',
    [hashApiKey(apiKey)]
  );

  if (keyData.rows.length === 0 || keyData.rows[0].revoked) {
    return res.status(401).json({ error: 'Invalid API key' });
  }

  const { user_id, tier } = keyData.rows[0];
  const limits = tierLimits[tier] || tierLimits.free;

  // Rate limit by user, not by key (prevents key rotation abuse)
  const limiter = new SlidingWindowRateLimiter(redisClient, {
    ...limits,
    keyPrefix: 'apikey'
  });

  const result = await limiter.isAllowed(user_id);

  res.setHeader('X-RateLimit-Limit', limits.max);
  res.setHeader('X-RateLimit-Remaining', result.remaining);
  res.setHeader('X-RateLimit-Reset', result.resetAt);

  if (!result.allowed) {
    return res.status(429).json({ error: 'Rate limit exceeded' });
  }

  req.userId = user_id;
  next();
}
アカウントごとのグラフ走査クォータ(23andMe の教訓)

関係性またはグラフを1ホップずつ展開するエンドポイント(DNA 親族、連絡先ネットワーク、フォロワーの拡散、組織のメンバーシップ検索など)は、IP ごとの制限に加えてアカウントごとのクォータを設けるべき典型例です。IP ごとの予算内であっても、侵害されたアカウント1つを使ってグラフをたどり、接続先の他のすべてのアカウントからデータを流出させることができます。これが、23andMe のクレデンシャルスタッフィングによる侵害の被害を拡大させた要因です。

// Per-account daily budget for graph-expansion endpoints
const graphLimiter = new SlidingWindowRateLimiter(redisClient, {
  windowMs: 24 * 60 * 60 * 1000, // 24 hours
  maxRequests: 500, // tune to product norms
  keyPrefix: 'graph'
});

async function graphTraversalQuota(req, res, next) {
  const result = await graphLimiter.isAllowed(req.userId);
  if (!result.allowed) {
    return res.status(429).json({ error: 'Daily graph quota exceeded' });
  }
  next();
}

app.get('/api/relatives', requireAuth, graphTraversalQuota, listRelatives);
app.get('/api/contacts/expand', requireAuth, graphTraversalQuota, expandContacts);

入力検証

API3:2023(オブジェクトプロパティレベルの認可不備)および API10:2023(API の安全でない利用)に対応します。

Zod を使った検証(TypeScript/JavaScript)

2026-05-08 時点で、zod は 4.x 系です(現行バージョンは 4.4.3。固定する前に確認してください)。以下のパターンは v4 で動作します。まだ 3.x を利用している場合も、safeParse と以下のスキーマビルダーに変更はありません。

信頼できない入力にはデシリアライズの側面もあります。詳しくは以下の専用セクションを参照してください。CWE-502(信頼できないデータのデシリアライズ)は2024~2025年に CISA KEV カタログで最多を占めたため、信頼境界では JSON のみを使用し、スキーマ検証を行うことが基本です。

const { z } = require('zod');

// Define schemas
const createUserSchema = z.object({
  email: z.string().email().max(255),
  password: z.string().min(12).max(128),
  name: z.string().min(1).max(100).optional()
});

const updateProfileSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  bio: z.string().max(500).optional(),
  website: z.string().url().optional().or(z.literal(''))
});

const paginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20)
});

// Middleware factory
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);

    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.issues.map(issue => ({
          field: issue.path.join('.'),
          message: issue.message
        }))
      });
    }

    req.validated = result.data;
    next();
  };
}

// Usage
app.post('/users', validate(createUserSchema), async (req, res) => {
  const { email, password, name } = req.validated;
  // Data is validated and typed
});
サニタイズ

Trusted Types は、関連するブラウザー側の防御機能です。危険な DOM シンクに対し、文字列ではなくポリシーで検証されたオブジェクトを使用するよう強制することで、DOM ベースの XSS のさまざまな種類を根絶します。2026-05-08 時点で、Trusted Types はクロスブラウザーに対応しています(Chromium は 2020 年から、Firefox は 148、Safari は 26.0 に対応。caniuse のグローバル利用率は約 89%)。そのため、require-trusted-types-for 'script' は、もはや Chrome 限定のあれば便利な機能ではなく、現実的な CSP ディレクティブになりました。「セキュリティヘッダー」セクションを参照してください。

const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const validator = require('validator');

const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

// HTML sanitization (when you MUST allow some HTML)
function sanitizeHtml(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href'],
    ALLOW_DATA_ATTR: false
  });
}

// String sanitization
function sanitizeString(str) {
  if (typeof str !== 'string') return '';

  return str
    .trim()
    .slice(0, 10000) // Max length
    .replace(/[\x00-\x1F\x7F]/g, ''); // Remove control characters
}

// SQL-safe identifier (for dynamic column names)
function sanitizeIdentifier(str) {
  // Only allow alphanumeric and underscores
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(str)) {
    throw new Error('Invalid identifier');
  }
  return str;
}

// Filename sanitization
function sanitizeFilename(filename) {
  return filename
    .replace(/[^a-zA-Z0-9._-]/g, '_')
    .replace(/\.{2,}/g, '.')
    .slice(0, 255);
}
SQL インジェクションの防止

OWASP Top 10:2025 の A05(インジェクション)に該当します。MOVEit の CVE-2023-34362 は、2023 年を代表する事例です。ファイル転送製品で認証前に悪用可能な SQLi が見つかり、Cl0p によって数千の組織に対して武器化されました(https://nvd.nist.gov/vuln/detail/CVE-2023-34362 による)。「内部向け」または「ユーザー向けではない」エンドポイントなら、パラメーター化を省略しても安全だと思い込まないでください。

// BAD: String interpolation
const query = `SELECT * FROM users WHERE id = ${userId}`;

// BAD: String concatenation
const query = 'SELECT * FROM users WHERE id = ' + userId;

// BAD: Template literals with user input
const query = `SELECT * FROM users WHERE name = '${name}'`;

// GOOD: Parameterized queries (PostgreSQL)
const result = await db.query(
  'SELECT * FROM users WHERE id = $1',
  [userId]
);

// GOOD: Parameterized queries (MySQL)
const result = await db.query(
  'SELECT * FROM users WHERE id = ?',
  [userId]
);

// GOOD: Query builders (Knex)
const users = await knex('users')
  .where('id', userId)
  .first();

// GOOD: ORMs (Prisma)
const user = await prisma.user.findUnique({
  where: { id: userId }
});

// When you need dynamic column names (rare)
const allowedColumns = ['name', 'email', 'created_at'];
const sortColumn = allowedColumns.includes(req.query.sort)
  ? req.query.sort
  : 'created_at';

const query = `SELECT * FROM users ORDER BY ${sortColumn}`; // Safe because allowlisted

コマンドインジェクション(CWE-78)

CWE-78(OS コマンドインジェクション)は、2024~2025 年の CISA KEV カタログで最多を占めました。2024 年は 14 件、2025 年は 18 件で、従来型の SQL インジェクションを上回っています。Ivanti Connect Secure の攻撃チェーン(CVE-2024-21887)が代表的な事例です。ルールはシンプルです。信頼できない入力からシェルコマンドを組み立ててはいけません。シェルを無効にして、言語の argv リストを受け取る spawn プリミティブを使用し、プロセスに渡すパスやファイル名は許可リストで制限してください。

Node.js

ユーザー入力の影響を受ける引数がある場合は、Node の child-process モジュールにあるシェル実行プリミティブを避けてください。これらは文字列全体を /bin/sh -c に渡すため、メタ文字(;、&、|、バッククォート、$())がインジェクションにつながります。代わりに、デフォルトでシェルが無効になっている argv リストを受け取る spawn 系の関数を使用してください。

const { exec, execFile, spawn } = require('child_process');

// DO NOT USE: shell metacharacters in `filename` execute as commands
exec(`convert ${filename} out.png`, (err, stdout) => { /* ... */ });

// DO NOT USE: same problem with execSync
require('child_process').execSync(`convert ${filename} out.png`);

// GOOD: argv-list with the shell disabled (the default for execFile/spawn)
execFile('convert', [filename, 'out.png'], (err, stdout) => { /* ... */ });

// GOOD: spawn with explicit shell:false plus an allowlist on the filename
const safeFilename = /^[A-Za-z0-9_.-]+\.(png|jpg|jpeg)$/.test(filename)
  ? filename
  : null;
if (!safeFilename) throw new Error('Invalid filename');

const child = spawn('convert', [safeFilename, 'out.png'], { shell: false });
Python

ユーザー入力の影響を受ける引数がある場合は、OS のシェル実行プリミティブ、os.system 呼び出し、shell=True を指定した subprocess.run、shell=True を指定した subprocess.Popen、および os.popen を避けてください。argv リストを指定し、shell=False(デフォルト)にした subprocess.run を使用し、信頼境界を越えるファイル名やパスは許可リストで制限してください。

import subprocess
import re
from pathlib import Path

# DO NOT USE: os.system passes the full string through the shell
import os
os.system(f"convert {filename} out.png")

# DO NOT USE: shell=True is the same vulnerability
subprocess.run(f"convert {filename} out.png", shell=True, check=True)

# DO NOT USE: os.popen also goes through the shell
os.popen(f"convert {filename} out.png").read()

# GOOD: argv list, shell disabled (the default), with input allowlisting
if not re.fullmatch(r"[A-Za-z0-9_.-]+\.(png|jpg|jpeg)", filename):
    raise ValueError("Invalid filename")

subprocess.run(
    ["convert", filename, "out.png"],
    check=True,
    shell=False,  # explicit; this is also the default
    timeout=30,
)

# GOOD: when the input is a path, resolve and confirm it's inside an allowed root
allowed_root = Path("/var/app/uploads").resolve()
candidate = (allowed_root / filename).resolve()
if not candidate.is_relative_to(allowed_root):
    raise ValueError("Path traversal attempt")

デシリアライズ(CWE-502)

CWE-502(信頼できないデータのデシリアライズ)は、2024~2025 年の KEV で 2 番目に多い CWE で、2024 年は 11 件、2025 年は 14 件でした。ネイティブのバイナリーシリアライザーや制限のない YAML ローダーは、入力をプログラムとして扱います。つまり、検証ロジックを実行する前の解析時点で任意のコードが実行されます。ルールは、信頼境界では JSON のみを使用し、使用前に解析済み JSON をスキーマ(Zod / Pydantic)で検証することです。

Python

信頼境界を越える入力では、ネイティブのバイナリーデシリアライズプリミティブ(pickle ローダー)と安全でない YAML ローダー(Loader=SafeLoader を指定しない単独の yaml.load 呼び出し)を避けてください。どちらも解析時に任意のオブジェクトを実行します。同じ注意は marshal ローダーにも当てはまります。

import json
import pickle
import yaml
from pydantic import BaseModel

# DO NOT USE: pickle.loads on untrusted input executes arbitrary code
obj = pickle.loads(request.body)

# DO NOT USE: yaml.load with no SafeLoader is a code-execution sink
config = yaml.load(request.body)  # equivalent to yaml.Loader

# DO NOT USE: marshal.loads has the same untrusted-input hazard as pickle
import marshal
obj = marshal.loads(request.body)

# GOOD: JSON parsing returns plain data (dict / list / str / number / bool / None)
class CreateUserRequest(BaseModel):
    email: str
    password: str

data = json.loads(request.body)  # safe parse
user = CreateUserRequest.model_validate(data)  # schema-validated

# GOOD: when YAML is genuinely required, use SafeLoader (or yaml.safe_load)
config = yaml.safe_load(request.body)
Node.js / Java / .NET

Node では、「これはシリアライズされたオブジェクトです」というペイロードを受け入れるライブラリ(node-serialize パッケージ、古い funcster 系パッケージ、eval ベースの JSON5 フォーク)に同様のリスクがあります。JSON.parse を使用し、Zod で検証してください。

Java では、信頼できない入力に対するバイナリーデシリアライズプリミティブ(ObjectInputStream.readObject)を避けてください。Jackson、GSON、または別の JSON ライブラリを使用するのが安全です。.NET では、レガシーのバイナリーフォーマッター(BinaryFormatter、NetDataContractSerializer、LosFormatter、SoapFormatter)を避けてください。Microsoft は、まさに CWE-502 を理由として、これらを削除する方針を示しています(https://learn.microsoft.com/en-us/dotnet/standard/serialization/binaryformatter-security-guide による)。信頼境界を越える入力には System.Text.Json を使用してください。

// DO NOT USE: a third-party deserializer on untrusted input
const obj = require('node-serialize').unserialize(request.body);

// GOOD: JSON.parse + Zod schema validation
const data = JSON.parse(request.body);
const parsed = createUserSchema.safeParse(data);
if (!parsed.success) {
  return res.status(400).json({ error: 'Validation failed' });
}

CORS 設定

API8:2023(セキュリティの設定ミス)に該当します。

Express.js
const cors = require('cors');

// Development: Allow localhost
const developmentOrigins = [
  'http://localhost:3000',
  'http://localhost:5173',
  'http://127.0.0.1:3000'
];

// Production: Specific domains only
const productionOrigins = [
  'https://yourapp.com',
  'https://www.yourapp.com',
  'https://app.yourapp.com'
];

const allowedOrigins = process.env.NODE_ENV === 'production'
  ? productionOrigins
  : [...productionOrigins, ...developmentOrigins];

const corsOptions = {
  origin: (origin, callback) => {
    // Allow requests with no origin (mobile apps, curl, etc.)
    if (!origin) {
      return callback(null, true);
    }

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
  exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining'],
  credentials: true, // Allow cookies
  maxAge: 86400 // Cache preflight for 24 hours
};

app.use(cors(corsOptions));

// Handle CORS errors
app.use((err, req, res, next) => {
  if (err.message === 'Not allowed by CORS') {
    return res.status(403).json({ error: 'CORS not allowed' });
  }
  next(err);
});
よくある CORS の間違い
// BAD: Allow all origins
app.use(cors()); // Defaults to '*'

// BAD: Allow all origins with credentials
app.use(cors({ origin: '*', credentials: true })); // Browsers will reject this

// BAD: Reflecting Origin header (allows any origin)
app.use(cors({
  origin: (origin, cb) => cb(null, origin) // Never do this
}));

// BAD: Regex that's too permissive
const origin = /yourapp\.com/; // Matches evilyourapp.com too!

// GOOD: Exact match or strict regex
const origin = /^https:\/\/(www\.)?yourapp\.com$/;
プライベートネットワークアクセス(ドラフト)

プライベートネットワークアクセス(PNA)は、公開オリジンがプライベートネットワークのリソース(LAN の IP アドレス、localhost、イントラネットのホスト名)にアクセスしようとした際に、CORS 形式のプリフライトを追加する WICG のドラフトです(https://wicg.github.io/private-network-access/)。2026-05-08 時点では Chromium のみがこれを適用しており、Chromium でも仕様の進展に伴い、適用は段階的に警告へと戻されています。ドラフトとして扱ってください。PNA を主要な防御策として頼るのではなく、あらゆる場所で有効になる日に備え、プライベートネットワークの API はネットワーク上の位置だけでなく、実際の認証で保護してください。

API キー管理

キーがユーザーごとのデータにスコープされる場合は API1:2023(オブジェクトレベル認可の不備)に、認証情報のライフサイクルについては API2:2023(認証の不備)に該当します。

セキュアなキーの生成と保存
const crypto = require('crypto');

// Generate API key
function generateApiKey() {
  // Format: prefix_randomBytes
  // Prefix helps identify key type and makes it recognizable
  const prefix = 'sk_live';
  const randomPart = crypto.randomBytes(24).toString('base64url');
  return `${prefix}_${randomPart}`;
}

// Hash for storage (never store plain keys)
function hashApiKey(key) {
  return crypto.createHash('sha256').update(key).digest('hex');
}

// Create new API key
app.post('/api-keys', requireAuth, async (req, res) => {
  const { name } = req.body;

  // Generate key
  const plainKey = generateApiKey();
  const keyHash = hashApiKey(plainKey);

  // Store only the hash
  await db.query(
    `INSERT INTO api_keys (user_id, key_hash, name, created_at)
     VALUES ($1, $2, $3, NOW())`,
    [req.userId, keyHash, name]
  );

  // Return plain key ONCE - user must save it
  res.json({
    key: plainKey,
    message: 'Save this key now. It will not be shown again.'
  });
});

// Verify API key
async function verifyApiKey(key) {
  const keyHash = hashApiKey(key);

  const result = await db.query(
    `SELECT id, user_id, revoked, last_used_at
     FROM api_keys WHERE key_hash = $1`,
    [keyHash]
  );

  if (result.rows.length === 0) {
    return null;
  }

  const keyData = result.rows[0];

  if (keyData.revoked) {
    return null;
  }

  // Update last used timestamp
  await db.query(
    'UPDATE api_keys SET last_used_at = NOW() WHERE id = $1',
    [keyData.id]
  );

  return keyData;
}

// Revoke API key
app.delete('/api-keys/:id', requireAuth, async (req, res) => {
  // Users can only revoke their own keys
  await db.query(
    'UPDATE api_keys SET revoked = true, revoked_at = NOW() WHERE id = $1 AND user_id = $2',
    [req.params.id, req.userId]
  );

  res.json({ success: true });
});
漏えい時だけでなく、定期的にローテーションする

漏えいの兆候がなくても、API キーは定期的にローテーションしてください(一般的な基準は 90 日ですが、高い権限を持つキーはさらに短くします)。長期間有効なキーにはリスクが蓄積します。ログ行の漏えい、古い CI シークレット、退職した従業員、忘れられたテストスクリプトなどが原因です。ローテーションのフローでは、重複期間に「次の」キーを「現在の」キーと並行して有効にし、呼び出し元がサービスを停止せずに切り替えられるようにしてください。

// Add columns: rotated_from, rotation_due_at
// On rotation:
// 1. Generate new key
// 2. Insert with rotated_from = old key id, expires_at = old key expiry + window
// 3. Notify the key owner with the new value (out-of-band)
// 4. Mark old key revoked = true at the end of the overlap window

// Background job: flag keys past their rotation_due_at
const stale = await db.query(
  `SELECT id, user_id, name FROM api_keys
   WHERE revoked = false
     AND rotation_due_at < NOW()`
);
// Email each owner; auto-revoke after a grace period.
API キーミドルウェア
async function apiKeyAuth(req, res, next) {
  // Accept the key from headers ONLY, never query strings.
  // Query strings are routinely logged by web servers, reverse proxies, CDN
  // edge nodes, and analytics tooling, so a key in `?api_key=...` becomes a
  // credential leak by way of access logs. OWASP API Top 10 (API2:2023) and
  // RFC 6750 §2.3 both call this out.
  const apiKey = req.headers['x-api-key']
    || req.headers['authorization']?.replace('Bearer ', '');

  if (!apiKey) {
    return res.status(401).json({
      error: 'API key required',
      hint: 'Pass API key in X-API-Key header'
    });
  }

  const keyData = await verifyApiKey(apiKey);

  if (!keyData) {
    // Don't reveal if key exists but is revoked
    return res.status(401).json({ error: 'Invalid API key' });
  }

  req.apiKeyId = keyData.id;
  req.userId = keyData.user_id;

  next();
}

リクエストサイズの制限

API4:2023(リソース消費の無制限)に該当します。

const express = require('express');

// Global body size limit
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ limit: '100kb', extended: true }));

// Per-route limits
app.post('/api/upload', express.json({ limit: '10mb' }), (req, res) => {
  // Handle large upload
});

// File upload limits
const multer = require('multer');
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 5 // Max 5 files
  },
  fileFilter: (req, file, cb) => {
    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
    if (allowedTypes.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error('Invalid file type'));
    }
  }
});

app.post('/upload', upload.single('file'), (req, res) => {
  // Handle upload
});

レスポンスのセキュリティ

API8:2023(セキュリティ設定ミス)に該当します。

情報を漏えいさせない
// BAD: Leaking stack traces
app.use((err, req, res, next) => {
  res.status(500).json({
    error: err.message,
    stack: err.stack // Never in production!
  });
});

// GOOD: Generic error in production
app.use((err, req, res, next) => {
  console.error(err); // Log full error server-side

  if (process.env.NODE_ENV === 'production') {
    res.status(500).json({ error: 'Internal server error' });
  } else {
    res.status(500).json({ error: err.message, stack: err.stack });
  }
});

// BAD: Revealing database structure
res.status(400).json({
  error: 'duplicate key value violates unique constraint "users_email_key"'
});

// GOOD: User-friendly error
res.status(400).json({
  error: 'An account with this email already exists'
});

XSS の防止

OWASP Top 10:2025 A05(インジェクション)に該当します。2026 年における XSS 防御の優先順位は次のとおりです。

  1. nonce と 'strict-dynamic' を使用した CSP3。モダンな厳格 CSP です。許可リスト(script-src https://cdn.foo.com …)は、もはや推奨されません。Weichselbaum らによると、許可リストは日常的に回避可能です。nonce またはハッシュと 'strict-dynamic' の組み合わせが、W3C によって推奨されている厳格 CSP です。
  2. Trusted Types(require-trusted-types-for 'script')。危険なシンク(innerHTML、eval、setTimeout(string))がポリシーで検証済みのオブジェクトを受け取るよう強制し、DOM ベースの XSS を防ぎます。2026 年時点でクロスブラウザーに対応しています(Chromium は 2020 年から、Firefox 148、Safari 26.0。https://caniuse.com/trusted-types によると、世界全体の利用率は約 89%)。
  3. フレームワークの自動エスケープ。React、Vue、Angular、Svelte、および最新のテンプレートエンジンは、補間された文字列をデフォルトでエスケープします。DOMPurify を使わずに、バイパス API(dangerouslySetInnerHTML、v-html、[innerHTML]、{@html})を使わないでください。
  4. サーバー側の最後の境界で手動エスケープする。テンプレートを使わない HTML レスポンスに値を挿入する必要がある場合は、実績のあるエスケーパーを使用してください。
// BAD: Directly inserting user content
res.send(`<h1>Hello ${userName}</h1>`);

// GOOD: Use a template engine with auto-escaping
// EJS (auto-escapes by default with <%= %>)
res.render('greeting', { name: userName });

// GOOD: Escape manually when needed
const escapeHtml = require('escape-html');
res.send(`<h1>Hello ${escapeHtml(userName)}</h1>`);

// GOOD: Set Content-Type for JSON responses
res.json({ name: userName }); // Express sets correct headers

// In React/Vue/Angular/Svelte: framework handles escaping by default.
// Don't use dangerouslySetInnerHTML / v-html / [innerHTML] / {@html} on user input.

'unsafe-inline' とインラインスクリプトについては、セキュリティヘッダーのセクションを参照してください。nonce をテンプレートに通すよう設定すれば、厳格 CSP3 によってインラインスクリプトに関する問題は解消されます。

セキュリティヘッダー

API8:2023(セキュリティ設定ミス)に該当します。2026-05-08 時点で helmet は 8.x 系です(現行バージョンは 8.1.0。固定する前に確認してください)。2026 年の厳格 CSP の基準では、以前の「すべての X- ヘッダーを設定する」というアドバイスに代わるものになっています。

const helmet = require('helmet');
const crypto = require('crypto');

// Per-request CSP nonce middleware (wire this into your template engine)
app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
  next();
});

// Strict CSP3: nonce + 'strict-dynamic' + downgrade fallbacks
app.use(helmet.contentSecurityPolicy({
  useDefaults: false,
  directives: {
    defaultSrc: ["'self'"],
    // Strict CSP per https://www.w3.org/TR/CSP3/ and MDN strict-CSP guide.
    // 'strict-dynamic' lets nonced scripts load further scripts;
    // https: + 'unsafe-inline' are downgrade fallbacks for old browsers
    // that ignore 'strict-dynamic' (modern browsers ignore the fallbacks).
    scriptSrc: [
      (req, res) => `'nonce-${res.locals.cspNonce}'`,
      "'strict-dynamic'",
      'https:',
      "'unsafe-inline'"
    ],
    styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
    imgSrc: ["'self'", 'data:', 'https:'],
    connectSrc: ["'self'", 'https://api.yourapp.com'],
    fontSrc: ["'self'", 'https://fonts.gstatic.com'],
    objectSrc: ["'none'"],
    baseUri: ["'self'"],
    frameAncestors: ["'none'"], // replaces X-Frame-Options
    formAction: ["'self'"],
    upgradeInsecureRequests: [],
    // Trusted Types: kills DOM-based XSS at the sink.
    // Cross-browser as of 2026 per https://caniuse.com/trusted-types
    requireTrustedTypesFor: ["'script'"],
    // Reporting via the Reporting API (W3C WD)
    reportTo: ['default']
  }
}));

// HSTS: 2 years + includeSubDomains + preload (per https://hstspreload.org)
// Operationally irreversible once preload-listed, verify subdomains first.
app.use(helmet.hsts({
  maxAge: 63072000,
  includeSubDomains: true,
  preload: true
}));

// Cross-origin isolation (COOP / COEP / CORP)
// Required for crossOriginIsolated === true (SharedArrayBuffer, high-resolution timers).
// Per WHATWG HTML Origin chapter: https://html.spec.whatwg.org/multipage/origin.html
app.use((req, res, next) => {
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
  res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); // or 'credentialless'
  res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
  next();
});

// Permissions-Policy: structured-fields syntax, opt-out of dangerous features.
// Per https://www.w3.org/TR/permissions-policy/ (W3C WD 2025-10-06; per-directive
// caniuse lookup required, this is "Limited Availability", not Baseline).
app.use((req, res, next) => {
  res.setHeader(
    'Permissions-Policy',
    'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()'
  );
  next();
});

// Reporting API: Reporting-Endpoints + report-to wiring.
// Per https://www.w3.org/TR/reporting-1/
app.use((req, res, next) => {
  res.setHeader(
    'Reporting-Endpoints',
    'default="https://reports.yourapp.com/csp"'
  );
  next();
});

// Keep nosniff
app.use(helmet.noSniff());

// Note: X-XSS-Protection is deprecated and ignored by modern browsers, do not set it.
// X-Frame-Options is replaced by CSP's frame-ancestors directive (set above).

HTML をレンダリングするときは、テンプレートで res.locals.cspNonce を読み取り、すべてのインライン <script> タグと <style> タグに設定する必要があります(<script nonce="<%= cspNonce %>">…)。これにより、厳格な動的ポリシーが信頼するスクリプトを決定できます。

サブリソース完全性

OWASP Top 10:2025 A03(ソフトウェアサプライチェーンの障害)に対応します。Polyfill.io の 2024 年のインシデントが、その典型的な事例です。Funnull は 2024 年 2 月に polyfill.io ドメインを買収し、そのスクリプトを読み込んだ約 11 万のサイトにマルウェアを挿入しました(https://sansec.io/research/polyfill-supply-chain-attack を参照)。CSP の 'strict-dynamic' ポリシーは、スクリプトが別のスクリプトを読み込む不正な連鎖を阻止します。SRI ハッシュを設定していれば、ポリシーが読み込みを許可していても、読み込みに失敗します。

すべてのサードパーティ製 <script src> と <link rel="stylesheet"> には、integrity 属性を付けるべきです。SRI は W3C 勧告です(https://www.w3.org/TR/SRI/)。

<script
  src="https://cdn.example.com/lib.min.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"></script>

<link
  rel="stylesheet"
  href="https://cdn.example.com/lib.min.css"
  integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
  crossorigin="anonymous">

取得したアセットから SRI ハッシュを生成します。

curl -fsSL https://cdn.example.com/lib.min.js \
  | openssl dgst -sha384 -binary \
  | openssl base64 -A

cdnjs と jsdelivr のコピー&ペースト用スニペットには integrity 属性が含まれています。unpkg には「SRI-on」モードがあります。これらを使ってください。特定のバージョンを指定し、@latest は決して使わないでください。

TLS の設定方針

OWASP Top 10:2025 A04(暗号化の失敗)および API8:2023(セキュリティ設定の不備)に対応します。

  • TLS 1.3 をデフォルトにします。RFC 8446(https://datatracker.ietf.org/doc/rfc8446/)は 2018 年 8 月に公開されました。
  • TLS 1.2 を最低バージョンにします。それより古いバージョンは非推奨です。
  • TLS 1.0 と TLS 1.1 を無効にします。RFC 8996 / BCP 195(https://datatracker.ietf.org/doc/rfc8996/)は、2021 年 3 月に両方を正式に非推奨としています。
  • SSL Labs は v2009r(2025 年 5 月 16 日)以降、TLS 1.3 を使用しないサーバーの評価を A- までに制限しています。A または A+ の評価を得るには、TLS 1.3 を利用可能にする必要があります。
  • 今後の方向性は耐量子ハイブリッド鍵交換です。Cloudflare によると、2025 年後半には HTTPS トラフィックの半分以上で、耐量子ハイブリッド鍵交換(X25519+Kyber / X25519MLKEM768)がネゴシエートされました。この分野は急速に変化しているため、CDN / ロードバランサーの最新の対応状況を確認してください。

マネージドエッジ(Cloudflare、Fastly、AWS CloudFront、Azure Front Door)の背後で運用している場合、通常は「最小 TLS バージョン」を 1.2 以上(推奨は 1.3)に設定するだけで十分です。暗号スイートのネゴシエーションはエッジに任せてください。自分で TLS を終端する場合は、Mozilla SSL Configuration Generator(https://ssl-config.mozilla.org/)で最新の「intermediate」または「modern」プロファイルを確認してください。

タイムアウト保護

API4:2023(リソース消費の制限なし)および API7:2023(サーバーサイドリクエストフォージェリ。タイムアウトと送信先 URL の許可リストを組み合わせてください)に対応します。

// Request timeout middleware
function timeout(ms) {
  return (req, res, next) => {
    res.setTimeout(ms, () => {
      res.status(408).json({ error: 'Request timeout' });
    });
    next();
  };
}

app.use(timeout(30000)); // 30 second default

// External API call timeout
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    });
    return response;
  } finally {
    clearTimeout(timeoutId);
  }
}

// Database query timeout
const result = await db.query({
  text: 'SELECT * FROM large_table WHERE condition = $1',
  values: [value],
  timeout: 5000 // 5 second query timeout
});

FastAPI(Python)での同等の設定

2026-05-08 時点のライブラリバージョンです(固定する前に PyPI で確認してください)。

  • fastapi は 0.x 系です(現行は 0.136.1。1.0 より前のため、マイナー単位で固定してください。マイナー間で破壊的変更が入ることがあります)。
  • slowapi は 0.1.x 系です(1.0 より前のため、マイナー単位で固定してください)。
  • pydantic は 2.x 系です(現行は 2.13.4)。
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter
from slowapi.util import get_remote_address
from pydantic import BaseModel, EmailStr, Field
import hashlib
import secrets

app = FastAPI()

# CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourapp.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["*"],
)

# Rate limiting
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter

@app.post("/api/login")
@limiter.limit("5/minute")
async def login(request: Request, credentials: LoginRequest):
    # Handle login
    pass

# Input validation with Pydantic 2.x
class CreateUserRequest(BaseModel):
    email: EmailStr
    password: str = Field(min_length=12, max_length=128)
    name: str = Field(max_length=100, default=None)

@app.post("/users")
async def create_user(user: CreateUserRequest):
    # Data is already validated
    pass

# API key generation
def generate_api_key() -> str:
    return f"sk_live_{secrets.token_urlsafe(24)}"

def hash_api_key(key: str) -> str:
    return hashlib.sha256(key.encode()).hexdigest()

API のセキュリティチェックリスト

OWASP API Security Top 10:2023 のカテゴリ別に整理しています。該当しないカテゴリ(ここでは対象外の API5 / API9)はスキップしてください。

API1:2023 オブジェクトレベルの認可の不備
  • すべての取得/更新/削除で、オブジェクトごとにアクセスをチェックする
  • API キーはテナント間で共有せず、ユーザーごとにスコープを設定する
  • 必要に応じて、オブジェクト ID が予測可能または列挙可能にならないようにする
API2:2023 認証の不備
  • 認証エンドポイントには、一般的な API エンドポイントより厳しいレート制限を設ける
  • パスワードリセットエンドポイントには、アカウントごとに非常に厳しい制限を設ける
  • すべての認証フローを HTTPS のみで実行する
  • 認証の基盤となる仕組みについては、secure-auth スキルを参照する
API3:2023 オブジェクトプロパティレベルの認可の不備
  • 入力をスキーマ(Zod / Pydantic)に照らして検証する
  • 書き込み可能なフィールドを明示的な許可リストで指定し、「リクエスト本文のすべて」を許可しない
  • 一括割り当てにつながる処理(Object.assign、model.update(req.body))を避ける
API4:2023 リソース消費の制限なし
  • すべてのエンドポイントにレート制限を設定する
  • リクエスト本文のサイズ制限を設定する
  • ファイルのアップロードサイズと個数の上限を設定する
  • すべての DB クエリと外部呼び出しにタイムアウトを設定する
  • リストエンドポイントにページネーションの上限を設定する
API6:2023 機密性の高いビジネスフローへの無制限アクセス
  • グラフ/関係性/ファンアウトのエンドポイントにアカウント単位のクォータを設定します(23andMeの教訓)
  • 認証情報の大量試行が狙われやすいエンドポイントに、行動シグナル(移動不可能な移動、デバイスフィンガープリント)を導入します
  • 分散型の不正利用パターンに備え、前段にWAFまたはボット管理を配置します
API7:2023 サーバーサイドリクエストフォージェリ
  • アウトバウンドHTTP呼び出しの送信先を、許可リストに登録されたホストのみに制限します
  • すべてのアウトバウンド呼び出しにタイムアウトを設定します
  • クラウドメタデータのエンドポイントをネットワーク層でブロックします
API8:2023 セキュリティの設定ミス
  • 厳格なCSP3:nonce + 'strict-dynamic' + Trusted Types
  • max-age=63072000; includeSubDomains; preload を指定したHSTS
  • COOP/COEP/CORPを設定します
  • 危険な機能を制限するPermissions-Policy
  • X-Content-Type-Options: nosniff
  • X-XSS-Protection は設定しません(非推奨)
  • X-Frame-Options をCSPの frame-ancestors に置き換えます
  • CORSは特定のオリジンに設定し、認証情報を使用する場合は * を決して指定しません
  • TLS 1.3をデフォルト、TLS 1.2を最小バージョンとし、TLS 1.0/1.1を無効にします
  • すべてのサードパーティ製 <script> と <link rel="stylesheet"> にSRIハッシュを設定します
  • エラーメッセージからシステム情報やDB構造が漏れないようにします
API10:2023 APIの安全でない利用
  • アップストリームのレスポンスを、ユーザー入力と同じ厳密さで検証します
  • 信頼境界を越える入力に対して、ネイティブのバイナリデシリアライザーや制限のないYAMLローダーを使用しません
  • アップストリームで制御される値を組み合わせたシェル実行プリミティブを使用しません
横断的な対策
  • APIキーは保存前にハッシュ化し、侵害時だけでなく定期的にローテーションします
  • 互換性を破る変更に備えてAPIをバージョン管理します

コメント