綺麗に死ぬITエンジニア

EC2のセキュリティグループにCloudFrontからしかアクセスを許可しない設定を追加する(AWS Lambda, SNS, S3利用 サーバーレス版)

2015-11-04

※ 最新のAWS環境(LambdaのNode.js 4.3対応)に対応した形で記事を書き直しました。最新の記事はこちらからどうぞ

本記事投稿日現在、AWSのセキュリティグループの受信許可設定には、IPアドレスの指定および他のセキュリティグループのID指定しか対応していません。つまり、CloudFront経由でしかアクセスさせたくない場合であっても、そのような設定をすることは、現時点ではできません。

そこで今回、CloudFrontのIPアドレス範囲に変更があったとき、自動でセキュリティグループを設定してくれる仕組みを考えました。

前回前々回の記事では、EC2のサーバー上から定期的に変更確認を行い、変更があったときにセキュリティグループの修正を行うシェルスクリプトをご紹介しましたが、今回はAWSのLambda / SNS / S3を使ってサーバーレスで実装したいと思います。

また、今回の構成ではAmazon SNSのイベント通知を利用するため、AWSのIPアドレス範囲に変更があるときにしか処理が走りません。サーバー上で実装する場合には定期的にAWSにIPアドレス範囲の変更があるかどうかを確認する処理を走らせなければなりませんから、多少設定が面倒ですが、非常に効率的な構成となります。

なお、まずは気軽にサーバー上のシェルスクリプトで実装したい方は、前回の記事を参考にしてください。

本スクリプトのメリット

このスクリプトを利用することにより、WebサーバへのアクセスがCloudFrontからのみに絞られるため、外部からWebサーバ本体(EC2)のIPアドレスを特定することが困難となり、サーバ本体に対するHTTP/S経由以外の攻撃を強力に軽減することができます。

SSH経由で不正アクセスを試みようとも、IPアドレスがわからなければ攻撃などできません。

よって、悪意のある攻撃者に、意図的に狙い撃ちされるリスクが軽減されます。

設定手順

では、設定手順を解説していきます。

なお、前提として、AWSのルートアカウントで操作できることを想定しています。それ以外の権限のないアカウントでは、下記の手順を実行できない可能性がありますので、ご注意ください。

AWSのコンソールにログインしてください。

Lambda用のロールの作成

  • ナビゲーションバーで、自分のアカウント名→[認証情報]をクリック
  • ナビゲーションペインの[ロール]を選択し、[新しいロールの作成]をクリック
  • 任意のロール名を入力し、[次のステップ]をクリック
  • [AWSサービスロール]の一覧から、[AWS Lambda]を選択
  • [AdministratorAccess]のチェックを入れ、[次のステップ]をクリック
  • [ロールの作成]をクリック

Amazon S3 バケットの用意

  • メインメニューから[S3]をクリック
  • [バケットを作成]をクリック
  • 任意のバケット名を入力し、使用するリージョンを選択後、[作成]をクリック

LambdaへJavaScript(Node.js)コードのデプロイ

  • 必要に応じて、ナビゲーションバーでリージョンを変更する
  • メインメニューから[Lambda]をクリック
  • 一覧画面が表示されている場合は[Create a Lambda function]をクリック。それ以外の場合は、[Get Started Now]をクリック
  • [Skip]をクリック
  • [Name]や[Description]に任意の名前を入力する
  • [Lambda function code]に、以下のコードの8~11行目を自身の環境に合わせて修正し、入力する
