Amazon SNS の IP アドレスの範囲を教えてください

Amazon SNS の IP アドレスの範囲を教えてください

Clock Icon2025.05.30

困っていた内容

Amazon SNS から HTTP(S) エンドポイントに対してメッセージを送信していますが、送信元の IP アドレスを Amazon SNS からのみに制限したいので、 Amazon SNS の IP アドレス範囲を教えてください。

どう対応すればいいの?

結論から言うと、 Amazon SNS 固有の IP アドレス範囲はありません。

SNS トピックから送信されるメッセージは、同じリージョンの IP アドレスから送信される保証がないため、仮に IP アドレス範囲でアクセスを制限する場合、 ip-ranges.json から全リージョンの IP アドレス範囲を取得し、これらを許可する必要があります。

とはいえ、リクエストを到達させないという意味合いでは、必要性はあるものとは思いますが、 IP アドレスの更新を独自に追跡するのは運用負荷が高いことや、悪意をもった第三者が AWS から HTTP(S) エンドポイントにメッセージを送信できる可能性を否定できず、 IP アドレスによる制限のみでは十分ではないものと思います。

そこで、代替案として SNS から送信される各メッセージに付与される署名を検証することで、 SNS から送信されるメッセージの真正性を確保する方法が考えられます。

実際にメッセージを検証してみる

まずは EC2 インスタンス上に Node.js 製の Express Web サーバー を基にした HTTP エンドポイントを建て、ここに SNS トピックからメッセージ送信することを考えます。

スクリーンショット 2025-05-30 13.47.06

まずは EC2 インスタンスを用意し、 Node.js と Express をインストールします。 Node.js のインストール方法は こちら の記事に、 Express についても同様に公式ドキュメントに記載の手順でさくっと終わらせてしまいましょう。

次に、 EC2 インスタンス上に SNS メッセージを受け取るためのプログラムを作成します。
ポイントとして、 SNS サブスクリプションの確認を行わなければメッセージの発行が行えないため、確認を自動で行うための処理を加えています。本運用では実行されないコードになるので、確認時にはリクエストボディをそのままログ出力し、含まれる SubscribeURL に手動でアクセスして確認しても良いと思います。

const express = require('express');
const SNSMessageValidator = require('./sns-validator');

const app = express();
const validator = new SNSMessageValidator();

// JSONパースのミドルウェア
app.use(express.json({ type: '*/*' }));

app.get('/', async (req, res) => {
  res.status(200).send('hello');
});

// SNSエンドポイント
app.post('/sns-endpoint', async (req, res) => {
  try {
    const message = req.body;

    console.log('Received SNS message:', message);

    // 署名検証
    const isValid = await validator.validateMessage(message);

    if (!isValid) {
      console.error('Invalid message signature');
      return res.status(400).send('Invalid signature');
    }

    console.log('Message signature validated successfully');

    // メッセージタイプに応じた処理
    switch (message.Type) {
      case 'SubscriptionConfirmation':
        await handleSubscriptionConfirmation(message);
        break;

      case 'Notification':
        await handleNotification(message);
        break;

      case 'UnsubscribeConfirmation':
        await handleUnsubscribeConfirmation(message);
        break;

      default:
        console.log('Unknown message type:', message.Type);
    }

    res.status(200).send('OK');

  } catch (error) {
    console.error('Error processing SNS message:', error);
    res.status(500).send('Internal Server Error');
  }
});

/**
 * サブスクリプション確認の処理
 */
async function handleSubscriptionConfirmation(message) {
  console.log('Handling subscription confirmation');

  // SubscribeURLにアクセスして確認を完了
  if (message.SubscribeURL) {
    try {
      const response = await fetch(message.SubscribeURL);
      console.log('Subscription confirmed:', response.status);
    } catch (error) {
      console.error('Failed to confirm subscription:', error);
    }
  }
}

/**
 * 通知メッセージの処理
 */
async function handleNotification(message) {
  console.log('Handling notification');
  console.log('Subject:', message.Subject);
  console.log('Message:', message.Message);

  // ここでビジネスロジックを実装
  // 例:データベースへの保存、他のサービスへの転送など
}

/**
 * サブスクリプション解除確認の処理
 */
