2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js基本】なぜAPI処理はサーバーサイドで完結させるべきか?〜注文処理を例に学ぶセキュリティ〜

Last updated at Posted at 2025-08-07

はじめに

Next.jsでWebアプリケーションを開発する際、「どこでロジックを処理するか」は非常に重要な設計判断です。特にユーザーからのデータ送信(POSTリクエスト)を伴う機能、例えば「注文処理」などを実装する場合、クライアント側の処理に頼ると深刻なセキュリティリスクを生む可能性があります。

この記事では、「注文申請」を具体的なシナリオとして、❌ クライアントサイドで処理を行う危険な例と、✅ サーバーサイド(API Routes)で処理を完結させる安全な例をコードと共に比較・解説します。

結論から言うと、基本原則は「クライアントは絶対に信用しない (Never trust the client)」です。


❌ 悪い例:クライアントを信用した場合の危険な実装

⚠️ この実装は絶対に使用しないでください。セキュリティ上の重大な脆弱性を含んでいます。

まず、最もやってはいけない実装例です。この例では、クライアント(ブラウザ)側で合計金額を計算し、さらにユーザーID請求書送付先のメールアドレスまでもAPIに送信してしまいます。

❌ 危険なシナリオ

  1. ユーザーがカートページで「注文する」ボタンを押す
  2. フロントエンドのJavaScriptが、合計金額を計算する
  3. 計算した金額、カート情報、ユーザーIDメールアドレスをAPIに送信する
    • ブラウザの開発者ツールや、curl/Postmanなどのツールを使えば、誰でもAPIリクエストの中身を書き換えて送ることができます
    • いくらUI上でボタンを押すだけの画面でも、通信の中身は“ユーザーの自由”です
  4. APIは送られてきた情報を鵜呑みにして注文を作成し、請求書メールを送る

❌ 危険なフロントエンド (pages/cart.tsx)

// pages/cart.tsx (❌ 悪い例のフロントエンド)
import { useState } from 'react';
import { useSession } from 'next-auth/react';

interface CartItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
}

export default function CartPage() {
  const { data: session, status } = useSession();

  const [cartItems, setCartItems] = useState<CartItem[]>([
    { productId: 'prod_abc', name: 'すごい商品A', price: 1000, quantity: 2 },
    { productId: 'prod_def', name: '便利な商品B', price: 500, quantity: 1 },
  ]);

  const handleOrderSubmit = async () => {
    if (!session || !session.user || !session.user.id) {
      alert('ログインしてください。');
      return;
    }

    const userId = session.user.id;
    let userEmail = '';

    try {
      // ❌ 危険1: フロントエンドから誰でも叩けるAPIで個人情報を取得
      const emailRes = await fetch(`/api/get-user-email?userId=${userId}`);
      if (!emailRes.ok) {
        throw new Error('メールアドレスの取得に失敗しました。');
      }
      const data = await emailRes.json();
      userEmail = data.email;
    } catch (error) {
      console.error(error);
      alert('ユーザー情報の取得中にエラーが発生しました。');
      return;
    }

    // ❌ 危険2: クライアント側で合計金額を計算(改ざん可能)
    const totalAmount = cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);

    // ❌ 危険3: 改ざん可能なデータを全て含めてAPIにPOST
    const response = await fetch('/api/create-order-bad', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        // ↓↓↓ 全て攻撃者によって改ざん可能 ↓↓↓
        userId: userId,                // ❌ なりすまし可能
        email: userEmail,              // ❌ 情報漏洩・迷惑行為可能
        items: cartItems,              // ❌ 価格・商品改ざん可能
        totalAmount: totalAmount,      // ❌ 金額改ざん可能
      }),
    });

    if (response.ok) {
      alert('注文が完了しました!');
    } else {
      alert('注文に失敗しました。');
    }
  };

  if (status === 'loading') {
    return <div>読み込み中...</div>;
  }
  
  if (status === 'unauthenticated') {
    return <div>注文するにはログインしてください</div>
  }

  return (
    <div>
      <h1>ショッピングカート</h1>
      {/* カートの中身を表示 */}
      <button onClick={handleOrderSubmit}>この内容で注文する</button>
    </div>
  );
}

