1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[日本語訳] Stripeを利用したECやサービスをより安全に構築・運用するために意識したい4つのポイント

Posted at

この記事は、dev.toに公開されているPaul Asjes氏によって書かれた英語記事を日本語訳し加筆修正しました。

元記事: Building secure ecommerce

スクリーンショット 2024-04-05 11.23.42.png
イラストはChris Tragによる

新しいものを作る際、私は常に以下の3ステップを踏むようにしています。

  1. 最低限の機能を実装し、製品が動作するようにする
  2. 実装内容を評価し、改善点を洗い出す
  3. 改善点に基づきコードをリファクタリングし、テストを追加する

つまり、最初の概念実証(ステップ1)ができた時点で一旦立ち止まり、コードに明らかな欠陥がないか確認するのです。欠陥があれば修正し、テストを追加してから次に進みます。

しかし、正直なところ、ステップ1の終わりで「いつか後で直そう」と思い、手を付けずにいる開発者は多いはずです。これは技術的負債と呼ばれる現象です。

本記事では、技術的負債そのものについては触れません。代わりに、技術的負債を放置した場合に発生しがちな問題、つまりセキュリティ上の脆弱性について説明します。開発者は使用しているツールやフレームワークの脆弱性を見落としがちですが、適切な対策をとれば、最悪の事態から自身やビジネスを守ることができます。当初はセキュリティを無視しても問題ない場合もありますが、ビジネスが成長するにつれ、セキュリティ上の技術的負債を悪用しようとする人々の標的になる可能性があります。

この記事では、Stripeインテグレーションをセキュアにするための効果的な方法をいくつか紹介します。基本的な内容が含まれていますが、万が一に備え、基礎を押さえておく必要があります。

ポイント1: GitHubにAPIキーをコミットしない

言うまでもありませんが、秘密のAPIキーは安全に保管する必要があります。APIキーが流出する事例は驚くほど多く起きています。GitリポジトリにAPIキーをコミットしてしまうことは、開発者にとってよくある落とし穴です。

特に新人開発者は、テスト環境とプロダクション環境でAPIキーを切り替えるための、よくあるアンチパターンに陥りがちです。

// テスト環境
const stripe = new Stripe('sk_test_123');

// プロダクション環境
// const stripe = new Stripe('sk_live_123');

コメントアウトを切り替えれば、使用環境のAPIキーを簡単に変更できます。しかし、これをGitにプッシュしてしまうと、リポジトリにアクセスできる人全員にAPIキーが漏れてしまいます。被害を最小限に抑えるためには、すぐにAPIキーをロールする必要がありますが、キーが悪用される前にロールが完了するか分かりません。キーをロールすることは対症療法に過ぎず、根本的な流出の原因を解決する必要があります。

対策1: 環境変数を利用する

適切な解決策は、環境変数.env ファイルに設定することです。

例えば.envファイルには以下のように記述します。

.env
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxx

そしてアプリケーションの初期化時にdotenvライブラリを使って環境変数をロードします。

javascript
+require('dotenv').config();
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

.env ファイル自体をGitにコミットしないよう、.gitignoreファイルには.envファイルを追記します。

.gitignore
node_modules/
+.env

これで.envファイルがGitにコミットされることを防ぐことができます。

GitHubのSecret scanning機能やsecretlintで、APIキーのコミットを予防・検知する

GitHubでソースコードを管理している場合、GitHubが持つ「Secret scanning」機能を利用することで、「StripeのAPIキーを誤ってコミット・プッシュしてしまったケース」の予防や検知を行うことができるようになります。

$ git push origin main
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 10 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 427 bytes | 427.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: Review all repository rules at http://github.com/hideokamoto-stripe/demo-app/rules?ref=refs%2Fheads%2Fmain
remote: 
remote: - GITHUB PUSH PROTECTION
remote:   ——————————————————————————————————————————————————————
remote:    Resolve the following secrets before pushing again.
remote:   
remote:    (?) Learn how to resolve a blocked push
remote:    https://docs.github.com/code-security/secret-scanning/pushing-a-branch-blocked-by-push-protection
remote:   
remote:   
remote:   —— Stripe API Key ————————————————————————————————————
remote:    locations:
remote:      - commit: 1fcdb3b095xxxxxxxxx5fae9
remote:        path: README.md:4
remote:   
remote:    (?) To push, remove secret from commit(s) or follow this URL to allow the secret.
remote:    https://github.com/hideokamoto-stripe/demo-gh-secret-scanning/security/secret-scanning/unblock-secret/xxxxxxxx
remote: 
To github.com:hideokamoto-stripe/demo-gh-secret-scanning.git
 ! [remote rejected] main -> main (push declined due to repository rule violations)
