🔒

Webセキュリティベストプラクティス - OWASP Top 10対策

20分 で読める | 2025.12.02

Webアプリケーションのセキュリティは、開発者が最初から考慮すべき重要な要素です。OWASP(Open Web Application Security Project)が公開するTop 10は、最も一般的で危険な脆弱性のリストであり、すべての開発者が理解すべき内容です。本記事では、OWASP Top 10の各脆弱性と実践的な対策を解説します。

OWASP Top 10 2021

脆弱性一覧

ランクカテゴリ説明
A01アクセス制御の不備認可チェックの欠如・不備
A02暗号化の失敗機密データの不適切な保護
A03インジェクションSQL、XSS、コマンドインジェクション
A04安全でない設計セキュリティを考慮しない設計
A05セキュリティ設定ミスデフォルト設定、不要な機能
A06脆弱なコンポーネント既知の脆弱性を含むライブラリ
A07認証の失敗認証メカニズムの不備
A08整合性の欠如安全でない更新、CI/CD
A09ログとモニタリングの欠如攻撃検知の失敗
A10SSRFサーバーサイドリクエストフォージェリ

A03: インジェクション攻撃

SQLインジェクション

// 脆弱なコード
async function getUser(userId: string) {
  const query = `SELECT * FROM users WHERE id = '${userId}'`;
  return db.query(query);
}
// userId = "1' OR '1'='1" → 全ユーザーが取得される

// 安全なコード(パラメータ化クエリ)
async function getUser(userId: string) {
  const query = 'SELECT * FROM users WHERE id = $1';
  return db.query(query, [userId]);
}

// ORMを使用(Prisma)
async function getUser(userId: string) {
  return prisma.user.findUnique({
    where: { id: userId }
  });
}

XSS(クロスサイトスクリプティング)

// 脆弱なコード
function renderComment(comment: string) {
  document.getElementById('comments').innerHTML = comment;
}
// comment = "<script>alert('XSS')</script>" → スクリプト実行

// 安全なコード(エスケープ)
function escapeHtml(text: string): string {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

function renderComment(comment: string) {
  const escaped = escapeHtml(comment);
  document.getElementById('comments').innerHTML = escaped;
}

// より安全:textContentを使用
function renderComment(comment: string) {
  document.getElementById('comments').textContent = comment;
}

// React/Vueは自動的にエスケープ
function CommentList({ comments }: { comments: string[] }) {
  return (
    <ul>
      {comments.map((c, i) => <li key={i}>{c}</li>)}
    </ul>
  );
}

Content Security Policy(CSP)

// Express.js でのCSP設定
import helmet from 'helmet';

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'strict-dynamic'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", "data:", "https:"],
    connectSrc: ["'self'", "https://api.example.com"],
    fontSrc: ["'self'", "https://fonts.gstatic.com"],
    objectSrc: ["'none'"],
    baseUri: ["'self'"],
    frameAncestors: ["'none'"],
    upgradeInsecureRequests: [],
  },
}));

A01: アクセス制御の不備

認可チェックの実装

// 脆弱なコード
app.get('/api/users/:id', async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.params.id } });
  res.json(user);  // 誰でもアクセス可能
});

// 安全なコード
app.get('/api/users/:id', authenticate, async (req, res) => {
  const userId = req.params.id;
  const currentUser = req.user;

  // 自分自身または管理者のみアクセス可能
  if (userId !== currentUser.id && currentUser.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }

  const user = await db.user.findUnique({ where: { id: userId } });
  res.json(user);
});

IDOR(Insecure Direct Object Reference)対策

// 脆弱なコード
app.get('/api/orders/:orderId', async (req, res) => {
  const order = await db.order.findUnique({
    where: { id: req.params.orderId }
  });
  res.json(order);  // 他人の注文も見れる
});

// 安全なコード
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
  const order = await db.order.findFirst({
    where: {
      id: req.params.orderId,
      userId: req.user.id  // 所有者チェック
    }
  });

  if (!order) {
    return res.status(404).json({ error: 'Order not found' });
  }

  res.json(order);
});

RBAC(ロールベースアクセス制御)

// ロール定義
const ROLES = {
  admin: ['read', 'write', 'delete', 'manage_users'],
  editor: ['read', 'write'],
  viewer: ['read'],
} as const;

