軽量日記 by adokoy

Mojo::UserAgentを使ってアクティブチェック(外部監視)

表題の通り、cron等で適当にアクティブチェックのプログラムを書き捨てるならMojo::UserAgentが便利ですよというお話。

MojoliciousはPerl標準モジュール以外に依存していないので、サクッとインストールして実行環境を用意することができる(まあディストロのパッケージマネージャでインストールすればたいていのものはストレスなくインストールできますが)

サンプルコード

以下コードは監視対象HTTPサーバへアクセスした後、ステータスコードが200,301以外を返された場合にSlackへ通知する例。

use strict;
use warnings;
use utf8;
use Encode;
use Mojo::UserAgent;

### Slackで通知する場合のapi url
my $slack = 'https://your.slack.channel.example.com/services/A/B/C';
### 監視対象の設定
my $external = [
   {name => 'my site 1', url => 'https://your.site1.example.com/'},
   {name => 'my site 2', url => 'https://your.site2.example.com/'},
];

### 実行
my $ext_ua = Mojo::UserAgent->new;
for(0 .. $#$external){
    my $instance = $external->[$_];
    my $con_error = '';
    my $code = 1000;
    eval{
        $code = $ext_ua->get($instance->{url})->result->code;
    };
    if($@){
        $con_error = $@;
    }
    if($code != 200 and $code != 301){
         my $message = $instance->{name} . ' : ' . $code . ' : ' . "$con_error\n";
         my $tmp_res = $ext_ua->post(
             $slack
             =>
             { 'Content-type' => 'application/json' }
             =>  json => { text => $message }
             )->result;
    }
}

障害の発生と継続

例えばこのコードを10分間隔で実行した場合、監視対象の障害が継続していれば10分間隔でSlack通知が来てしまう。

「新たに発生した時だけ通知」という動作にしたい場合は、例えば、SQLiteをDBにして障害の発生したURLと日時(エポックタイム)を記録するようにして、最後に検知してからの経過秒数 + α > 監視間隔 を満たした場合だけSlack通知するように書けば良い。

SQLiteの簡単なCRUD操作を提供してくれるMojo::SQLiteというラッパーモジュールがあるので使うとよい。

Mojo::SQLite : https://metacpan.org/pod/Mojo::SQLite

が、こちらはMojoliciousと違って依存モジュール満載なので、それが嫌であればストイックにファイル操作でurlとエポック時間を保存するだけの適当なcsvやjsonをストレージにすればよい(と思います)

ウェブサイトのセキュリティ:入力バリデーション、正規表現

安全なウェブサイトを作るためにネット上の情報を漁っていると、「入力バリデーション」と「パスワード」についてクリティカルなものがあったりする。

今回はこのあたりについて自分の考えをまとめておく。

入力バリデーション

入力のバリデーションはSQLインジェクションの根本的な対策にはならない。SQLインジェクションへの根本的な対策は、プリペアードステートメントを利用すること。

クライアントサイドの入力バリデーションは、ウェブサイトの利用者へ「入力値の条件が満たされていない理由」を親切丁寧に伝えるために過ぎない。攻撃者はその気になればHTTPリクエストのすべての内容を生成することができるということを理解しておかなければならない。

最強のバリデーション

selectタグのoption項目のように、クライアントからの入力値として取りうる値が限定的であれば、

  • 入力値を文字列へ強制的に変換した後に、
  • 予め適切に設定した最大文字列長以下の長さであることを確認し、
  • 「if,caseで型を含めて等しいかを判定して分岐」するか、
  • または、「あらかじめ用意した連想配列(辞書)のキーとして存在するかをチェック」する

という方法が最強と思われる。

現実的な方法

お使いのフレームワークが用意しているバリデーション機構を利用してください。

その正規表現、大丈夫?

一つの正規表現で無理やり入力値のバリデーションを実現することは避けた方が良い。

そもそも本来の正規表現・正規言語は有限オートマトンでしかないので、可読性、保守性、ReDoS対策の観点から、できるだけシンプルな造りになるように処理をステップごとに分割し、一つ一つのコードを単純化することを検討しよう。