error: failed to push some refs to 'github.com:hideokamoto-stripe/demo-app.git'

またsecretlintのようなOSSのLintツールを利用して、APIキーの混入を検知することもできます。

$ npx secretlint "**/*"
/Users/hideokamoto/stripe/demo-gh-secret-scanning/index.js
  1:24  error  [PATTERN] found matching Stripe Secret: sk_test_        @secretlint/secretlint-rule-pattern
  3:24  error  [PATTERN] found matching Stripe Webhook Secret: whsec_  @secretlint/secretlint-rule-pattern

✖ 2 problems (2 errors, 0 warnings)

この2つの機能については、以下の記事で設定方法や動きを紹介しています。こちらもぜひご覧ください。

対策2: APIキーにアクセスできる人間を最小限に抑える

.envファイルにAPIキーを設定するだけでは不十分です。APIキーにアクセスできる人数を最小限に抑える必要があります。組織内でAPIキーを知る人が多ければ多いほど、意図的または偶発的なリークの可能性が高くなります。APIキーは「知る必要のある人のみ」が知るべき情報として扱う必要があり、理想を言えばStripeアカウントの管理者のみにアクセス権を与えるべきです。

注意: テスト用の秘密APIキー(例: sk_test_123)も機密情報
テスト用のデータがテスト秘密APIキーを持つ悪意のある人によって台無しにされる可能性があります。テストデータが必ずしもダミーデータというわけではありません。チームの誰かがStripe Connectの統合を構築している場合、本物の住所や電話番号をテストモードで登録しているかもしれません。これらの機密情報は、テストの秘密APIキーを使えば参照できてしまいます。従って、テストモードかプロダクションモードかを問わず、秘密のAPIキーは必ず安全に保管しましょう。

[制限付きのAPIキー]を使って、利用するAPIキーの権限を絞る

万が一APIキーが漏洩した場合に備える方法として、そのAPIキーでアクセスできるリソースを制限することができます。シークレットAPIキーを利用すると、Stripe上のすべてのリソースにアクセスできます。しかし多くの場合、APIキーを利用してアクセスしたいリソースはごく一部です。そこでrk_からはじまる制限付きのAPIキーを発行しましょう。制限付きのAPIキーを利用することで、そのAPIキーでアクセスできるリソースを制限できます。これによって、例えばPayment IntentやCheckout Sessionの作成だけを行うAPIキーが漏洩しても、顧客の情報やサブスクリプション契約の内容、アカウントの情報などにアクセスされることを防ぐことができます。

ポイント2: 金額をクライアントに設定させない

次の架空のコード例を見てみましょう。このコードでは、10ドルの注文を処理するためのPayment Intentを作成するリクエストを、クライアント側から送信しています。

// クライアント側
const totalAmount = 1000;
const { clientSecret } = await fetch('/create-payment-intent', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    amount: totalAmount,
  }),
}).then((res) => res.json());

サーバー側では、受け取った金額を利用してPayment Intentを作成し、決済処理に利用するクライアントシークレットをクライアント側に返却しています。

// サーバー側
app.post('/create-payment-intent', async (res, req) => {
  const pi = await stripe.paymentIntents.create({
    amount: req.body.amount,
    currency: 'usd',
    payment_method_types: ['card'],
  });

  res.send({
    clientSecret: pi.client_secret,
  });
});

これは、クライアントからサーバーにPaymentIntentの作成を要求し、その後クライアント側で確定して支払いを完了させる比較的シンプルな方法に見えます。しかし、大きな欠陥があります。それは、「課金金額がクライアント側で設定されている」ことです。

クライアントはJavaScriptで動作しているため、上記の例では誰でも簡単にスクリプトの実行前にブレークポイントを設定し、支払うべき金額を変更して、スクリプトを再開させることができてしまいます。つまり、悪意のある人がビジネスロジックで想定している金額ではなく、自分で支払う金額を決められてしまうのです。