(function (exports) {

  // ユーザ定義関数群
  var _ = (function () {

    // ユーザ定義のパラメータ。ここを変更し、環境に合わせてください
    var options = {
      groupId: '###設定したいセキュリティグループのID###',
      protocol: 'tcp',
      port: 80,
      s3bucket: '###先ほど作成したS3バケットの名称###'
    };
    // ここまで

    // 利用するライブラリのインクルード
    var AWS = require('aws-sdk');
    var EC2 = new AWS.EC2();
    var S3 = new AWS.S3();
    var https = require('https');

    /**
     \* AWS-SDKのコールバック関数としてログを取得し、指定したコールバック関数を実行する共通関数
     \*
     \* @param {Object} err
     \* @param {Object} data
     \* @param {function} callback - 実行後のコールバック関数
     \*/
    var _callbackRecordResult = function (err, data, callback) {
      if (err) {
        // エラー時はエラーの内容をログに出力
        console.log(err, err.stack);
      } else {
        // 正常時は取得したデータをログに出力
        console.log(data);
      }
      if (callback != null) callback(err, data);
    };

    /**
     \* セキュリティグループを操作する共通関数
     \*
     \* @param {String} method - 動作 'authorizeSecurityGroupIngress' or 'revokeSecurityGroupIngress'
     \* @param {Array} ipRanges - IPアドレス範囲の配列 ex. ['192.168.0.0/24','8.8.8.8/32','8.8.4.4/32']
     \* @param {function} callback - 実行後のコールバック関数
     \*/
    var _securityGroupCommon = function (method, ipRanges, callback) {

      // IPアドレス範囲の配列をオプションとして渡せる形式に変換
      var optionsIpRanges = [];
      ipRanges.forEach(function (ipRange) {
        optionsIpRanges.push({CidrIp: ipRange});
      });

      // セキュリティグループを操作するAWS-SDKのメソッドを実行
      EC2[method]({
        GroupId: options.groupId,
        IpPermissions: [{
          IpProtocol: options.protocol,
          FromPort: options.port,
          ToPort: options.port,
          IpRanges: optionsIpRanges
        }]
      }, function (err, data) {
        _callbackRecordResult(err, data, callback);
      });
    };

    /**
     \* S3を操作する共通関数
     \*
     \* @param {String} method - 動作 'putObject' or 'getObject'
     \* @param {String} key - セット or ゲットするキー
     \* @param {function} callback - 実行後のコールバック関数
     \* @param {String} body - セットする内容(ゲットする場合は不要)
     \*/
    var _s3Common = function (method, key, callback, body) {
      var _options = {
        Bucket: options.s3bucket,
        Key: key
      };
      if (method === 'putObject') _options.Body = body;

      // S3を操作するAWS-SDKのメソッドを実行
      S3[method](_options, function (err, data) {
        _callbackRecordResult(err, (method === 'getObject' && !err) ? data.Body.toString() : data, callback);
      });
    };

    return {

      /**
       \* S3にIP範囲の情報を追加する関数
       \*
       \* @param {String} syncToken - 追加するip-range.jsonのsyncToken
       \* @param {Array} body - 追加するIP範囲の配列
       \* @param {function} callback - 実行後のコールバック関数
       \*/
      addIpRangeToS3: function (syncToken, body, callback) {
        _s3Common('putObject', syncToken, callback, JSON.stringify(body));
      },

      /**
       \* S3上のHEADファイル(現在のip-range.jsの位置を示すファイル)を設定・更新する関数
       \*
       \* @param {String} syncToken - セットする新しいsyncToken
       \* @param {function} callback - 実行後のコールバック関数
       \*/
      setTheHeadFileOnS3: function (syncToken, callback) {
        _s3Common('putObject', 'HEAD', callback, syncToken);
      },

      /**
       \* S3上のHEADファイル(現在のip-range.jsの位置を示すファイル)を取得する関数
       \*
       \* @param {function} callback - 実行後のコールバック関数
       \*/
      getTheHeadFileOnS3: function (callback) {
        _s3Common('getObject', 'HEAD', callback);
      },

      /**
       \* S3のIP範囲の情報を取得する関数
       \*
       \* @param {String} syncToken - 取得するIP範囲のsyncToken
       \* @param {function} callback - 実行後のコールバック関数
       \*/
      getIpRangeFromS3: function (syncToken, callback) {
        _s3Common('getObject', syncToken, function (err, data) {
          callback(err, JSON.parse(data));
        });
      },

      /**
       \* JSONファイルを取得する関数(httpsのみ)
       \*
       \* @param {String} url - 取得するjsonファイルのURL
       \* @param {function} callback - 実行後のコールバック関数
       \*/
      getJSON: function (url, callback) {
        https.get(url, function (res) {
          var body = '';
          res.setEncoding('utf8');
          res.on('data', function (chunk) {
            body += chunk;
          });
          res.on('end', function (res) {
            callback(JSON.parse(body));
          });
        });
      },

      /**
       \* ip-range.jsonファイルをCloudFrontのIP範囲の配列に変換する関数
       \*
       \* @param {Object} json - ip-range.jsonの内容
       \* @return {Array} ipRange - CloudFrontのIP範囲の配列
       \*/
      changeIpRangeJsonToCloudFrontIpRangeArray: function (json) {
        var ipRange = [];
        for (var i = 0; i < json.prefixes.length; i++) {
          if (json.prefixes[i].service === "CLOUDFRONT") {
            ipRange.push(json.prefixes[i].ip_prefix);
          }
        }
        return ipRange;
      },

      /**
       \* 古いIP範囲と新しいIP範囲の配列からセキュリティグループの内容を変更する関数
       \*
       \* @param {Array} oldIpRange - 古いIP範囲の配列 ex. ['192.168.0.0/24','8.8.8.8/32','8.8.4.4/32']
       \* @param {Array} newIpRange - 新しいIP範囲の配列 ex. ['192.168.0.0/24','8.8.8.8/32','8.8.4.4/32']
       \* @param {function} callback - 実行後のコールバック関数(追加完了時と削除完了時の2回実行される)
       \*/
      changeIpRangeOfSecurityGroup: function (oldIpRange, newIpRange, callback) {

        // 差分のみ追加・削除を実行するように、配列の重複を削除
        var i = 0;
        while (i < oldIpRange.length) {
          var j = 0;
          while (j < newIpRange.length) {
            if (oldIpRange[i] == newIpRange[j]) {
              oldIpRange.splice(i, 1);
              newIpRange.splice(j, 1);
              i--;
              break;
            }
            j++;
          }
          i++;
        }

        // セキュリティグループの操作
        if (oldIpRange.length > 0) _securityGroupCommon('revokeSecurityGroupIngress', oldIpRange, callback);
        if (newIpRange.length > 0) _securityGroupCommon('authorizeSecurityGroupIngress', newIpRange, callback);
      }

    };
  })();

  /********************************************************************************************************************
   \* 以下、Lambda実行時のメイン関数                                                                                       *
   \********************************************************************************************************************/
  exports.handler = function (event, context) {

    // 現在設定されているセキュリティグループの情報(syncToken)を取得
    _.getTheHeadFileOnS3(function (err, head) {

      /**
       \* AmazonIpSpaceChangedトピックから通知を受け取ると、event.Records[0].Sns.Messageには、以下の情報が文字列で格納される
       \* {
       \*   "create-time":"yyyy-mm-ddThh:mm:ss+00:00",
       \*   "synctoken":"0123456789",
       \*   "md5":"6a45316e8bc9463c9e926d5d37836d33",
       \*   "url":"https://ip-ranges.amazonaws.com/ip-ranges.json"
       \* }
       \*/
      var e = JSON.parse(event.Records[0].Sns.Message);

      // 処理成功までのコールバック呼び出し回数
      var count = 0;
      var countSuccess = 4;

      /**
       \* 処理成功判定用のコールバック関数
       \* 変数countSuccessで指定した回数だけ呼ばれると、処理完了とみなし正常終了を返す
       \*
       \* @param {Object} err
       \*/
      var successCallback = function (err) {
        if (!err) {
          count++;
          if (count == countSuccess) context.done(null, 'success');
        }
      };

      /**
       \* 共通処理をまとめた関数
       \*
       \* @param {Array} oldIpRange - 古い(削除する)IP範囲の配列 ex. ['192.168.0.0/24','8.8.8.8/32','8.8.4.4/32']
       \*/
      var common = function (oldIpRange) {

        // ip-range.jsonを取得
        _.getJSON(e.url, function (json) {

          // syncTokenをS3のHEADファイルに保存
          _.setTheHeadFileOnS3(json.syncToken, successCallback);

          // 新しく登録するIP範囲の配列を取得
          var newIpRange = _.changeIpRangeJsonToCloudFrontIpRangeArray(json);

          // 新しく登録するIP範囲の情報をS3に保存
          _.addIpRangeToS3(json.syncToken, newIpRange, successCallback);

          // セキュリティグループの情報を変更
          _.changeIpRangeOfSecurityGroup(oldIpRange, newIpRange, successCallback);

        });
      };

      // エラー時(HEADファイルがないときなど)
      if (err) {

        // 実行する処理の数が3つになるため、3回コールバックが呼ばれたら成功に変更
        countSuccess = 3;

        // 削除するIP範囲はなしで、共通関数実行
        common([]);

      } else if (head !== e.synctoken) {

        // 古い(削除する)IP範囲を取得
        _.getIpRangeFromS3(head, function (err, oldIpRange) {

          // エラーでなければ、共通関数実行
          if (!err) common(oldIpRange);

        });
      }
    });
  };
})(exports);
  • [Lambda function handler and role]の[Role]で、先ほど作成したロールを選択する
  • [Next]をクリック
  • [Create function]をクリック