メールアドレスのバリデーションを自前の一行正規表現で済ませてしまうのはやめよう。

ちょっとまって、その正規表現、本当に本当に大丈夫?

入力文字列の長さを調べたいのならば、例えばPHPであればstrlenで判定できる。

半角英数字以外の文字が含まれていないかチェックしたいのであれば preg_match("/[^0-9a-zA-Z]/",$input) でチェックしよう。

新しく設定しようとしているパスワードが条件を満たしているか判定したいだって? 以下の処理をシーケンシャルに実行して、条件に反していたらその時点でリジェクトすればオッケーだと思うよ。

  • 「受け入れ可能なcharacter以外が入っていないかを判定」
  • 「文字列の長さは条件の範囲内かを判定」
  • 「必要な文字種類がすべて入っているかを分割して判定」
  • 「文字の種類数が条件を満たしているかを判定("AAAAAaaaaa11111"などの弱いPWへの対策)」
  • 「クラックされやすいパスワードリストに載っていないかを判定」

しつこくてごめんね。その正規表現、本当にほんと~~に大丈夫?

正規言語の限界、イプシロン遷移、バックトラック、DFAとNFAについて考えたことがない、「そんな単語聞いたことも無い」という状態であれば、頑張って複雑な一行の正規表現で済ませてしまうのは絶対にやめよう。

「0回以上繰り返し」の量指定子は地雷です。*{0,N}が該当しますね。

例えばこんな短い正規表現と入力でReDoSが成立します: https://twitter.com/adokoy0001/status/824580874704293889

バックトラックが何なのか分かっていないのであれば、「極力正規表現を使わない方法」を模索しましょう。

JSONのバリデーション、カッコの開き・閉じの対応チェック、回文(逆から読んでも同じ文章)の判定、任意の1以上の整数nに対する a^n b^n の判定、などなど、有限オートマトンであるところの正規表現ではスタックがないので不可能です。

そういうのはプッシュダウンオートマトンであるところの文脈自由言語とかならできます。

正規表現を積極的に使うべきシーンは、文字列の置換やパターンマッチした部分を抽出する処理などでしょう。

それでも正規表現を使いたい場合

どうしても入力バリデーションを正規表現一発でやりたいのであれば、以下のURLにある正規表現のやばい挙動を完全に理解した上でやりましょう。

やばい挙動: https://regex101.com/r/enRb1V/1/debugger

やばい挙動その2: https://regex101.com/r/GM8BLm/2/debugger

最後の量指定子{1,3}を、{1,50}にしてみよう。

やばい挙動その3: https://regex101.com/r/GM8BLm/3/debugger

おわかりいただけただろうか。さらに50から70とかに増やすとタイムアウトになったりする。

ここで重要なのは以下である。

  • 数字を増やすと指数関数的にステップ数が増える
  • 短い入力でもバックトラック地獄は起こりうる
  • 短くシンプルな正規表現でも危ないものは危ない
  • 量指定子*を使わなくても、イプシロン遷移があれば危ない

この投稿が何かのお役に立てれば幸いである。

ウェブアプリケーションを攻撃者から守るためのロードマップ

ウェブアプリのセキュリティ対策について無責任なことをネットで書くことはご法度であり、また、セキュリティ対策を講じる担当者もネット上の情報を安易に受け止めてはいけない。このドキュメントも例外ではない。

JPAが公開している安全なウェブサイトの作り方、脆弱性情報データベースの情報をこまめにチェックすること。

ネットワーク構成とファイアウォール

アプリケーションとストレージは分離し、HTTPを受け付けるサーバーとそのportのみをpublicに晒す。ロードバランサを利用するのであれば、LBの80,443番ポートのみpublicに晒し、APPとDBはローカルネットワーク内部のみのトラフィックを受け付ける。

各サーバにアクセスするために、マネジメント用のサーバMGMTを設けると良い。その場合の構成は、LB(80,443ポートを晒す),MGMT(カスタマイズされたsshポートを晒す),APP(ローカルのみ),DB(ローカルのみ)