❌ 危険なAPIルート (pages/api/create-order-bad.ts)

// pages/api/create-order-bad.ts (❌ 悪い例のAPI - 使用禁止)
import { NextApiRequest, NextApiResponse } from 'next';

export default async function handle(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).end();
  }

  // ❌ 危険: クライアントから送られてきたデータをそのまま信用する
  const { userId, email, items, totalAmount } = req.body;

  try {
    // ❌ 問題1: 送られてきたuserIdで注文を作成(なりすまし可能)
    console.log(`ユーザー(${userId})の注文を作成します...`);
    // await db.orders.create({ data: { userId, totalAmount, ... } });

    // ❌ 問題2: 送られてきたemailに請求書を送信(情報漏洩/迷惑行為が可能)
    console.log(`請求書を ${email} に送信します...`);
    // await sendInvoiceEmail(email, { items, totalAmount });
    
    // ❌ 問題3: 送られてきたtotalAmountを信用(価格改ざん可能)
    console.log(`合計金額は ¥${totalAmount} です。`);

    res.status(200).json({ message: '注文を受け付けました(危険な処理)' });
  } catch (error) {
    res.status(500).json({ error: 'サーバーエラー' });
  }
}

💥 脆弱性と問題点

この危険な実装には最低でも3つの致命的な脆弱性が存在します:

  1. 🎭 なりすまし攻撃

    • 攻撃者がuserIdを他人のID(例: user_456)に書き換えてリクエストを送信
    • 他人のアカウントに注文を紐付けることができてしまう
  2. 📧 情報漏洩・迷惑行為

    • 攻撃者がemailを無関係な第三者のアドレスに書き換え
    • その人に請求書メールを送付させることができる
    • 個人情報を含むメールであれば情報漏洩、そうでなくても迷惑行為
  3. 💰 価格改ざん攻撃

    • totalAmountitemsの中のprice1円などの極端に低い価格に改ざん
    • 不正な金額で商品を注文できてしまう

✅ 良い例:サーバーサイドで処理を完結させる安全な実装

次に、セキュリティとデータの整合性を確保した安全な実装例です。クライアントは「何を」「いくつ」注文したいかという意思表示のみを送り、本人確認・価格計算・在庫確認などは全てサーバーサイドで行います。

✅ 安全なシナリオ

  1. ユーザーがカートページで「注文する」ボタンを押す
  2. フロントエンドは、セッションに紐づくAPIエンドポイントに対し、「どの商品を」「いくつ」注文したいかだけを送信
  3. APIは、まずリクエストに含まれるセッション情報からユーザーを特定する
  4. サーバーサイドで、DBからユーザのメールアドレス最新の価格と在庫を取得し直し、合計金額を再計算する
  5. サーバーサイドで、セッションから取得したユーザーIDで注文を作成し、セッションから取得したメールアドレスに請求書を送る

✅ 安全なフロントエンド (pages/cart.tsx)

// pages/cart.tsx (✅ 良い例のフロントエンド)
import { useState } from 'react';

interface CartItem {
  productId: string;
  quantity: number;
}

export default function CartPage() {
  const [cartItems, setCartItems] = useState<CartItem[]>([
    { productId: 'prod_abc', quantity: 2 },
    { productId: 'prod_def', quantity: 1 },
  ]);

  const handleOrderSubmit = async () => {
    // ✅ サーバーには「意思」だけを送る。ID, Email, 金額は送らない!
    const response = await fetch('/api/create-order-good', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ items: cartItems }),
    });

    if (response.ok) {
      const result = await response.json();
      alert(`注文が完了しました!\n注文ID: ${result.orderId}\n合計金額: ¥${result.totalAmount.toLocaleString()}`);
    } else {
      const error = await response.json();
      alert(`注文に失敗しました: ${error.message}`);
    }
  };
  
  return (
    <div>
      <h1>ショッピングカート</h1>
      {/* 在庫や価格はサーバーから取得した最新情報を表示(このコードでは省略) */}
      <button onClick={handleOrderSubmit}>この内容で注文する</button>
    </div>
  );
}

