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 | ログとモニタリングの欠如 | 攻撃検知の失敗 |
| A10 | SSRF | サーバーサイドリクエストフォージェリ |
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),
});
}
SameSite Cookie
// モダンな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セキュリティは継続的な取り組みが必要です。
開発時のチェックリスト
- 入力検証: すべてのユーザー入力を検証・サニタイズ
- 認証・認可: 適切なアクセス制御の実装
- 暗号化: 機密データの暗号化、HTTPS強制
- セッション管理: 安全なCookie設定
- 依存関係: 定期的な脆弱性スキャン
運用時のチェックリスト
- ログ・モニタリング: 異常検知の仕組み
- インシデント対応: 対応計画の策定
- 定期的な監査: ペネトレーションテスト
- 教育: 開発者のセキュリティ意識向上
セキュリティは「後から追加する機能」ではなく、設計段階から組み込むべき要素です。