中間者攻撃

クラウドサーバへの初回SSHアクセス時は、予めクラウド基盤で提供されている該当サーバのフィンガープリントを照合する。

たとえ運用チームだけが用いるウェブコンソールであっても、クラウド上など社内ネットワークの外に置かれている以上は必ず中間者攻撃を想定した構成にしなければならない。対策として最も優れている方法は「とにかくLet's Encryptを入れて中間者攻撃を確実に防ぐ」こと。

サーバー本体のアップデート

HTTPサーバーおよびリバースプロキシのセキュリティパッチは必ず適用する。

無停止で手軽にメンテナンスできるような構成が望ましいため、グローバルIPをサーバにほぼゼロダウンタイムでアタッチできるサービスを利用すると良い。

認証

sshログインはpublic key認証とパスワード認証を併用することが望ましい。

アプリへのログインに用いられるパスワードは必ずsaltを付けてハッシュ化する。saltは共通のものだけを使うのではなく、レコード毎に生成しなければならない。

システム運用チームは、特権的なウェブコンソールでアカウントを共有してはならない。運用チームのメンバーが退職した際にはそのアカウントをログイン不能な状態にするか、可能であれば削除すること。

外部システムとの連携に用いられる認証情報は、アプリへのログインに用いる情報とは完全に分離し、APIアクセスキーやToken等として簡単に失効させることができるようにすること。

PaaS等を利用する際に用いられるクレデンシャルを環境変数に設定する場合、その環境変数がプリントされるページが存在してはならない(例:AWS S3の認証情報をHTTPサーバの環境変数にセットした状態で、phpinfo()が呼ばれるページをプロダクションに放置してしまうなど)

ソースコード中に外部システムのクレデンシャルを直接書き込まないこと。

ウェブアプリ固有の問題

認証情報をhiddenパラメタに含めてはいけない。

適切なHTMLエスケープ機構を利用せずにユーザーからの入力値やDBから読みだした値を出力することはXSSに繋がる。

副作用のある操作は全てCSRF対策が必要。

顧客自身がページをWYSIWYGエディタ等でカスタマイズできる機能がある場合、それ自体がXSS脆弱性となっており、さらにページ編集機能にCSRF対策が施されていない場合はフィッシングサイトへの誘導に利用される危険性がある。

決済機能

クレジットカード番号などは間違ってもサーバに保存してはならない。

デバッグ目的でカード番号をログに保存してはいけない。

決済時のみ決済サービスへjumpする機能を利用することができるのであれば、利用する。

何らかの攻撃が成功し決済サービスへのjump先がフィッシングサイトへ書き換えられてしまう事態を想定し、システムの外から定期的に(数分間隔)決済ページへのリダイレクト先を監視するサーバを立てること(具体的には、決済ページに遷移するまでのアクセスを順次実行するUserAgentを作ってcronで定期的に実行して監視。)

SQLインジェクション

何らかの値をリテラル・変数問わず入力する場合、必ずプリペアードステートメントを利用する。プリペアードステートメントのエミュレーションはやはりエミュレーションに過ぎないので安全ではない。

プリペアードステートメントを利用しなくても安全なケースは「select count(*) from table_a」等、「かならずこのSQLにしかならない」ということが確定している定型文などに限ると考える。

原則として、「本物の(エミュレーションではない)プリペアードステートメントを利用すること以外にSQLインジェクションの有効な対策は存在しない」と考えるべきである。

二段階(二要素)認証とアクティビティ通知

アプリケーションの利用者が必ずしも高いリテラシーを持っているとは限らない。パスワードは辞書攻撃等で突破される前提で考えておき、二段階(二要素)認証を必ず導入しておくこと。

異常な回数のログイン試行やログインの成功などを、事前に登録されたアカウント本人のメールアドレス等にそのアクティビティを送る。

パスワードリセット時に、事前に登録されたもの以外のメールアドレスに復旧URLを送り付けてはいけない(例:7Pay)

FuelPHPのクエリビルダチートシート