async function handleUnsubscribeConfirmation(message) {
  console.log('Handling unsubscribe confirmation');
  // 必要に応じて処理を実装
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

続いて、メッセージに含まれる署名を検証するプログラムを用意します。メッセージに含まれる証明書をダウンロードし、それを利用して crypto モジュールで署名を検証します。

const crypto = require('crypto');
const https = require('https');

class SNSMessageValidator {
  constructor() {
    this.certCache = new Map();
  }

  /**
   * SNSメッセージの署名を検証
   * @param {Object} message - SNSから受信したメッセージ
   * @returns {Promise<boolean>} - 検証結果
   */
  async validateMessage(message) {
    try {
      // 必要なフィールドの存在確認
      if (!this.hasRequiredFields(message)) {
        console.error('Required fields are missing');
        return false;
      }

      // 証明書の取得
      const cert = await this.getCertificate(message.SigningCertURL);

      // 署名文字列の構築
      const stringToSign = this.buildStringToSign(message);

      // 署名の検証
      return this.verifySignature(stringToSign, message.Signature, cert);

    } catch (error) {
      console.error('Signature validation failed:', error);
      return false;
    }
  }

  /**
   * 必要なフィールドの存在確認
   */
  hasRequiredFields(message) {
    const requiredFields = ['Message', 'MessageId', 'Timestamp', 'TopicArn', 'Type', 'Signature', 'SigningCertURL', 'SignatureVersion'];
    return requiredFields.every(field => message.hasOwnProperty(field));
  }

  /**
   * 証明書の取得(キャッシュ機能付き)
   */
  async getCertificate(certUrl) {
    // URLの検証(セキュリティ対策)
    if (!this.isValidCertUrl(certUrl)) {
      throw new Error('Invalid certificate URL');
    }

    // キャッシュから取得を試行
    if (this.certCache.has(certUrl)) {
      return this.certCache.get(certUrl);
    }

    // 証明書をダウンロード
    const cert = await this.downloadCertificate(certUrl);

    // キャッシュに保存
    this.certCache.set(certUrl, cert);

    return cert;
  }

  /**
   * 証明書URLの妥当性確認
   */
  isValidCertUrl(url) {
    try {
      const parsedUrl = new URL(url);
      return parsedUrl.protocol === 'https:' && 
             parsedUrl.hostname.endsWith('.amazonaws.com');
    } catch {
      return false;
    }
  }

  /**
   * 証明書のダウンロード
   */
  downloadCertificate(certUrl) {
    return new Promise((resolve, reject) => {
      https.get(certUrl, (response) => {
        let data = '';

        response.on('data', (chunk) => {
          data += chunk;
        });

        response.on('end', () => {
          resolve(data);
        });

      }).on('error', (error) => {
        reject(error);
      });
    });
  }

  /**
   * 署名対象文字列の構築
   */
  buildStringToSign(message) {
    const fields = [];

    // メッセージタイプに応じてフィールドを選択
    if (message.Type === 'Notification') {
      const notificationFields = [
        'Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type'
      ];

      notificationFields.forEach(field => {
        if (message[field] !== undefined) {
          fields.push(`${field}\n${message[field]}\n`);
        }
      });

    } else if (message.Type === 'SubscriptionConfirmation' || message.Type === 'UnsubscribeConfirmation') {
      const confirmationFields = [
        'Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type'
      ];

      confirmationFields.forEach(field => {
        if (message[field] !== undefined) {
          fields.push(`${field}\n${message[field]}\n`);
        }
      });
    }

    return fields.join('');
  }

  /**
   * 署名の検証
   */
  verifySignature(stringToSign, signature, certificate) {
    try {
      const verifier = crypto.createVerify('RSA-SHA1');
      verifier.update(stringToSign, 'utf8');

      return verifier.verify(certificate, signature, 'base64');
    } catch (error) {
      console.error('Signature verification error:', error);
      return false;
    }
  }
}

module.exports = SNSMessageValidator;

最後に、 SNS トピックを用意します。 EC2 インスタンスの前段に ALB を設定し、 ALB の DNS 名を HTTP エンドポイントとして設定します。

スクリーンショット 2025-05-30 14.22.02

あとは、作成したエンドポイントに対して「サブスクリプションの確認」を実行することで、プログラム中の自動確認が行われ、 SNS トピックの状態が「保留中の確認」から「確認済み」に変化し、エンドポイントに対してメッセージが送信可能な状態になるかと思います。

スクリーンショット 2025-05-30 14.24.04

スクリーンショット 2025-05-30 14.24.15

あとは、 SNS トピックで適当なメッセージを発行すると、 Web サーバー側で SNS メッセージの受信ができ、署名の検証にも成功していることが確認できるかと思います。

スクリーンショット 2025-05-30 14.29.55

node index.js
Server running on port 3000
Received SNS message: {
  Type: 'Notification',
  MessageId: 'd26bceb7-a085-52b3-a401-************',
  TopicArn: 'arn:aws:sns:ap-northeast-1:111111111111:hoge-topic',
  Subject: 'sample message',
  Message: 'hello, this is sample message.',
  Timestamp: '2025-05-30T05:29:17.090Z',
  SignatureVersion: '1',
  Signature: '************',
  SigningCertURL: 'https://sns.ap-northeast-1.amazonaws.com/SimpleNotificationService-**********.pem',
  UnsubscribeURL: 'https://sns.ap-northeast-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:ap-northeast-1:111111111111:hoge-topic:285477e2-e0d3-4888-ab3a-************'
}
Message signature validated successfully
Handling notification
Subject: sample message
Message: hello, this is sample message.

さいごに

SNS に限定した IP アドレスの範囲がなく制限が難しいことから、回避策として SNS メッセージの署名を検証することで、実質的に SNS 以外からのリクエストを制限する方法についてご紹介いたしました。

上記のプログラム例では簡単のために省略していますが、署名の検証のほか、証明書チェーンの検証、発行元トピックの ARN の検証を行うことがセキュリティ上ベストプラクティスとされているため、本番運用に際しては、これらの点についてご留意いただければと存じます。

SNS メッセージ署名を正しく検証するには、次のベストプラクティスに従います。

  • 不正な傍受攻撃を防ぐために、必ず HTTPS を使用して署名証明書を取得します。
  • 証明書が Amazon SNS によって発行されていることを確認します。
  • 証明書の信頼チェーンが有効であることを確認します。
  • 証明書は SNS 署名付き URL から取得する必要があります。
  • 検証なしでメッセージで提供された証明書を信頼しないでください。
  • スプーフィングTopicArnを防ぐために、予期しないメッセージを拒否します。
  • Amazon SNS AWS SDKs は、組み込みの検証ロジックを提供するため、誤実装のリスクが軽減されます。

参考資料

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.