さらに悪いことに、エンドポイントを繰り返し叩いて、勝手に金額を設定したPaymentIntentを無限に作成できてしまいます。なぜこれが問題なのかは後で説明します。

Chrome の開発者ツールを使えば、リクエストをコピーして何度でも再生できます
Chrome の開発者ツールを使えば、リクエストをコピーして何度でも再生できます

この対策としては、金額をバックエンドへ渡す代わりに、顧客が購入する商品やサービスを表すIDなどの不変の値を渡すようにしましょう。理想を言えば、価格情報をデータベースに保存しておき、バックエンドのビジネスロジックからその情報を参照して総額を計算できるようにしておく必要があります。例えば以下のコードでは、StripeのProductPriceデータを利用して、支払い金額を計算するように実装しています。

  let amount = 0;

  // Take the items from the cart and calculate the
  // total to be used in the payment intent creation.
  for (const product of req.body.cart) {
    const stripePrices = await stripe.prices.list({
      active: true,
      product: product.id,
      expand: ["data.product"],
    });
    
    // Calculate total for the Payment Intent
    amount += stripePrices.data[0].unit_amount * product.quantity;
  }

  const paymentIntent = await stripe.paymentIntents.create({
    amount: amount,
    currency: 'usd',
    payment_method_types: ['card'],
  });

クライアントに金額を設定させるべき正当な理由がある場合もあります。例えば、寄付を募る場合や、価格を自由に設定できる商品がある場合などです。そういった場合は、Stripe CheckoutStripe Payment Linksを検討すると良いでしょう。この機能には、そうした用途向けのユーティリティが組み込まれています。

ポイント3: Webhookの署名を検証する

Webhookは、ビジネスロジックにとって非常に強力なツールです。Stripeでは、Stripeアカウントに関連する重要なことを見逃さないために、誰もがWebhookを使用することを推奨しています。

Webhookの統合を構築する際、Webhookの署名を検証することを忘れずに行う必要があります。署名検証とは、Webhookリクエストが実際にStripeから送信されたものであり、悪意のある第三者からではないことを暗号化によって確認することです。難しそうですが、Stripe クライアントライブラリにこの機能が組み込まれているので心配無用です。

まずは、するべきではないコードの例を見てみましょう。

app.post('/webhook', express.json({type: 'application/json'}), (request, response) => {
  const event = request.body;

  // イベントを処理する
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object;
      console.log(`PaymentIntent for ${paymentIntent.amount} was successful!`);
      // 成功したPayment Intentを処理するメソッドを定義して呼び出す
      // handlePaymentIntentSucceeded(paymentIntent);
      break;
  }

  // イベント受信を確認するため200レスポンスを返す
  response.send();
});

一見すると問題ない見せかけですが、誰でもこの /webhook エンドポイントにアクセスでき、Stripeになりすまして、サーバー上のビジネスロジックをだますことができてしまいます。つまり、無料で物やサービスを手に入れられる可能性があります。

このエンドポイントにアクセスできるのはStripeだけであるべきなのです。認証ロジックを実装し、Stripeのみがアクセスできるようにするためには、署名検証が役立ちます。

Webhookエンドポイントを作成すると、Stripeから Webhook 署名秘密キー(例: whsec_123) が提供されます。この鍵を使って、受信したWebhookリクエストが実際にStripeから送信されたものであり、悪意のある第三者からではないことを暗号化によって確認できます。次に、署名検証を行っているコードの例を示します。