FuelPHPのquery builderのselect系についてのみ、実用的な例を示しておく。 insert,update,delete系はORMを使ったほうが効果的な場合もあるだろうが、FuelPHPのModel自動生成機能はリレーションをまともに読んでくれない(リレーションを手作業で張る必要あり・・・)ので、DBの仕様を頻繁に変えるような開発初期段階ではかなり大変で、Modelの更新も慎重にやらなきゃなので、アンチパターンに入りがちかもしれない。 PerlのDBIx::Classとスキーマローダーに慣れている身としては結構厳しい。

(ということで、私としてはFuelPHP標準搭載のQuery Builderがコスパ的にかなり良いと思っています。)

DB::query("LOCK TABLES table_01 WRITE, table_02 WRITE, table_03 WRITE, table_log WRITE, table_transaction WRITE");
DB::start_transaction();
$result = DB::select_array([
    ['table_01.field_01','field_alias_name01'],
    ['table_02.field_02','field_alias_name02'],
    ['table_03.field_03','field_alias_name03'],
  ])
  ->from('table_01')
  ->join('table_02','inner')
  ->on('table_01.primary_key_01','=','table_02.foreign_key_02')
  ->join('table_03','left outer')
  ->on('table_01.primary_key_01','=','table_03.foreign_key_03')
  ->where('table_01.field_04','=',$condition01)
  ->and_where_open()
  ->where('table_02.field_05','=',$condition02)
  ->or_where('table_02.field_06','=',$condition03)
  ->and_where_close()
  ->and_where('table_03.field_07','in',[$item01,$item02,$item03])
  ->order_by('field_alias_name01','desc')
  ->order_by('field_alias_name02','asc')
  ->limit($record_limitation)
  ->offset($offset_value)
  ->execute()
  ->as_array();
if(count($result) === 0){
  DB::insert('table_log')
    ->set(['field_01' => 'Empty', 'field_02' => $user_name])
    ->execute();
}else{
  foreach($result as $result_item){
    DB::insert('table_transaction')
      ->set(['field_01' => $result_item['field_alias_name02']])
      ->execute();
  }
}
DB::commit_transaction();
DB::query("UNLOCK TABLES");

この例は「かなり強いLOCKを割と長い時間維持する」書き方なので、マネをするとしたら非機能要件に合っているかどうかを吟味してください。

PHPのarrayから再帰的にkeyとvalueを全て取り出す

PHP歴がそろそろ半年になろうとしているので、私的なエチュードであるところの「ある言語で複雑なデータ構造を再帰的に全てスキャンするプログラム」を書いてみた。

動作は表題の通りです。

<?php
# 複雑なデータ構造
$external = ['abc','abcd','abcde','xyz'];
$input = [
       'A1' => [
        'AA1' => [
              'AAA1' => 'aaaa1',
              'AAA2' => ['aaaa2','aaaa2a','aaaa2aa','aaaa2aaa']
              ],
        ],
       'B1' => [
        'BB1' => 'bb1',
        'BB2' => [
              'BBB1' => [
                     'BBBB1' => 'bbbb1',
                     'BBBB2' => 'bbbb2',
                     ],
              ],
        'BB3' => 'bb3'
        ],
       'C1' => &$external
       ];

# 再帰的key_value抽出関数
function extract_key_value_rec($var,&$keys,&$values){
  if(!is_scalar($var)){
foreach($var as $key => $value){
  if(array_values($var) !== $var){
    $keys[$key] = true;
  }
  extract_key_value_rec($value,$keys,$values);
}
  }else{
$values[$var] = true;
  }
}

# 実行
extract_key_value_rec($input,$keys_1,$values_1);
print_r($keys_1);
print_r($values_1);
?>

is_scalarでスカラーかどうかの型判定をして再帰呼び出しを続けるかどうかを分岐、array_values($var) !== $var の部分で連想配列か配列かを判定している。

FuelPHPのDeployerによるデプロイ

