UnlockOS Developers
← 記事一覧に戻る
🔐

堅牢な決済システムの構築: サーバーサイド検証とIDOR対策

2026年4月27日2026年5月3日
6
200 commits
深度 8/10
securityvalidationauthorizationpayment-security

堅牢な決済システムの構築: サーバーサイド検証と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 }
  );
}

まとめ

信頼できる決済システムの構築には複数の防御層が必要です:

  1. サーバーサイド検証でクライアントサイドの操作を防ぐ
  2. 多層認証でIDOR攻撃を阻止する
  3. アトミックトランザクションでデータの一貫性を保つ
  4. 入力値検証でインジェクション攻撃を防ぐ
  5. 環境変数検証で設定エラーを早期発見する
  6. 構造化されたエラー処理で情報漏洩を防ぐ

これらのパターンは安全な金融システムの基盤を形成し、攻撃を受けてもシステムが整合性を保ち、調査のための明確な監査証跡を提供することを保証します。

主要な発見

1
セキュリティ

サーバーサイドでの手数料検証

クライアントから提供される財務データは決して信頼せず、操作攻撃を防ぐために常にサーバーサイドで手数料を再計算する

2
認証

多層IDOR攻撃防止

未認可のリソースアクセスを防ぐために、複数の層で多層防御の認証チェックを実装する

3
データ整合性

アトミック決済トランザクション

決済操作中の一貫性を保つために、適切なロールバック処理を備えたデータベーストランザクションを使用する

4
バリデーション

実行時設定検証

決済処理中ではなく起動時に重要な環境変数を検証して、早期に失敗するようにする