綺麗に死ぬITエンジニア

EC2へCloudFrontからしかアクセスできないようにする方法

2017-03-23

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

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

なお、本記事は以前書いた記事を、Lambdaの最新環境(Node.js 4.3)に対応できるよう改修・リファクタリングしたものです。

本記事の内容は、AWSの機能をフルに使うものであり、AWS初心者には若干難しい内容も含まれています。気軽にサーバー上のシェルスクリプトで実装したい方は、以前の記事を参考にしてください。

本仕組みのメリット

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

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

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

設定手順

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

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

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

Lambda用のロールの作成

Lambda関数を実行できるロールを、以下の手順で作成します。

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

Amazon S3 バケットの用意

本仕組みでは、IPアドレス範囲の情報をS3に格納して利用します。よって、Lambdaから利用できるS3バケットを以下の手順で作成します。

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

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

以下の手順で、セキュリティグループを操作するLambda関数本体の設定をします。

  • 必要に応じて、ナビゲーションバーでリージョンを変更する
  • メインメニューからLambdaをクリック
  • Lambda関数の一覧画面へ進み、Lambda 関数の作成をクリック
  • 「設計図の選択」画面で、ブランク関数をクリック
  • 「トリガーの設定」画面で、SNSを選択し、SNSトピックにarn:aws:sns:us-east-1:806199016981:AmazonIpSpaceChangedと入力し、次へをクリック
  • 名前説明に任意の名前を入力し、ランタイムでNode.js 4.3を選択する
  • 「Lambda 関数のコード」欄で、「コード エントリ タイプ」コードをインラインで編集を選択し、以下のコードを入力する
'use strict';

const AWS = require('aws-sdk');
const https = require('https');

const CONFIG = {
    groupId: process.env.SECURITY_GROUP_ID || '',
    protocol: process.env.PROTOCOL || 'tcp',
    port: process.env.PORT || 80,
    s3bucket: process.env.S3BUCKET || ''
};

const EC2 = new AWS.EC2();
const S3 = new AWS.S3();

/**
 * Promiseのログを残す共通関数
 *
 * @param {Promise} promise
 */
function log(promise) {
    return promise.then(data => {
        // 正常時は取得したデータをログに出力
        console.log(data);

        return data;
    }).catch(error => {
        // エラー時はエラーの内容をログに出力
        console.log(error, error.stack);

        return error;
    });
}

/**
 * セキュリティグループを操作する共通関数
 *
 * @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']
 * @return {Promise}
 */