以前お話したPHPのデプロイについて、Deployerというツールを使ってDSLを書いてみたところいい感じだった。 会社のプロダクトではFuelPHPというWAFを使っているが、レシピはcommonなもので十分だった。

まずはDeployer公式サイト : https://deployer.org/

インストールは公式に書いてある通りでOK。

curl -LO https://deployer.org/deployer.phar
mv deployer.phar /usr/local/bin/dep
chmod +x /usr/local/bin/dep

前提条件としては、デプロイスクリプトを実行するマシンは、アプリサーバーに公開鍵認証でsshログインできることと、sudoコマンドでパスワードを聞かれないこと、apacheの設定でユーザーディレクトリの設定を済ませていることぐらい(必要なコマンドだけnopassにしてください)。

デプロイ対象サーバーの設定として、以下のような形式で設定を書き込む。

set('application', 'app'); # アプリサーバーのアプリ展開path等に使う。

host('999.999.000.999')       # リモートホストの指定。複数指定も可能(当たり前か)
    ->stage('production')     
    ->user('app_user')        # リモートホストのユーザー名。
    ->set('branch','master')  # pullしてデプロイする対象のブランチ
    ->set('deploy_path', '~/{{application}}');  # デプロイ対象path


## /home/app_user/app/以下のデプロイされたディレクトリにwww-dataへのアクセス権を与えるためのタスクを定義
desc('chown deployment dir');
task('deploy:chown_dir', function () {
    $target_dir = '~/{{application}}/current';
    $res1 = run("sudo chown -h app_user:www-data $target_dir");
    $res2 = run("sudo chown -hR app_user:www-data $target_dir/");
    $res3 = run("sudo chown -R app_user:www-data $target_dir/");
});

desc('My Application Deployment');
task('deploy', [
    'deploy:info',
    'deploy:prepare',
    'deploy:lock',
    'deploy:release',
    'deploy:update_code',
    'deploy:shared',
    'deploy:writable',
    'deploy:vendors',
    'deploy:clear_paths',
    'deploy:symlink',
    'deploy:chown_dir',    # これが追加されたタスク
    'deploy:unlock',
    'cleanup',
    'success'
]);

アプリケーションはリモートの/home/app_user/appに展開され、/home/app_user/app/currentのシンボリックリンクが指し示すディレクトリが最新のものになる。

デプロイスクリプトが置かれたディレクトリで以下のコマンドを実行すれば、アプリが展開される(うまい具合にcomposer updateなどもちゃんと実行される)

$ dep deploy production

MySQL(InnoDB)のロックとトランザクション

元PostgreSQLユーザー故、MySQLのトランザクションとテーブルロック獲得処理を逆に取り違えて致命的なミスをしてしまうところだった。

以下のようなPostgreSQLの(他セッションからの読み取りを含む)排他ロックが要求されるトランザクション処理があると仮定する。

## PostgreSQL
BEGIN;
LOCK TABLE table_stock IN ACCESS EXCLUSIVE MODE;
UPDATE table_reserve SET num_of_reserve = num_of_reserve + 1 where item_id = 10 and date = '2020-01-01';
UPDATE table_stock SET num_of_stock = num_of_stock - 1 where item_id = 10 and date = '2020-01-01';
COMMIT;

PostgreSQLでは「トランザクションを開始」->「排他ロック(ACCESS EXCLUSIVE)獲得」->「アトミックかつ不可視にしたい処理」->「トランザクションをコミット」->「ロックを自動的に開放」という流れになる。 また、ロックモードの指定は「ロックを獲得する対象テーブル(レコード)の状態をどのように変更するか」という観点での名前になっている。

それに対してMySQLでは、「autocommitを0に設定」->「排他ロックを獲得」->「暗黙的にトランザクションが開始される」->「アトミックかつ不可視にしたい処理」->「トランザクションを明示的にコミット」->「明示的にロックを開放」という流れ。 ロックモードの指定はREADとWRITEがあり、これは「ロックを獲得しようとしているセッションがこれから実行する処理は、読み込みだけなのか、それとも書き込みが含まれるのか」という観点での名前になっている。

