セキュアコーディング実践ガイド2025 - 最新サイバー脅威への対策とベストプラクティス
2025年の最新サイバー脅威に対応するセキュアコーディングの実践方法を徹底解説。AI悪用攻撃、サプライチェーン攻撃、ゼロトラストセキュリティなど、最新の脅威動向と具体的な対策コードを紹介します。
OWASP API Security Top 10 2023年版の最新リストに基づく、APIセキュリティの脅威と実践的な対策方法を徹底解説。95%の組織が経験するAPI攻撃への対策を、本番環境で使える具体的な実装例と共に紹介します。
API セキュリティは、現代の Web 開発において最も重要な課題の 1 つです。OWASP API Security Top 10 は、API 開発者が直面する最も深刻なセキュリティリスクを特定し、対策を提供しています。
95%の組織が何らかの API セキュリティインシデントを経験し、BOLA は全 API 攻撃の約 40%を占めています。2025 年までに 5 億の新規 API が作成される中、最新の脅威に対応した対策が不可欠です。
オブジェクトレベルの認可不備 - 全API攻撃の40%
認証の不備
プロパティレベルの認可不備 - 過剰なデータ公開とMass Assignmentを統合
無制限のリソース消費 - DoS攻撃やコスト爆発の原因
機能レベルの認可不備
センシティブなビジネスフローへの無制限アクセス - 新規追加
サーバーサイドリクエストフォージェリ - 新規追加
セキュリティ設定の不備
不適切なインベントリ管理
APIの安全でない使用 - 新規追加
最も一般的で深刻な脆弱性で、全 API 攻撃の約 40%を占めています。ユーザーが他のユーザーのデータにアクセスできてしまう問題です。
/shops/{shopName}/revenue_data.json
パターンで数千店舗の売上データが流出// 危険:ユーザーIDの検証なし
app.get('/api/users/:id/profile', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});
// 危険:直接的なオブジェクト参照
app.get('/api/orders/:orderId', async (req, res) => {
const order = await Order.findById(req.params.orderId);
res.json(order);
});
// 安全:認証とオブジェクトレベルの認可チェック
app.get('/api/users/:id/profile', authenticate, async (req, res) => {
// 自分のプロファイルのみアクセス可能
if (req.user.id !== req.params.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await User.findById(req.params.id);
res.json(user);
});
// 安全:所有権の確認
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await Order.findOne({
_id: req.params.orderId,
userId: req.user.id // 所有権を確認
});
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
res.json(order);
});
// 危険:ユーザーIDの検証なし
app.get('/api/users/:id/profile', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});
// 危険:直接的なオブジェクト参照
app.get('/api/orders/:orderId', async (req, res) => {
const order = await Order.findById(req.params.orderId);
res.json(order);
});
// 安全:認証とオブジェクトレベルの認可チェック
app.get('/api/users/:id/profile', authenticate, async (req, res) => {
// 自分のプロファイルのみアクセス可能
if (req.user.id !== req.params.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await User.findById(req.params.id);
res.json(user);
});
// 安全:所有権の確認
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await Order.findOne({
_id: req.params.orderId,
userId: req.user.id // 所有権を確認
});
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
res.json(order);
});
// authorization.middleware.js
const checkResourceOwnership = (resourceType) => {
return async (req, res, next) => {
try {
const resourceId = req.params.id || req.params[`${resourceType}Id`];
const Model = require(`../models/${resourceType}`);
const resource = await Model.findById(resourceId);
if (!resource) {
return res.status(404).json({ error: 'Resource not found' });
}
// 所有権の確認
if (resource.userId.toString() !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Access denied' });
}
req.resource = resource;
next();
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
};
};
// 使用例
app.get('/api/orders/:id',
authenticate,
checkResourceOwnership('order'),
(req, res) => {
res.json(req.resource);
}
);
# authorization.py
from functools import wraps
from fastapi import HTTPException, Depends, status
from typing import Callable
def check_resource_ownership(resource_type: str):
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs):
# 現在のユーザーを取得
current_user = kwargs.get('current_user')
resource_id = kwargs.get('resource_id')
# リソースを取得
Model = globals()[resource_type.capitalize()]
resource = await Model.get(resource_id)
if not resource:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Resource not found"
)
# 所有権の確認
if resource.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
kwargs['resource'] = resource
return await func(*args, **kwargs)
return wrapper
return decorator
# 使用例
@app.get("/orders/{order_id}")
@check_resource_ownership("order")
async def get_order(
order_id: str,
current_user: User = Depends(get_current_user),
resource: Order = None
):
return resource
// middleware/authorization.go
func CheckResourceOwnership(resourceType string) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString("userID")
resourceID := c.Param("id")
var ownerID string
switch resourceType {
case "order":
var order models.Order
if err := db.First(&order, resourceID).Error; err != nil {
c.JSON(404, gin.H{"error": "Resource not found"})
c.Abort()
return
}
ownerID = order.UserID
c.Set("resource", order)
case "profile":
var profile models.Profile
if err := db.First(&profile, resourceID).Error; err != nil {
c.JSON(404, gin.H{"error": "Resource not found"})
c.Abort()
return
}
ownerID = profile.UserID
c.Set("resource", profile)
}
// 所有権の確認
if ownerID != userID && !isAdmin(userID) {
c.JSON(403, gin.H{"error": "Access denied"})
c.Abort()
return
}
c.Next()
}
}
// 使用例
router.GET("/orders/:id",
AuthMiddleware(),
CheckResourceOwnership("order"),
handlers.GetOrder,
)
認証の実装不備は、アカウントの乗っ取りやなりすましを可能にします。
// 危険:署名検証なし、有効期限なし
const jwt = require('jsonwebtoken');
app.post('/api/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (user && user.password === req.body.password) {
// 危険:平文パスワード、弱い秘密鍵
const token = jwt.sign({ userId: user.id }, 'secret');
res.json({ token });
}
});
// 危険:トークン検証が不十分
app.get('/api/protected', (req, res) => {
const token = req.headers.authorization;
const decoded = jwt.decode(token); // 検証なし!
res.json({ userId: decoded.userId });
});
// 安全:適切な認証実装
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
// 環境変数から強力な秘密鍵を取得
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// レート制限チェック
if (await isRateLimited(req.ip)) {
return res.status(429).json({ error: 'Too many attempts' });
}
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// タイミング攻撃を防ぐため、常に同じ応答時間
await bcrypt.compare('dummy', '$2b$10$dummy.hash');
return res.status(401).json({ error: 'Invalid credentials' });
}
// アクセストークンとリフレッシュトークンを生成
const accessToken = jwt.sign(
{
userId: user.id,
type: 'access',
jti: crypto.randomUUID() // JWT ID for revocation
},
JWT_SECRET,
{
expiresIn: JWT_EXPIRY,
issuer: 'api.example.com',
audience: 'app.example.com'
}
);
const refreshToken = jwt.sign(
{
userId: user.id,
type: 'refresh',
jti: crypto.randomUUID()
},
JWT_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
// リフレッシュトークンをデータベースに保存
await saveRefreshToken(user.id, refreshToken);
res.json({
accessToken,
refreshToken,
expiresIn: 900 // 15 minutes in seconds
});
});
// 安全:適切なトークン検証
const authenticate = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
// トークンがブラックリストにないか確認
if (await isTokenRevoked(token)) {
return res.status(401).json({ error: 'Token revoked' });
}
const decoded = jwt.verify(token, JWT_SECRET, {
issuer: 'api.example.com',
audience: 'app.example.com'
});
if (decoded.type !== 'access') {
return res.status(401).json({ error: 'Invalid token type' });
}
req.user = await User.findById(decoded.userId);
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
};
// 危険:署名検証なし、有効期限なし
const jwt = require('jsonwebtoken');
app.post('/api/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (user && user.password === req.body.password) {
// 危険:平文パスワード、弱い秘密鍵
const token = jwt.sign({ userId: user.id }, 'secret');
res.json({ token });
}
});
// 危険:トークン検証が不十分
app.get('/api/protected', (req, res) => {
const token = req.headers.authorization;
const decoded = jwt.decode(token); // 検証なし!
res.json({ userId: decoded.userId });
});
// 安全:適切な認証実装
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
// 環境変数から強力な秘密鍵を取得
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// レート制限チェック
if (await isRateLimited(req.ip)) {
return res.status(429).json({ error: 'Too many attempts' });
}
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// タイミング攻撃を防ぐため、常に同じ応答時間
await bcrypt.compare('dummy', '$2b$10$dummy.hash');
return res.status(401).json({ error: 'Invalid credentials' });
}
// アクセストークンとリフレッシュトークンを生成
const accessToken = jwt.sign(
{
userId: user.id,
type: 'access',
jti: crypto.randomUUID() // JWT ID for revocation
},
JWT_SECRET,
{
expiresIn: JWT_EXPIRY,
issuer: 'api.example.com',
audience: 'app.example.com'
}
);
const refreshToken = jwt.sign(
{
userId: user.id,
type: 'refresh',
jti: crypto.randomUUID()
},
JWT_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
// リフレッシュトークンをデータベースに保存
await saveRefreshToken(user.id, refreshToken);
res.json({
accessToken,
refreshToken,
expiresIn: 900 // 15 minutes in seconds
});
});
// 安全:適切なトークン検証
const authenticate = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
// トークンがブラックリストにないか確認
if (await isTokenRevoked(token)) {
return res.status(401).json({ error: 'Token revoked' });
}
const decoded = jwt.verify(token, JWT_SECRET, {
issuer: 'api.example.com',
audience: 'app.example.com'
});
if (decoded.type !== 'access') {
return res.status(401).json({ error: 'Invalid token type' });
}
req.user = await User.findById(decoded.userId);
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
};
// TOTP (Time-based One-Time Password) 実装
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// MFAセットアップ
app.post('/api/mfa/setup', authenticate, async (req, res) => {
// 既にMFAが有効な場合は拒否
if (req.user.mfaEnabled) {
return res.status(400).json({ error: 'MFA already enabled' });
}
// シークレットを生成
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`,
issuer: 'MyApp',
length: 32
});
// 一時的にシークレットを保存
await User.updateOne(
{ _id: req.user.id },
{
tempMfaSecret: secret.base32,
tempMfaSecretExpiry: new Date(Date.now() + 10 * 60 * 1000) // 10分
}
);
// QRコードを生成
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({
secret: secret.base32,
qrCode: qrCodeUrl,
manualEntryKey: secret.base32
});
});
// MFA検証と有効化
app.post('/api/mfa/verify', authenticate, async (req, res) => {
const { token } = req.body;
if (!req.user.tempMfaSecret || new Date() > req.user.tempMfaSecretExpiry) {
return res.status(400).json({ error: 'No pending MFA setup' });
}
// トークンを検証
const verified = speakeasy.totp.verify({
secret: req.user.tempMfaSecret,
encoding: 'base32',
token: token,
window: 1 // 前後1つのウィンドウを許容
});
if (!verified) {
return res.status(400).json({ error: 'Invalid token' });
}
// バックアップコードを生成
const backupCodes = Array.from({ length: 10 }, () =>
crypto.randomBytes(4).toString('hex')
);
// MFAを有効化
await User.updateOne(
{ _id: req.user.id },
{
mfaSecret: req.user.tempMfaSecret,
mfaEnabled: true,
mfaBackupCodes: backupCodes.map(code =>
bcrypt.hashSync(code, 10)
),
$unset: { tempMfaSecret: 1, tempMfaSecretExpiry: 1 }
}
);
res.json({
message: 'MFA enabled successfully',
backupCodes: backupCodes
});
});
// ログイン時のMFA検証
app.post('/api/login/mfa', async (req, res) => {
const { sessionToken, mfaToken } = req.body;
// セッショントークンを検証
const session = await LoginSession.findOne({
token: sessionToken,
expiry: { $gt: new Date() },
mfaPending: true
});
if (!session) {
return res.status(401).json({ error: 'Invalid session' });
}
const user = await User.findById(session.userId);
// TOTPトークンを検証
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: mfaToken,
window: 1
});
if (!verified) {
// バックアップコードを試す
const backupCodeValid = await checkBackupCode(user, mfaToken);
if (!backupCodeValid) {
return res.status(401).json({ error: 'Invalid MFA token' });
}
}
// セッションを更新
session.mfaPending = false;
await session.save();
// JWTトークンを発行
const tokens = generateTokens(user);
res.json(tokens);
});
// WebAuthn/Passkeys実装
const {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} = require('@simplewebauthn/server');
// WebAuthn登録開始
app.post('/api/webauthn/register/start', authenticate, async (req, res) => {
const user = await User.findById(req.user.id);
// 既存の認証器を取得
const existingAuthenticators = user.authenticators || [];
const options = await generateRegistrationOptions({
rpName: 'MyApp',
rpID: 'example.com',
userID: user.id,
userName: user.email,
userDisplayName: user.name,
attestationType: 'none',
excludeCredentials: existingAuthenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
transports: auth.transports,
})),
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: true,
residentKey: 'preferred',
userVerification: 'preferred'
},
});
// チャレンジを保存
await User.updateOne(
{ _id: user.id },
{
currentChallenge: options.challenge,
challengeExpiry: new Date(Date.now() + 5 * 60 * 1000)
}
);
res.json(options);
});
// WebAuthn登録完了
app.post('/api/webauthn/register/finish', authenticate, async (req, res) => {
const user = await User.findById(req.user.id);
const { credential } = req.body;
if (!user.currentChallenge || new Date() > user.challengeExpiry) {
return res.status(400).json({ error: 'Challenge expired' });
}
try {
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: user.currentChallenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
requireUserVerification: true,
});
if (!verification.verified) {
return res.status(400).json({ error: 'Verification failed' });
}
const { credentialPublicKey, credentialID, counter } =
verification.registrationInfo;
// 認証器を保存
await User.updateOne(
{ _id: user.id },
{
$push: {
authenticators: {
credentialID: Buffer.from(credentialID),
credentialPublicKey: Buffer.from(credentialPublicKey),
counter,
transports: credential.response.transports,
createdAt: new Date()
}
},
$unset: { currentChallenge: 1, challengeExpiry: 1 }
}
);
res.json({ verified: true });
} catch (error) {
console.error(error);
res.status(400).json({ error: 'Registration failed' });
}
});
// WebAuthn認証
app.post('/api/webauthn/login/start', async (req, res) => {
const { email } = req.body;
const user = await User.findOne({ email });
if (!user || !user.authenticators?.length) {
// ユーザーが存在しないことを隠す
return res.json({
challenge: crypto.randomBytes(32).toString('base64'),
allowCredentials: []
});
}
const options = await generateAuthenticationOptions({
allowCredentials: user.authenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
transports: auth.transports,
})),
userVerification: 'preferred',
});
// チャレンジを保存
await LoginSession.create({
userId: user.id,
challenge: options.challenge,
expiry: new Date(Date.now() + 5 * 60 * 1000)
});
res.json(options);
});
// SMS/Email OTP実装
const twilio = require('twilio');
const nodemailer = require('nodemailer');
const twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
// OTP送信
app.post('/api/otp/send', authenticate, async (req, res) => {
const { method, destination } = req.body;
// レート制限
const key = `otp:${req.user.id}:${method}`;
const attempts = await redis.incr(key);
if (attempts === 1) {
await redis.expire(key, 300); // 5分で3回まで
} else if (attempts > 3) {
return res.status(429).json({
error: 'Too many attempts. Try again later.'
});
}
// 6桁のOTPを生成
const otp = crypto.randomInt(100000, 999999).toString();
// OTPをRedisに保存(5分間有効)
await redis.setex(
`otp:${req.user.id}:${otp}`,
300,
JSON.stringify({ method, destination, attempts: 0 })
);
try {
if (method === 'sms') {
await twilioClient.messages.create({
body: `Your verification code is: ${otp}`,
to: destination,
from: process.env.TWILIO_PHONE_NUMBER
});
} else if (method === 'email') {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
await transporter.sendMail({
from: '"MyApp" <noreply@example.com>',
to: destination,
subject: 'Your verification code',
html: `
<p>Your verification code is:</p>
<h2>${otp}</h2>
<p>This code will expire in 5 minutes.</p>
`
});
}
res.json({
message: 'OTP sent successfully',
expiresIn: 300
});
} catch (error) {
console.error('OTP send error:', error);
res.status(500).json({ error: 'Failed to send OTP' });
}
});
// OTP検証
app.post('/api/otp/verify', authenticate, async (req, res) => {
const { otp } = req.body;
const key = `otp:${req.user.id}:${otp}`;
const data = await redis.get(key);
if (!data) {
return res.status(400).json({ error: 'Invalid or expired OTP' });
}
const otpData = JSON.parse(data);
// 試行回数をチェック
if (otpData.attempts >= 3) {
await redis.del(key);
return res.status(400).json({ error: 'Too many failed attempts' });
}
// OTPを削除(一度だけ使用可能)
await redis.del(key);
// セッションを更新またはトークンを発行
const tokens = generateTokens(req.user);
res.json({
message: 'OTP verified successfully',
...tokens
});
});
特定のプロパティへのアクセス制御が不適切な場合に発生します。
// 危険:すべてのフィールドを更新可能
app.put('/api/users/:id', authenticate, async (req, res) => {
const user = await User.findByIdAndUpdate(
req.params.id,
req.body, // すべてのフィールドを受け入れる
{ new: true }
);
res.json(user);
});
// 危険:機密情報を含むすべてのフィールドを返す
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // パスワードハッシュ、APIキーなども含む
});
// 安全:ホワイトリスト方式でフィールドを制限
const updateableFields = ['name', 'bio', 'avatar'];
const publicFields = ['id', 'name', 'bio', 'avatar', 'createdAt'];
const privateFields = [...publicFields, 'email', 'phone'];
app.put('/api/users/:id', authenticate, async (req, res) => {
// 自分のプロファイルのみ更新可能
if (req.user.id !== req.params.id) {
return res.status(403).json({ error: 'Forbidden' });
}
// 更新可能なフィールドのみ抽出
const updates = {};
updateableFields.forEach(field => {
if (req.body[field] !== undefined) {
updates[field] = req.body[field];
}
});
// 特別な権限が必要なフィールド
if (req.body.role && req.user.isAdmin) {
updates.role = req.body.role;
}
const user = await User.findByIdAndUpdate(
req.params.id,
updates,
{ new: true, runValidators: true }
);
// 返すフィールドも制限
const response = pickFields(user, privateFields);
res.json(response);
});
// 安全:コンテキストに応じたフィールド制御
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
let fields;
if (req.user.id === req.params.id) {
// 自分のプロファイル:プライベートフィールドを含む
fields = privateFields;
} else if (req.user.isAdmin) {
// 管理者:すべてのフィールド(パスワード以外)
fields = [...privateFields, 'lastLogin', 'ipAddress'];
} else {
// その他:パブリックフィールドのみ
fields = publicFields;
}
const response = pickFields(user, fields);
res.json(response);
});
// ヘルパー関数
function pickFields(obj, fields) {
return fields.reduce((result, field) => {
if (obj[field] !== undefined) {
result[field] = obj[field];
}
return result;
}, {});
}
// 危険:すべてのフィールドを更新可能
app.put('/api/users/:id', authenticate, async (req, res) => {
const user = await User.findByIdAndUpdate(
req.params.id,
req.body, // すべてのフィールドを受け入れる
{ new: true }
);
res.json(user);
});
// 危険:機密情報を含むすべてのフィールドを返す
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // パスワードハッシュ、APIキーなども含む
});
// 安全:ホワイトリスト方式でフィールドを制限
const updateableFields = ['name', 'bio', 'avatar'];
const publicFields = ['id', 'name', 'bio', 'avatar', 'createdAt'];
const privateFields = [...publicFields, 'email', 'phone'];
app.put('/api/users/:id', authenticate, async (req, res) => {
// 自分のプロファイルのみ更新可能
if (req.user.id !== req.params.id) {
return res.status(403).json({ error: 'Forbidden' });
}
// 更新可能なフィールドのみ抽出
const updates = {};
updateableFields.forEach(field => {
if (req.body[field] !== undefined) {
updates[field] = req.body[field];
}
});
// 特別な権限が必要なフィールド
if (req.body.role && req.user.isAdmin) {
updates.role = req.body.role;
}
const user = await User.findByIdAndUpdate(
req.params.id,
updates,
{ new: true, runValidators: true }
);
// 返すフィールドも制限
const response = pickFields(user, privateFields);
res.json(response);
});
// 安全:コンテキストに応じたフィールド制御
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
let fields;
if (req.user.id === req.params.id) {
// 自分のプロファイル:プライベートフィールドを含む
fields = privateFields;
} else if (req.user.isAdmin) {
// 管理者:すべてのフィールド(パスワード以外)
fields = [...privateFields, 'lastLogin', 'ipAddress'];
} else {
// その他:パブリックフィールドのみ
fields = publicFields;
}
const response = pickFields(user, fields);
res.json(response);
});
// ヘルパー関数
function pickFields(obj, fields) {
return fields.reduce((result, field) => {
if (obj[field] !== undefined) {
result[field] = obj[field];
}
return result;
}, {});
}
リソース消費の制限がない場合、DoS 攻撃の対象となります。
// 基本的なレート制限(express-rate-limit使用)
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD
});
// グローバルレート制限
const globalLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:global:'
}),
windowMs: 15 * 60 * 1000, // 15分
max: 100, // 最大100リクエスト
message: 'Too many requests from this IP',
standardHeaders: true,
legacyHeaders: false,
});
// API エンドポイント別のレート制限
const apiLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:api:'
}),
windowMs: 1 * 60 * 1000, // 1分
max: 20,
keyGenerator: (req) => {
// 認証済みユーザーはユーザーIDで、未認証はIPで制限
return req.user ? `user:${req.user.id}` : `ip:${req.ip}`;
}
});
// 厳しい制限が必要なエンドポイント
const strictLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:strict:'
}),
windowMs: 15 * 60 * 1000, // 15分
max: 5, // 最大5回
skipSuccessfulRequests: true, // 成功したリクエストはカウントしない
});
// 適用
app.use(globalLimiter);
app.use('/api/', apiLimiter);
app.use('/api/auth/login', strictLimiter);
app.use('/api/auth/register', strictLimiter);
app.use('/api/password-reset', strictLimiter);
// 高度なレート制限(スライディングウィンドウ、コスト基準)
class AdvancedRateLimiter {
constructor(redis) {
this.redis = redis;
}
// コストベースのレート制限
async checkCostBasedLimit(key, cost, options = {}) {
const {
maxCost = 1000,
windowMs = 60000,
refillRate = 100 // 毎秒回復するコスト
} = options;
const now = Date.now();
const windowStart = now - windowMs;
// Lua スクリプトでアトミックに処理
const luaScript = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local cost = tonumber(ARGV[2])
local maxCost = tonumber(ARGV[3])
local windowMs = tonumber(ARGV[4])
local refillRate = tonumber(ARGV[5])
-- 現在のコストを取得
local currentCost = tonumber(redis.call('get', key) or 0)
local lastRefill = tonumber(redis.call('get', key .. ':lastRefill') or now)
-- コストを回復
local timePassed = (now - lastRefill) / 1000
local refilled = math.min(currentCost, timePassed * refillRate)
currentCost = math.max(0, currentCost - refilled)
-- 新しいコストをチェック
if currentCost + cost > maxCost then
return {0, currentCost, maxCost}
end
-- コストを追加
currentCost = currentCost + cost
redis.call('set', key, currentCost)
redis.call('set', key .. ':lastRefill', now)
redis.call('expire', key, windowMs / 1000)
redis.call('expire', key .. ':lastRefill', windowMs / 1000)
return {1, currentCost, maxCost}
`;
const result = await this.redis.eval(
luaScript,
1,
key,
now,
cost,
maxCost,
windowMs,
refillRate
);
return {
allowed: result[0] === 1,
currentCost: result[1],
maxCost: result[2],
remainingCost: Math.max(0, result[2] - result[1])
};
}
// スライディングウィンドウ
async checkSlidingWindow(key, options = {}) {
const {
windowMs = 60000,
max = 60
} = options;
const now = Date.now();
const windowStart = now - windowMs;
const pipe = this.redis.pipeline();
// 古いエントリを削除
pipe.zremrangebyscore(key, '-inf', windowStart);
// 現在のカウントを取得
pipe.zcard(key);
// 新しいエントリを追加(まだ制限内の場合)
pipe.zadd(key, now, `${now}-${Math.random()}`);
// TTLを設定
pipe.expire(key, Math.ceil(windowMs / 1000));
const results = await pipe.exec();
const count = results[1][1];
if (count >= max) {
// 制限を超えた場合、追加したエントリを削除
await this.redis.zrem(key, `${now}-${Math.random()}`);
return {
allowed: false,
count: count,
max: max,
resetAt: windowStart + windowMs
};
}
return {
allowed: true,
count: count + 1,
max: max,
resetAt: windowStart + windowMs
};
}
}
// 使用例
const limiter = new AdvancedRateLimiter(redis);
// APIコストミドルウェア
const costBasedLimiter = (costMap) => {
return async (req, res, next) => {
const endpoint = `${req.method}:${req.route.path}`;
const cost = costMap[endpoint] || 1;
const key = req.user ? `cost:user:${req.user.id}` : `cost:ip:${req.ip}`;
const result = await limiter.checkCostBasedLimit(key, cost, {
maxCost: req.user?.subscription === 'premium' ? 5000 : 1000,
windowMs: 3600000, // 1時間
refillRate: req.user?.subscription === 'premium' ? 200 : 50
});
// ヘッダーに情報を追加
res.set({
'X-RateLimit-Cost': cost,
'X-RateLimit-Remaining': result.remainingCost,
'X-RateLimit-Limit': result.maxCost
});
if (!result.allowed) {
return res.status(429).json({
error: 'Rate limit exceeded',
retryAfter: Math.ceil(cost / (result.maxCost / 3600))
});
}
next();
};
};
// エンドポイントごとのコスト定義
const apiCosts = {
'GET:/api/users': 1,
'GET:/api/users/:id': 1,
'POST:/api/users': 10,
'GET:/api/reports/generate': 100,
'POST:/api/ai/generate': 50
};
app.use('/api', costBasedLimiter(apiCosts));
// 分散環境対応(複数インスタンス、地理的分散)
const Redlock = require('redlock');
class DistributedRateLimiter {
constructor(redisNodes) {
this.clients = redisNodes.map(node => new Redis(node));
this.redlock = new Redlock(this.clients, {
retryCount: 3,
retryDelay: 200
});
}
// グローバル分散レート制限
async checkGlobalLimit(key, options = {}) {
const {
max = 1000,
windowMs = 60000,
shardCount = this.clients.length
} = options;
// 各シャードの制限を計算
const maxPerShard = Math.ceil(max / shardCount);
// ローカルシャードをチェック
const localShard = this.getShardForKey(key);
const localResult = await this.checkLocalLimit(
localShard,
key,
maxPerShard,
windowMs
);
if (!localResult.allowed) {
// ローカルで拒否された場合、他のシャードをチェック
return this.checkOtherShards(key, max, windowMs);
}
return localResult;
}
// シャード間でのレート制限チェック
async checkOtherShards(key, max, windowMs) {
const results = await Promise.all(
this.clients.map(client =>
this.getShardCount(client, key, windowMs)
)
);
const totalCount = results.reduce((sum, count) => sum + count, 0);
return {
allowed: totalCount < max,
count: totalCount,
max: max,
distributed: true
};
}
// 地理的に分散したレート制限
async checkGeoDistributedLimit(key, options = {}) {
const {
regions = ['us-east', 'eu-west', 'ap-south'],
maxPerRegion = 1000,
maxGlobal = 2500,
windowMs = 60000
} = options;
// 現在のリージョンを特定
const currentRegion = this.getCurrentRegion();
// ローカルリージョンのチェック
const localKey = `${key}:${currentRegion}`;
const localResult = await this.checkLocalLimit(
this.getRegionalClient(currentRegion),
localKey,
maxPerRegion,
windowMs
);
if (!localResult.allowed) {
return { ...localResult, region: currentRegion };
}
// グローバル制限のチェック(非同期)
this.checkGlobalLimitAsync(key, maxGlobal, windowMs);
return {
...localResult,
region: currentRegion,
globalCheckPending: true
};
}
// バースト対応のトークンバケット
async checkTokenBucket(key, options = {}) {
const {
capacity = 100,
refillRate = 10, // トークン/秒
tokensRequested = 1
} = options;
const luaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local tokensRequested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- 現在のトークン数と最終更新時刻を取得
local bucket = redis.call('hmget', key, 'tokens', 'lastRefill')
local tokens = tonumber(bucket[1] or capacity)
local lastRefill = tonumber(bucket[2] or now)
-- トークンを補充
local timePassed = math.max(0, now - lastRefill)
local tokensToAdd = math.floor(timePassed * refillRate / 1000)
tokens = math.min(capacity, tokens + tokensToAdd)
-- トークンをチェック
if tokens < tokensRequested then
-- トークン不足
local waitTime = math.ceil((tokensRequested - tokens) * 1000 / refillRate)
return {0, tokens, waitTime}
end
-- トークンを消費
tokens = tokens - tokensRequested
redis.call('hmset', key, 'tokens', tokens, 'lastRefill', now)
redis.call('expire', key, 3600)
return {1, tokens, 0}
`;
const result = await this.clients[0].eval(
luaScript,
1,
key,
capacity,
refillRate,
tokensRequested,
Date.now()
);
return {
allowed: result[0] === 1,
remainingTokens: result[1],
waitTimeMs: result[2],
capacity: capacity
};
}
}
// 使用例
const distributedLimiter = new DistributedRateLimiter([
{ host: 'redis1.example.com', port: 6379 },
{ host: 'redis2.example.com', port: 6379 },
{ host: 'redis3.example.com', port: 6379 }
]);
// ミドルウェア
app.use(async (req, res, next) => {
const key = req.user ? `user:${req.user.id}` : `ip:${req.ip}`;
// バーストトラフィックに対応
const result = await distributedLimiter.checkTokenBucket(key, {
capacity: 100, // バースト時の最大
refillRate: 10, // 通常時は10req/秒
tokensRequested: 1
});
res.set({
'X-RateLimit-Remaining': result.remainingTokens,
'X-RateLimit-Capacity': result.capacity
});
if (!result.allowed) {
return res.status(429).json({
error: 'Rate limit exceeded',
retryAfter: Math.ceil(result.waitTimeMs / 1000)
});
}
next();
});
const helmet = require('helmet');
// 本番環境用の厳格なセキュリティ設定
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.example.com"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
reportUri: "/api/csp-report"
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
frameguard: { action: 'deny' },
noSniff: true,
xssFilter: true,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
permittedCrossDomainPolicies: false
}));
// CORS設定
const cors = require('cors');
app.use(cors({
origin: (origin, callback) => {
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com'
];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
maxAge: 86400 // プリフライトリクエストのキャッシュ
}));
// セキュリティ監査ログ
app.use((req, res, next) => {
const securityEvents = [
'/api/auth/login',
'/api/auth/logout',
'/api/users/*/delete',
'/api/admin/*'
];
if (securityEvents.some(pattern =>
req.path.match(new RegExp(pattern.replace('*', '.*')))
)) {
logger.security({
event: 'security_audit',
method: req.method,
path: req.path,
ip: req.ip,
userAgent: req.headers['user-agent'],
userId: req.user?.id,
timestamp: new Date().toISOString()
});
}
next();
});
対策 | パフォーマンス影響 | 推奨設定 | 代替案 |
---|---|---|---|
JWTトークン検証 | 低(<1ms) | 全リクエストで実施 | セッション管理 |
レート制限 | 低(Redis使用時) | エンドポイント別に設定 | CloudflareのRate Limiting |
入力検証 | 中(複雑な検証時) | スキーマベース検証 | WAF併用 |
暗号化 | 高(大量データ時) | センシティブデータのみ | ハードウェア暗号化 |
監査ログ | 中 | 重要操作のみ | 非同期処理 |
CORS検証 | 低 | 厳格に設定 | API Gateway利用 |
API セキュリティは、単一の対策ではなく、多層防御が重要です。本記事で紹介した対策を組み合わせることで、堅牢な API を構築できます。
定期的なセキュリティ監査とペネトレーションテストも忘れずに実施しましょう。