Lambda functionの初回実行

初回の実行を行うことで、S3のファイル体系の初期化を行い、セキュリティグループに現状のCloudFrontのIPアドレス範囲を追加します。

  • 必要に応じて、ナビゲーションバーでリージョンを変更する
  • メインメニューから[Lambda]をクリック
  • 一覧画面から先ほど作成したLambda functionをクリック
  • [Actions]をクリックし、[Configure test event]をクリック
  • 以下の情報を入力し、[Submit]をクリック
{
  "Records": [
    {
      "EventSource": "aws:sns",
      "EventVersion": "1.0",
      "EventSubscriptionArn": "arn:aws:sns:us-east-1:806199016981:AmazonIpSpaceChanged:39d4a2c4-f433-43db-a43b-c3aebcf57447",
      "Sns": {
        "Type": "Notification",
        "MessageId": "2fe2acac-b82e-5d88-ad78-e204ccec9f99",
        "TopicArn": "arn:aws:sns:us-east-1:806199016981:AmazonIpSpaceChanged",
        "Subject": "IP Space Changed",
        "Message": "{\"create-time\":\"2015-10-26T18:22:04+00:00\",\"synctoken\":\"1445882536\",\"md5\":\"73c5268edb2f0da823978f31df2af56f\",\"url\":\"https://ip-ranges.amazonaws.com/ip-ranges.json\"}",
        "Timestamp": "2015-10-26T18:35:46.919Z",
        "SignatureVersion": "1",
        "Signature": "YZtkG0by6/SMPaSF9E1Woto+0GP+aRzL8RRtFJMs2muJ0JmIw9foZn9mw4J5JsBSgzIUXw9n+V6siOd98sWJEeSyMpleGm165udB1tDYABPAMaD0gBfjOh1Y+6Yz4TcauaemRpoh3JrSRCCbsNmv1JvyiipxnmleYuS2+JHia9pLwthwxAxdFXd99OINrN3wYs2cLNKb7nILk2TdbpOB5tbSMvg0+tMWcHmHh5anf4ZYchiUbkUugzkGy8ydVEUClwtTTMvyNQPMtM7G6oGBXhP36tsEh9DYOGIbJB36GzkXenFapYG6rdFQ8OXQKNxlDKiTq5dkF6MSr0MaXPo28A==",
        "SigningCertUrl": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-bb750dd426d95ee9390147a5624348ee.pem",
        "UnsubscribeUrl": "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:806199016981:AmazonIpSpaceChanged:39d4a2c4-f433-43db-a43b-c3aebcf57447",
        "MessageAttributes": {}
      }
    }
  ]
}