// Webhook 署名秘密キーは環境変数に安全に保存する必要がある
+const endpointSecret = process.env.STRIPE_SIGNING_SECRET;
-app.post('/webhook', express.json({type: 'application/json'}), (request, response) => {
+app.post('/webhook', express.raw({type: 'application/json'}), (request, response) => 
-  const event = request.body;{
+  let event = request.body;
+  // エンドポイント秘密キーが定義されている場合のみ、イベントの検証を行う
+  // Stripe から送信された署名を取得する
+  const signature = request.headers['stripe-signature'];
+  try {
+    event = stripe.webhooks.constructEvent(
+      request.body,
+      signature,
+      endpointSecret
+    );
+  } catch (err) {
+    console.error(`⚠️  Webhook signature verification failed.`, err.message);
+    return response.sendStatus(400);
+  }

  // イベントを処理する
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object;
      console.log(`PaymentIntent for ${paymentIntent.amount} was successful!`);
      // 成功した支払意図を処理するメソッドを定義して呼び出す
      // handlePaymentIntentSucceeded(paymentIntent);
      break;
  }

  // イベント受信を確認するため200レスポンスを返す
  response.send();
});

上記の例ではNodeを使用していますが、すべてのStripeクライアントライブラリに署名検証機能が組み込まれています。この小さな変更によって、以下を実現できました。

  • このエンドポイントにアクセスできるのはStripeのみに制限された
  • 悪意のある攻撃者を阻止した

ポイント4: カードテストに注意

カードテスト(別名「carding」や「card checking」)とは、盗まれたクレジットカード番号が購入に使えるかどうかをテストする不正な活動のことです。この詐欺の手口は次のようになります。

  1. 不正利用者が盗まれたクレジットカード番号を入手する
  2. 不正利用者がStripeの統合をハイジャックし、カードが有効かどうかをテストする
  3. 不正利用者が有効なカードを使用または売却する

カードテストの被害にあうと、ビジネスに悪影響しか及ぼしません。最悪の場合、数千件もの不正な課金を返金しなければならず、返金、異議申し立て、問題への適切な対処を行わなければカード会社から高額な罰金を科される可能性もあります。さらに深刻な場合は、事業の存続すら危うくなるでしょう。

上記の手順2を達成する最も一般的な方法は、防御が不十分なエンドポイントを攻撃することです。次のようなコードを見てみましょう。

app.post('/create-payment-intent', async (res, req) => {
  const pi = await stripe.paymentIntents.create({
    amount: 1000,
    currency: 'usd',
    payment_method_types: ['card'],
  });

  res.send({
    clientSecret: pi.client_secret,
  });
});

このコードは先ほど、金額をクライアントに設定させないことについて説明した際に紹介したものと同じですが、今回は金額をサーバー側で適切に処理しています。しかし、問題があります。理想を言えば、このエンドポイントにアクセスできるのはクライアントのみであるべきですが、実際にはインターネット上の誰からでもアクセス可能になっています。

不正利用者はこのような無防備なエンドポイントが大好物です。なぜなら、Stripeの統合をハイジャックして無制限にPaymentIntentを生成できるからです。さらに悪いことに、彼らが自分で金額を設定できる場合はボーナスが付いてきます(でも、あなたはそうしないはずですよね?)。パブリッシャブルAPIキーは秘密ではありません。つまり、不正利用者はPaymentIntentのクライアントシークレットさえ手に入れれば、あなたの関与なしに支払いを確定できてしまうのです。

不正利用者がこれをどのようにして行うのかについての詳細は今回省きますが、この話題について詳しく知りたい方は、Snykの The Big Fix でこの問題について掘り下げた講演をご覧ください。

このような攻撃を防ぐ最良の方法は、CAPTCHAをサーバー側で確認する方式を実装することです。CAPTCHAとは、ユーザーが人間かどうかを判別するためのテストの一種です。フローは次のようになります。

  1. ユーザーが購入を試みた際にクライアント側でCAPTCHAを表示する
  2. CAPTCHAに合格したら、生成された鍵をバックエンドに送信する
  3. サーバー側でステップ2の鍵を使ってCAPTCHAに合格したことを確認する
  4. PaymentIntentのクライアントシークレットを返して支払いを完了する

上記の流れをGoogleの reCAPTCHA v3 で実装したミニデモの例があります。デモを実行してCAPTCHA(ウォーリーを探すなど)が表示されなかった場合、reCAPTCHA v3は「不可視」で、人間かどうかを裏側で判断しているためです。

この手法を使うことで、サーバー上での支払い作成リクエストは人間のみから開始でき、不正利用者がカードテストのために使用するスクリプトやボットからのリクエストを防ぐことができます。

訳者注: Cloudflare Turnstileを利用した対策

CAPTCHAを利用した認証以外にも、Cloudflareが提供するTurnstileを利用することもできます。

スクリーンショット 2023-09-19 13.31.33.png

実装方法やテスト方法については、こちらの記事をご覧ください。

まとめ

以上がStripeでセキュリティを確保する際に最も簡単に実装できる対策のいくつかでした。完全にセキュアな統合を保証するものではありませんが、適切なセキュリティの習慣を身につけるきっかけとなることでしょう。Stripeインテグレーションのセキュリティについてさらに詳しく知りたい方は、ドキュメントをご覧ください

1
1
0

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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?