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」の、有効化ボタンをクリックし、確認ダイアログの有効化ボタンをクリック
動作の説明
上記の設定を行うことで、大まかに以下のような動作になります。
- AWSのIPアドレス範囲が変更されると、作成したLambda関数が動作する
- Lambda関数により、最新のCloudFrontのIPアドレス範囲情報をS3に格納する
- Lambda関数により、S3に格納された前回のIPアドレス範囲情報と新IPアドレス範囲情報を比較し、差分だけセキュリティグループを変更する処理が動作する
スクリプトの詳細な動作については、コード内に細かくコメントをつけているので、読んでみてください。
まとめ
細かく説明すると長くなってしまうので、ここまでにします。結構面倒な設定が必要ですが、セキュリティグループに「0.0.0.0/0」がないのは気持ちがいいです。
ただ、この辺りの指定は将来AWS側で簡単に設定できるようにしてくれそうですけどね。
皆さんも是非、設定してみてください。また、スクリプトに関して、バグや改善点・疑問点等ありましたら、Twitterへお願いします。