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

ポイント: セキュリティは「多層防御」が原則です。1つの対策に頼るのではなく、入力バリデーション・出力エスケープ・CSP・HTTPSを組み合わせましょう。

まとめ

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

開発時のチェックリスト

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

運用時のチェックリスト

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

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

参考リンク

注意: ユーザー入力を信頼してはいけません。フロントエンドのバリデーションだけでなく、必ずサーバーサイドでもバリデーションを行ってください。

実践メモ: OWASP Top 10を定期的に確認し、最新の脆弱性動向を把握しましょう

参考リソース

← 一覧に戻る
PR
PR
PR
PR