これは以前実際にあったSNSのサンプルデータです。実際のデータでコードを実行すれば、初回実行を自動で検出し初期化を実行するはずです。

  • [Test]をクリック

Amazon SNSの設定

  • 必要に応じて、ナビゲーションバーでリージョンを[米国東部 (バージニア北部)]に変更する
  • メインメニューから[SNS]をクリック
  • ナビゲーションペインで[Subscriptions]を選択
  • [Create Subscription]をクリック
  • [Topic ARN]にarn:aws:sns:us-east-1:806199016981:AmazonIpSpaceChangedと入力
  • [Protocol]で[AWS Lambda]を選択
  • [Endpoint]で先ほど作成したLambda functionを選択
  • [Create Subscription]をクリック

動作の説明

上記の設定を行うことで、以下のような動作になります。

  1. AWSのIPアドレス範囲が変更されると、作成したLambda functionが動作する
  2. Lambda functionにより、S3に格納された情報を元に、旧IPアドレス範囲と新IPアドレス範囲の差分だけセキュリティグループを変更する処理が動作する

本体のスクリプトの内容は複雑ですが、簡単に言えばこのような動作をします。

スクリプトの詳細な動作については、コード内に細かくコメントをつけているので、読んでみてください。不明点等があればコメントをお願いします。

まとめ

細かく説明すると長くなってしまうので、ここまでにします。結構面倒な設定が必要ですが、セキュリティグループに「0.0.0.0/0」がないのは気持ちがいいです。

ただ、この辺りの指定は将来AWS側で簡単に設定できるようにしてくれそうですけどね。

皆さんも是非、設定してみてください。また、スクリプトに関して、バグや改善点・疑問点等ありましたら、Twitterへお願いします。

※ 最新のAWS環境(LambdaのNode.js 4.3対応)に対応した形で記事を書き直しました。最新の記事はこちらからどうぞ