はじめに
遠い昔、緊急時などにサーバーへある設定を入れる必要があった際
会社に来る→サーバにアクセスする→設定を入れる
という流れを踏む必要がありました。
(当時は社内ネットワークが無く、このような方法を取らざるを得ませんでした)
この運用だと緊急にも関わらず、暫定対応ですら数時間後の対応となってしまい困っていました。
その解決のために、SlackとAPIを利用して設定を入れる方式を導入しました。
Slackでコマンドを叩く → APIサーバーのAPIが叩かれる → 設定が入る。
しかし、Slackから叩ける = どこからでも叩けるサーバーになってしまうのでセキュリティをいかに担保するかが鍵になっていました。
今回は「どうやってセキュリティを担保したか」を説明していきます。
前提
- 対象サーバーがNode.jsで書かれていること(今回はNode.jsで実装したため、その説明になります)
- Slackのwebhook設定が完了しており、Slackからサーバーを叩けるようになっていること
【参考】Slack での Incoming Webhook の利用
使い方
まずサーバーに設定を入れるコマンド /config-on
をチャンネルに入力し、
その後スペースで区切ったパラメーターで authenticatorの番号
を入力します。
以下のように入力します。
/config-on [authenticatorの6桁番号]
コマンド(/config-on
)の後はリクエスト内では text
というプロパティに入ります。
このコマンドがどのように内部でチェックされていくのかを解説していきます。
セキュリティ担保のための対応一覧
セキュリティを担保するための対応について、以下の観点から紹介します。
- 不正利用対策
- Slack署名の確認
- 二段階認証
- コマンドチャンネル制御 & 利用ユーザーの制御
不正利用対策
そもそもSlackリクエストの方式に乗っ取っていなかったり、対象以外のSlackスペースからのアクセスでないアクセスだった場合は処理の最初で弾きます。
弾き方に工夫をしており、AWSでサーバーが見つからなかった場合のレスポンスを真似してを返すようにしています。
攻撃対象サーバーを探しているbotやツールから隠れる意図があります。
// bodyがない場合&techワークスペースでない場合&入力がない場合、偽装エラーを返す
if(!req.body || req.body.team_id !== 'スペースのID' || !req.body.text) {
let error = {
errHttpStatus:403,
message:"Missing Authentication Token"
}
throw error
}
slackの署名確認
安易な不正アクセスを弾いた次は、サーバーにIP制御をかけて対象Slackからのみのリクエストを許可しようと考えました。
しかしSlackの固定IPは公式で保証されていなかったため、この方法は取れませんでした。
代わりに署名を確認する方法がSlack公式で推奨されていたのでこちらを利用することにしました。
以下を参考に署名確認の機能を実装しました。
https://api.slack.com/lang/ja-jp/securing-your-slack-app
実装方法
- SlackのAPI設定 (https://api.slack.com/apps) よりアプリを選ぶ。
- 『Basic Information』からシークレットを確認する。
- 以下のようにサーバーにSlackの署名確認を実装する。
const crypto = require('crypto');
const slackSeacretKey = "Slackより取得したシークレットキー";
/**
* slackの署名確認
*/
const slackTokenAuthentication = async () => {
// slack側の署名生成方法に合わせるためにURLエンコードする
// reqにはslackのリクエストが入っている
slackInfo.text = encodeURIComponent(req.body.text.replace( / /g, '+'));
slackInfo.response_url = encodeURIComponent(req.body.response_url);
slackInfo.command = encodeURIComponent(req.body.command);
const sigString = JSON.stringify(slackInfo)
.replace( /":"/g, '=')
.replace( /","/g, '&')
.replace( /("|{|})/g, '')
.replace( /%2B/g, '+');
// slack側の署名フォーマットに合わせる
const sigBasestring = 'v0:' + slackTimeStamp + ':' + sigString;
// ハッシュ用関数の作成
const hmac = crypto.createHmac('sha256', slackSeacretKey);
// ハッシュ化
hmac.update(sigBasestring);
// 照合用にダイジェストを作成
const mySignature = 'v0=' + hmac.digest('hex');
// 自分のシークレットとSlackからリクエスト内の署名を照合
return (mySignature == slackSignature);
}
対象Slackアプリからのリクエストということが担保されたのですが、さらにセキュリティを高めるために二段階認証に処理を渡していきます。
二段階認証
slackの署名確認のみだと不安なので、今回は二段階認証を導入しました。
今回はspeakeasyという二段階認証用のライブラリを使用しました。
こちらのQiita記事を参考させていただき、準備&実装しました。
Node.jsとGoogle Authenticatorを使って二段階認証を行う
事前準備
- Google Authenticatorをスマホに入れる。
- 登録用のQRコード生成(以下のようにツールを作成し、QRコードを生成します)
const speakeasy = require('speakeasy');
const QRCodes = require('qrcode');
const secret = "任意のシークレット"
const user = "任意のユーザー名"
const speakSecret = speakeasy.generateSecret({ length: 20,name: 'Google Authenticatorに表示するサービス名', issuer: 'Google Authenticatorに表示するユーザー名' });
const url = speakeasy.otpauthURL({
secret: speakSecret.ascii,
label: encodeURIComponent('Google Authenticatorに表示するサービス名'),
issuer: 'Google Authenticatorに表示するユーザー名'
});
QRCodes.toDataURL(url, (err, qrcode) => {
// コンソールに出力されたURLをブラウザで開く
console.log(qrcode);
});
3.ブラウザにQRコードを表示させ、Google Authenticatorで読み込む
認証部分の実装
const speakeasy = require('speakeasy');
// Slackで入力したパラメータを取得
const inputArrayString = request.text.split(' ')
const verified = speakeasy.totp.verify({
// シークレット
secret: "QRコード作成時に使用した任意のシークレット",
encoding: 'base32',
// 取得したTOKEN
token: inputArrayString[0]
});
return verified; //true or false
ポイントは text
パラメータにはスラッシュコマンドが含まれないことです。
/config-on [authenticatorの6桁番号]
と入力した場合、 request.text
の中身は
[authenticatorの6桁番号]
となります。
splitで配列にしている理由は、authenticatorの番号以外にもパラメータを渡せるようにしているためです。
例: /config-on [authenticatorの6桁番号] [サーバーの設定値] [コメント]
コマンドチャンネル制御 & 利用ユーザーの制御
意図したslackコマンドであることの確認&二段階認証をクリアしたことで安全なリクエストということが確認できました。続いて社内向けのセキュリティを考えたいと思います。
意図しないユーザーからの実行を防ぎたいので、あらかじめサーバー内でコマンドを実行できるユーザーを登録し、利用範囲を絞ります。
またコマンドは個人チャンネルで実行する運用にしています。これはグループチャンネル上でコマンドを実行すると、対象メンバー以外の人にコマンドがバレてしまう & authenticatorの6桁番号がバレてしまうのを防ぐためです。
const authorizedUser = {
"ユーザーのID": "ユーザーのチャンネルID"
}
// ユーザーが自身の個人チャンネルからアクセスしたかを確認
return (req.body.channel_id !== authorizedUser[req.body.user_id])
あとがき
以上のセキュリティを通したのちに、サーバーに設定を入れる処理を実行します。
Slackのリクエスト形式に偽装した悪意のあるアクセスがあったとしても、
- スペースID、ユーザーID、チャンネルIDチェック
- 署名チェック
- 二段階認証
を突破する必要があり、APIの不正利用は困難になります。
今回は書かなかったのですが、他にも
- コマンド形式の確認(文字数や入力順)
という判定を入れています。これは紹介するほどの工夫はしていないので、今回は主要なチェックのみの紹介とさせていただきました。