堅牢な決済システムの構築: サーバーサイド検証とIDOR対策
はじめに
スマートロック管理などのセキュリティが重要なシステムにおいて、決済フローは最も攻撃リスクの高い部分の一つです。UnlockOSにおける最近のセキュリティ強化は、信頼できる決済システムを構築するための重要なパターンを示しています:サーバーサイドでの手数料検証、IDOR攻撃の防止、アトミックなトランザクション処理です。
サーバーサイドでの手数料再計算: クライアントを信頼してはいけない
基本的なセキュリティ原則として、クライアントから提供される財務データは決して信頼してはいけません。UnlockOSでは操作を防ぐためにサーバーサイドでの手数料再計算を実装しています:
export async function reservationModify({
reservationId,
newStartTime,
newEndTime,
facilityId
}: ModifyRequest) {
// Client cannot manipulate fees - server calculates from source of truth
const recomputedFee = await calculateFeeFromPlan({
planId: reservation.planId,
startTime: newStartTime,
endTime: newEndTime,
facilityTimezone: facility.timezone
});
// Validate against business rules
if (isNaN(recomputedFee) || recomputedFee < 0 || !isFinite(recomputedFee)) {
throw new ValidationError('Invalid fee calculation result');
}
// Stripe minimum amount validation
if (recomputedFee > 0 && recomputedFee < STRIPE_MINIMUM_JPY) {
throw new ValidationError('Amount below payment processor minimum');
}
// Create payment intent with server-calculated amount
const paymentIntent = await createPaymentIntent({
amount: recomputedFee,
currency: facility.currency,
metadata: { reservationId, type: 'modification' }
});
return { paymentIntent, calculatedFee: recomputedFee };
}多層認証によるIDOR攻撃の防止
Insecure Direct Object Reference(IDOR)脆弱性は、攻撃者が本来アクセスできないリソースにアクセスすることを可能にします。UnlockOSでは多層防御を実装しています:
export async function authorizeReservationAccess(
userId: string,
reservationId: string,
action: 'read' | 'modify' | 'cancel'
): Promise<AuthorizationResult> {
// Layer 1: Verify reservation exists and user owns it
const reservation = await db
.from('reservations')
.select('*, facility:facilities(*)')
.eq('id', reservationId)
.eq('guest_user_id', userId)
.single();
if (!reservation) {
throw new UnauthorizedError('Reservation not found or access denied');
}
// Layer 2: Verify user has facility access
const hasAccess = await checkFacilityAccess(userId, reservation.facility.id);
if (!hasAccess) {
throw new UnauthorizedError('Facility access denied');
}
// Layer 3: Business rule validation
if (action === 'modify' && isPastReservation(reservation.start_time)) {
throw new BusinessRuleError('Cannot modify past reservations');
}
return { authorized: true, reservation, facility: reservation.facility };
}アトミックトランザクションパターン
決済操作は、一貫性のない状態を防ぐためにアトミック性が必要です。UnlockOSではロールバック機能を備えたデータベーストランザクションを使用しています:
export async function processReservationExtension({
reservationId,
newEndTime,
userId
}: ExtensionRequest) {
return await db.transaction(async (trx) => {
try {
// 1. Lock the reservation row
const reservation = await trx
.from('reservations')
.select('*')
.eq('id', reservationId)
.eq('guest_user_id', userId)
.forUpdate()
.single();
// 2. Check for conflicts
const conflicts = await checkTimeSlotConflicts(
trx,
reservation.resource_id,
reservation.end_time,
newEndTime
);
if (conflicts.length > 0) {
throw new ConflictError('Time slot unavailable');
}
// 3. Calculate additional fee
const additionalFee = await calculateExtensionFee(reservation, newEndTime);
// 4. Process payment if required
let paymentIntent = null;
if (additionalFee > 0) {
paymentIntent = await createPaymentIntent({
amount: additionalFee,
currency: reservation.currency,
metadata: { reservationId, type: 'extension' }
});
}
// 5. Update reservation atomically
const updated = await trx
.from('reservations')
.update({
end_time: newEndTime,
updated_at: new Date().toISOString()
})
.eq('id', reservationId)
.select('*')
.single();
// 6. Log the operation for audit
await trx.from('audit_logs').insert({
entity_type: 'reservation',
entity_id: reservationId,
action: 'extend',
user_id: userId,
changes: { old_end_time: reservation.end_time, new_end_time: newEndTime },
timestamp: new Date().toISOString()
});
return { reservation: updated, paymentIntent };
} catch (error) {
// Transaction automatically rolls back
await logSecurityEvent({
event: 'reservation_extension_failed',
userId,
reservationId,
error: error.message
});
throw error;
}
});
}入力値の検証とサニタイゼーション
堅牢な入力値検証は、インジェクション攻撃とデータ破損を防ぎます:
import { z } from 'zod';
const ReservationModifySchema = z.object({
reservationId: z.string().uuid('Invalid reservation ID format'),
newStartTime: z.string().datetime('Invalid start time format'),
newEndTime: z.string().datetime('Invalid end time format'),
facilityId: z.string().uuid('Invalid facility ID format')
}).refine((data) => {
const start = new Date(data.newStartTime);
const end = new Date(data.newEndTime);
return end > start;
}, {
message: 'End time must be after start time'
});
export async function validateReservationModify(input: unknown) {
try {
return ReservationModifySchema.parse(input);
} catch (error) {
if (error instanceof z.ZodError) {
throw new ValidationError(
'Invalid input: ' + error.errors.map(e => e.message).join(', ')
);
}
throw error;
}
}環境変数の検証
重要な設定エラーは、実行時ではなく起動時にキャッチされるべきです:
function validateEnvironment() {
const requiredVars = {
SUPABASE_URL: process.env.SUPABASE_URL,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY
};
const missing = Object.entries(requiredVars)
.filter(([_, value]) => !value)
.map(([key, _]) => key);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}. ` +
'Application cannot start safely without proper configuration.'
);
}
// Validate URL format
try {
new URL(requiredVars.SUPABASE_URL!);
} catch {
throw new Error('SUPABASE_URL must be a valid URL');
}
}
// Validate at module load time
validateEnvironment();構造化されたエラーレスポンス
一貫したエラー処理は、情報漏洩を防ぐことでセキュリティを向上させます:
export class APIError extends Error {
constructor(
public code: string,
message: string,
public statusCode: number = 500,
public details?: Record<string, unknown>
) {
super(message);
this.name = 'APIError';
}
toJSON() {
return {
error: {
code: this.code,
message: this.message,
...(process.env.NODE_ENV === 'development' && { details: this.details })
}
};
}
}
export function handleAPIError(error: unknown) {
if (error instanceof APIError) {
return Response.json(error.toJSON(), { status: error.statusCode });
}
// Log unexpected errors but don't expose details
console.error('Unexpected error:', error);
return Response.json(
{ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } },
{ status: 500 }
);
}まとめ
信頼できる決済システムの構築には複数の防御層が必要です:
- サーバーサイド検証でクライアントサイドの操作を防ぐ
- 多層認証でIDOR攻撃を阻止する
- アトミックトランザクションでデータの一貫性を保つ
- 入力値検証でインジェクション攻撃を防ぐ
- 環境変数検証で設定エラーを早期発見する
- 構造化されたエラー処理で情報漏洩を防ぐ
これらのパターンは安全な金融システムの基盤を形成し、攻撃を受けてもシステムが整合性を保ち、調査のための明確な監査証跡を提供することを保証します。