// 認可ミドルウェア
function authorize(...requiredPermissions: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    const userRole = req.user?.role;
    const userPermissions = ROLES[userRole] || [];

    const hasPermission = requiredPermissions.every(
      p => userPermissions.includes(p)
    );

    if (!hasPermission) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    next();
  };
}

// 使用例
app.delete('/api/posts/:id',
  authenticate,
  authorize('delete'),
  deletePost
);

A07: 認証の失敗

安全なパスワード処理

import bcrypt from 'bcrypt';
import crypto from 'crypto';

// パスワードのハッシュ化
async function hashPassword(password: string): Promise<string> {
  const saltRounds = 12;
  return bcrypt.hash(password, saltRounds);
}

// パスワードの検証
async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

// パスワード強度の検証
function validatePasswordStrength(password: string): { valid: boolean; errors: string[] } {
  const errors: string[] = [];

  if (password.length < 12) {
    errors.push('パスワードは12文字以上必要です');
  }
  if (!/[A-Z]/.test(password)) {
    errors.push('大文字を含める必要があります');
  }
  if (!/[a-z]/.test(password)) {
    errors.push('小文字を含める必要があります');
  }
  if (!/[0-9]/.test(password)) {
    errors.push('数字を含める必要があります');
  }
  if (!/[!@#$%^&*]/.test(password)) {
    errors.push('特殊文字を含める必要があります');
  }

  return { valid: errors.length === 0, errors };
}

ブルートフォース対策

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

// レート制限(ログインエンドポイント)
const loginLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:login:'
  }),
  windowMs: 15 * 60 * 1000,  // 15分
  max: 5,  // 最大5回
  skipSuccessfulRequests: true,  // 成功したリクエストはカウントしない
  message: {
    error: 'ログイン試行回数が多すぎます。15分後に再試行してください。'
  }
});

app.post('/api/auth/login', loginLimiter, loginHandler);

// アカウントロック機能
async function checkAccountLock(userId: string): Promise<boolean> {
  const lockKey = `lock:${userId}`;
  const failKey = `fail:${userId}`;

  const lockUntil = await redis.get(lockKey);
  if (lockUntil && Date.now() < parseInt(lockUntil)) {
    return true;  // ロック中
  }

  return false;
}

async function recordFailedAttempt(userId: string): Promise<void> {
  const failKey = `fail:${userId}`;
  const lockKey = `lock:${userId}`;

  const attempts = await redis.incr(failKey);
  await redis.expire(failKey, 3600);  // 1時間でリセット

  if (attempts >= 5) {
    // 30分間ロック
    await redis.set(lockKey, Date.now() + 30 * 60 * 1000);
    await redis.expire(lockKey, 1800);
  }
}

セッション管理

import session from 'express-session';
import RedisStore from 'connect-redis';

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET!,
  name: 'sessionId',  // デフォルトの 'connect.sid' を変更
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,     // JavaScriptからアクセス不可
    secure: true,       // HTTPS必須
    sameSite: 'lax',    // CSRF対策
    maxAge: 24 * 60 * 60 * 1000,  // 24時間
  }
}));

// ログアウト時のセッション無効化
app.post('/api/auth/logout', (req, res) => {
  const sessionId = req.sessionID;

  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: 'ログアウトに失敗しました' });
    }

    // Cookieも削除
    res.clearCookie('sessionId');
    res.json({ message: 'ログアウトしました' });
  });
});

CSRF(クロスサイトリクエストフォージェリ)対策

CSRFトークン

import csrf from 'csurf';

// CSRFミドルウェア
const csrfProtection = csrf({
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'strict'
  }
});

// トークンを取得するエンドポイント
app.get('/api/csrf-token', csrfProtection, (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

// 保護されたエンドポイント
app.post('/api/transfer', csrfProtection, (req, res) => {
  // CSRFトークンが自動検証される
  // ...
});
// フロントエンドでの使用
async function makeRequest(url: string, data: object) {
  // CSRFトークンを取得
  const { csrfToken } = await fetch('/api/csrf-token').then(r => r.json());

  return fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken,
    },
    credentials: 'include',
    body: JSON.stringify(data),
  });
}
// モダンなCSRF対策
res.cookie('session', sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',  // または 'lax'
});