✅ 安全なAPIルート (pages/api/create-order-good.ts)

// pages/api/create-order-good.ts (✅ 良い例のAPI)
import { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/react'; // `next-auth`などの認証ライブラリを想定
import { prisma } from '../../lib/db'; // PrismaクライアントなどのDB接続用

export default async function handle(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method Not Allowed' });
  }

  // ✅ 1. セッション情報からユーザーIDを取得
  const session = await getSession({ req });
  if (!session || !session.user || !session.user.id) {
    return res.status(401).json({ message: '認証されていません。' });
  }

  const userId = session.user.id;

  try {
    // ✅ 2. DBからユーザー情報を取得
    const user = await prisma.user.findUnique({
      where: { id: userId },
      select: { id: true, email: true } // 必要なフィールドのみを選択
    });

    if (!user || !user.email) {
      return res.status(404).json({ message: 'ユーザー情報が見つかりません。' });
    }

    const { items } = req.body; // [{ productId, quantity }]
    let serverCalculatedTotal = 0;
    const orderItemsDetails = [];

    // ✅ 3. DBから最新の商品情報を取得し、在庫確認と金額計算を行う
    for (const item of items) {
      const product = await prisma.product.findUnique({
        where: { id: item.productId }
      });

      if (!product) throw new Error(`商品(ID: ${item.productId})が見つかりません。`);
      if (product.stock < item.quantity) throw new Error(`${product.name}の在庫が不足しています。`);

      // ✅ サーバーサイドでDBの価格を正として金額を計算
      serverCalculatedTotal += product.price * item.quantity;
      orderItemsDetails.push({
        productId: product.id,
        price: product.price, // 注文時点の価格を記録
        quantity: item.quantity,
      });
    }

    // ✅ 4. トランザクション内で注文作成と在庫更新を実行し、整合性を保つ
    const order = await prisma.$transaction(async (tx) => {
      for (const item of orderItemsDetails) {
        await tx.product.update({
          where: { id: item.productId },
          data: { stock: { decrement: item.quantity } },
        });
      }

      const newOrder = await tx.order.create({
        data: {
          userId: userId, // ✅ セッションから取得したIDを使用
          totalAmount: serverCalculatedTotal, // ✅ サーバーで計算した金額を使用
          items: {
            create: orderItemsDetails
          },
        },
      });

      return newOrder;
    });

    // ✅ 5. DBから取得したメールアドレスに請求書を送信
    await sendInvoiceEmail(user.email, order);

    // ✅ 6. 成功レスポンス
    res.status(201).json({
      orderId: order.id,
      totalAmount: order.totalAmount
    });

  } catch (error) {
    res.status(400).json({
      message: error.message || '注文処理中にエラーが発生しました。'
    });
  }
}

比較表:悪い例 vs 良い例

項目 ❌ 悪い例(クライアント依存) ✅ 良い例(サーバー完結)
本人確認 クライアント送信のID/Email
🎭 なりすまし可能
🔒 セッション情報
安全
金額計算 クライアント
💰 改ざん可能
🛡️ サーバー
DBの価格が正
在庫確認 できない、または古い情報 📦 サーバー
DBの最新在庫
信頼性 💥 低い
クライアントは信用できない
高い
サーバーが全ての権限を持つ
セキュリティ ⚠️ 脆弱 🛡️ 堅牢

まとめ

金銭が絡む処理や、データの整合性が重要な処理(在庫管理など)は、必ずサーバーサイドで再検証・再計算・実行するアーキテクチャを選択してください。

重要な原則

  1. 「クライアントは絶対に信用しない (Never trust the client)」
  2. 認証情報はセッションから取得する
  3. 価格・在庫は常にサーバーのDBから取得する
  4. 重要な処理は全てサーバーサイドで完結させる

Next.jsのAPI Routesは、このようなバックエンド処理を簡単かつ安全に実装するための強力な機能です。

Happy Coding! 🚀

2
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?