function securityGroup(method, ipRanges) {

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

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

/**
 * S3を操作する共通関数
 *
 * @param {String} method - 動作 'putObject' or 'getObject'
 * @param {String} key - セット or ゲットするキー
 * @param {String} [body] - セットする内容(ゲットする場合は不要)
 * @return {Promise}
 */
function s3(method, key, body) {
    let options = {
        Bucket: CONFIG.s3bucket,
        Key: key
    };
    if (method === 'putObject') options.Body = body;

    // S3を操作するAWS-SDKのメソッドを実行
    return log(S3[method](options).promise().then(data => {
        return (method === 'getObject') ? data.Body.toString() : data
    }));
}

/**
 * S3にIP範囲の情報を追加する関数
 *
 * @param {String} syncToken - 追加するip-range.jsonのsyncToken
 * @param {Array} body - 追加するIP範囲の配列
 * @return {Promise}
 */
function addIpRangeToS3(syncToken, body) {
    return s3('putObject', syncToken, JSON.stringify(body));
}

/**
 * S3上のHEADファイル(現在のip-range.jsの位置を示すファイル)を設定・更新する関数
 *
 * @param {String} syncToken - セットする新しいsyncToken
 * @return {Promise}
 */
function setTheHeadFileOnS3(syncToken) {
    return s3('putObject', 'HEAD', syncToken);
}

/**
 * S3上のHEADファイル(現在のip-range.jsの位置を示すファイル)を取得する関数
 *
 * @return {Promise}
 */
function getTheHeadFileOnS3() {
    return s3('getObject', 'HEAD');
}

/**
 * S3のIP範囲の情報を取得する関数
 *
 * @param {String} syncToken - 取得するIP範囲のsyncToken
 * @return {Promise}
 */
function getIpRangeFromS3(syncToken) {
    return s3('getObject', syncToken).then(data => {
        return JSON.parse(data);
    });
}

/**
 * JSONファイルを取得する関数(httpsのみ)
 *
 * @param {String} url - 取得するjsonファイルのURL
 * @return {Promise}
 */
function getJSON(url) {
    return new Promise((resolve, reject) => {
        https.get(url, res => {
            let body = '';
            res.setEncoding('utf8');
            res.on('data', chunk => {
                body += chunk;
            });
            res.on('end', () => {
                resolve(JSON.parse(body));
            });
        }).on('error', e => {
            reject(e);
        });
    });
}

/**
 * ip-range.jsonファイルをCloudFrontのIP範囲の配列に変換する関数
 *
 * @param {Object} json - ip-range.jsonの内容
 * @return {Array} ipRange - CloudFrontのIP範囲の配列
 */
function changeIpRangeJsonToCloudFrontIpRangeArray(json) {
    let ipRange = [];
    for (let 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']
 * @return {Promise}
 */
function changeIpRangeOfSecurityGroup(oldIpRange, newIpRange) {

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

    // セキュリティグループの操作
    let promises = [];
    if (oldIpRange.length > 0) promises.push(securityGroup('revokeSecurityGroupIngress', oldIpRange));
    if (newIpRange.length > 0) promises.push(securityGroup('authorizeSecurityGroupIngress', newIpRange));
    return Promise.all(promises);
}


/********************************************************************************************************************
 * 以下、Lambda実行時のメイン関数                                                                                       *
 ********************************************************************************************************************/
exports.handler = function (event, context, callback) {
    /**
     * 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"
     * }
     */
    const message = JSON.parse(event.Records[0].Sns.Message);

    if (typeof message != 'object' || message == null) return;

    /**
     * 共通処理をまとめた関数
     *
     * @param {Array} oldIpRange - 古い(削除する)IP範囲の配列 ex. ['192.168.0.0/24','8.8.8.8/32','8.8.4.4/32']
     * @return {Promise}
     */
    function process(oldIpRange) {
        // ip-range.jsonを取得
        return getJSON(message.url).then(json => {
            // 新しく登録するIP範囲の配列を取得
            let newIpRange = changeIpRangeJsonToCloudFrontIpRangeArray(json);

            return Promise.all([
                // syncTokenをS3のHEADファイルに保存
                setTheHeadFileOnS3(json.syncToken),

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

                // セキュリティグループの情報を変更
                changeIpRangeOfSecurityGroup(oldIpRange, newIpRange)
            ]).then(() => {
                callback(null, 'success')
            }).catch(error => {
                callback(Error, error);
            });
        });
    }

    // 現在設定されているセキュリティグループの情報(syncToken)を取得
    getTheHeadFileOnS3().then(head => {
        if (head !== message.synctoken) {
            // 古い(削除する)IP範囲を取得
            getIpRangeFromS3(head).then(oldIpRange => {
                process(oldIpRange);
            });
        }
    }).catch(() => {
        // エラー時(HEADファイルがないときなど)

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

上記のコードをダウンロードしたい方は、こちらからどうぞ。

  • 次の4つの環境変数を設定する。キーSECURITY_GROUP_ID、対象のEC2のセキュリティグループ。キーPROTOCOL、EC2へのアクセスを許可するプロトコル。キーPORT、EC2へのアクセスを許可するポート番号。キーS3BUCKET、先ほど作成したS3バケット
  • 「Lambda 関数ハンドラおよびロール」の「ロール」で、先ほど作成したロールを選択する
  • 次へをクリック
  • 関数の作成をクリック

Lambda functionの初回実行

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

  • 必要に応じて、ナビゲーションバーでリージョンを変更する
  • メインメニューからLambdaをクリック
  • 一覧画面から先ほど作成したLambda関数をクリック
  • アクションをクリックし、テストイベントの設定をクリック
  • 以下の情報を入力し、保存をクリック
{
  "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のサンプルデータです。実際のデータでコードを実行すれば、初回実行を自動で検出し初期化を実行するはずです。

  • テストをクリック
  • タイムアウトにより実行が失敗する場合、「基本設定」からタイムアウト時間を延ばし、再度テストを実行

Amazon SNSの設定(トリガーの有効化)

AWSのIPアドレス範囲の変更を検知し、先ほど作成したLambda関数が実行されるようにします。

  • 必要に応じて、ナビゲーションバーでリージョンを変更する
  • メインメニューからLambdaをクリック
  • 一覧画面から先ほど作成したLambda関数をクリック
  • トリガータブをクリック
  • 「SNS: AmazonIpSpaceChanged」の、有効化ボタンをクリックし、確認ダイアログの有効化ボタンをクリック

動作の説明

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

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

スクリプトの詳細な動作については、コード内に細かくコメントをつけているので、読んでみてください。

まとめ

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

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

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