A02: 暗号化の失敗

データの暗号化

import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');  // 32バイト

// 暗号化
function encrypt(plaintext: string): string {
  const iv = crypto.randomBytes(12);  // GCMは12バイトのIV
  const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);

  let encrypted = cipher.update(plaintext, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  const authTag = cipher.getAuthTag();

  // IV + AuthTag + 暗号文を結合
  return iv.toString('hex') + authTag.toString('hex') + encrypted;
}

// 復号
function decrypt(ciphertext: string): string {
  const iv = Buffer.from(ciphertext.slice(0, 24), 'hex');
  const authTag = Buffer.from(ciphertext.slice(24, 56), 'hex');
  const encrypted = ciphertext.slice(56);

  const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
  decipher.setAuthTag(authTag);

  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}

HTTPS強制

// HSTSヘッダー
app.use(helmet.hsts({
  maxAge: 31536000,  // 1年
  includeSubDomains: true,
  preload: true,
}));

// HTTPからHTTPSへのリダイレクト
app.use((req, res, next) => {
  if (req.headers['x-forwarded-proto'] !== 'https') {
    return res.redirect(301, `https://${req.hostname}${req.url}`);
  }
  next();
});

A10: SSRF(サーバーサイドリクエストフォージェリ)

SSRF対策

import { URL } from 'url';
import dns from 'dns/promises';

// 許可されたホストのリスト
const ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com'];

async function fetchUrl(urlString: string): Promise<Response> {
  const url = new URL(urlString);

  // プロトコルチェック
  if (!['http:', 'https:'].includes(url.protocol)) {
    throw new Error('Invalid protocol');
  }

  // ホスト名チェック
  if (!ALLOWED_HOSTS.includes(url.hostname)) {
    throw new Error('Host not allowed');
  }

  // プライベートIPへのアクセスを防止
  const addresses = await dns.resolve4(url.hostname);
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new Error('Private IP not allowed');
    }
  }

  return fetch(url.toString());
}

function isPrivateIP(ip: string): boolean {
  const parts = ip.split('.').map(Number);

  // ローカルホスト
  if (parts[0] === 127) return true;

  // プライベートアドレス
  if (parts[0] === 10) return true;
  if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
  if (parts[0] === 192 && parts[1] === 168) return true;

  // リンクローカル
  if (parts[0] === 169 && parts[1] === 254) return true;

  return false;
}

セキュリティヘッダー

推奨ヘッダー設定

import helmet from 'helmet';

app.use(helmet());

// 個別設定
app.use(helmet.frameguard({ action: 'deny' }));  // クリックジャッキング対策
app.use(helmet.noSniff());  // MIMEタイプスニッフィング対策
app.use(helmet.xssFilter());  // XSSフィルター
app.use(helmet.referrerPolicy({ policy: 'strict-origin-when-cross-origin' }));

// カスタムヘッダー
app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
  next();
});

レスポンスヘッダーチェックリスト

セキュリティヘッダー チェックリスト:

□ Content-Security-Policy
□ Strict-Transport-Security
□ X-Frame-Options
□ X-Content-Type-Options
□ Referrer-Policy
□ Permissions-Policy
□ X-XSS-Protection(レガシー)

依存関係のセキュリティ

脆弱性スキャン

# npm audit
npm audit
npm audit fix

# Snyk
npx snyk test
npx snyk monitor

# GitHub Dependabot
# .github/dependabot.yml で設定
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10

まとめ

Webセキュリティは継続的な取り組みが必要です。

開発時のチェックリスト

  1. 入力検証: すべてのユーザー入力を検証・サニタイズ
  2. 認証・認可: 適切なアクセス制御の実装
  3. 暗号化: 機密データの暗号化、HTTPS強制
  4. セッション管理: 安全なCookie設定
  5. 依存関係: 定期的な脆弱性スキャン

運用時のチェックリスト

  1. ログ・モニタリング: 異常検知の仕組み
  2. インシデント対応: 対応計画の策定
  3. 定期的な監査: ペネトレーションテスト
  4. 教育: 開発者のセキュリティ意識向上

セキュリティは「後から追加する機能」ではなく、設計段階から組み込むべき要素です。

参考リンク

← 一覧に戻る