複数テーブルに及ぶ更新系処理では、それら全てのテーブルに対して排他ロックを「一文で」獲得する必要がある。複数ステートメントでロックを獲得しようとしても、その前のロックは勝手にアンロックされる(というか、ロック獲得と同時に暗黙のトランザクションが開始される)。

そのほかにもいろいろある。

古いけど参考になるサイト: https://kannokanno.hatenablog.com/entry/20120704/1341419338

公式 : https://dev.mysql.com/doc/refman/5.6/ja/internal-locking.html

・・・と、PostgreSQL使いからするとかなり神経を使う激ヤバ仕様。

AWSでVPC構築

社会復帰してまだ半年にもならないですが、1年半以上パソコンを触っていないという浦島太郎状態から徐々にキャッチアップできるようになってきたような気がする(錯覚)

最近AWSで本番環境の構築作業をしていたので、メモ代わりに気付いたことを記しておく。ちなみにAWSで本番環境を構築するのは初めてです(今までにSoftbankクラウド、さくらのクラウド、GMOクラウド & Altus、IBMクラウドでWebサーバを構築したことはあります。AWSは個人アカウントの無料枠で試しただけ)

VPCのアドレス空間

まず初めに、展開するサービスの要件に合わせたアドレス空間でバーチャルプライベートネットワークを構築する。とりあえず 10.X.0.0/16 ぐらい確保しておけば大丈夫だろう。

AWSで複数のサービスを展開することや、不要になったサービスを事業譲渡などで他社に引き渡すことを考えると、プライベートネットワークをサービス単位で構築しておいた方が良い(サービス間連携はプライベートネットワーク内の通信以外の手段で実現する)。

VPNサーバーを立ててメンテ基盤を作ることもあるので、自社内・関係取引先含む既存のネットワークと重ならないアドレス空間が好ましい。

VPCには一つ以上のサブネットが必要。VPCで確保したアドレス空間と同じものを割り当ててよい。

インターネットゲートウェイを作成

VPCとインターネットを相互接続するためにはインターネットゲートウェイが必要。1つ作成し、割り当てる。

Egress-OnlyではインターネットからVPCに対するアクティブな接続はできないので注意(SSHなど)。

ここまで設定すればうまく動きそうに思えるが、後述するデフォルトゲートウェイの設定を書いてやらなければまだ動かない。

ルートテーブルの設定

作成したVPCのルートテーブルに、0.0.0.0/0の通信先をインターネットゲートウェイに向けるように設定し、作成したサブネットにも関連付けを施す。

これでやっとうまく動く。

phpのアトミックデプロイ

Perlのゼロダウンタイムでグレイスフルなホットデプロイに慣れていたせいで、phpのプロジェクトをデプロイ(本番環境で新しいプログラムに入れ替える)する際のアトミシティを考えていなかった。

今時自前で作るのはばかばかしいが実際に作るとしたら、ブルーグリーンデプロイメントのように、HTTPサーバで設定しているドキュメントルートの指し示すディレクトリをシンボリックリンクにして、それをアトミックに張り替えるスクリプトを組めば良いらしい。

参考1: https://ngyuki.hatenablog.com/entry/2017/07/25/082825

参考2: https://qiita.com/dalance/items/d2d2feb720172036fd22

ちなみに普段使いのFuelPHPではDeployerというのが良く使われている模様。ゼロダウンタイムと銘打っているので、これも恐らく同様にシンボリックリンクを使ってやってくれているものだと思われる。 (DBへのコネクションをpersistentに張っている場合はどうなるんだろう?)

アプリケーションを実行しているプロセスのグレイスフルなリスタートをするようなものがphpに無いか探したが、見つけられなかった。

InnoDBはHashインデックスを張ることができない

表題の通り。勘違いしてた。

Index作成時にHASHを指定できるが、実際に作成されるのはBTREEインデックスになるとのこと。で、ややこしいのがInnoDBは適応的ハッシュインデックスという機能を持っており、稼働状況から自動的に必要なハッシュインデックスが構築され、それが利用されるとのことでした。