RDBMSはデータセキュリティを学ぶには良い題材です。RDBMSはできる限り正しいデータを保存する仕組みがあるからです。RDBMSからどのようにデータのセキュリティを保証するのか紹介します。
プログラムが正しく動作する為には
- 正しいコードであること
- 正しい(妥当な)データであること
が絶対条件です。どちらが欠けてもプログラムは正しく動作しません。
SQLiteは手軽で良いのですが、組み込み型であるためデータセキュリティを学ぶには機能的に問題があります。SQLiteにはデータ型を保証する機能もデータの内容を検証する機能もありません。1
利用するRDBMSはPostgreSQLを用い、データセキュリティに関連性の強い機能のみを対象とします。
RDBMSのデータセキュリティ
RDBMSはきっちりとデータを管理する仕組みを提供するシステムです。この為に色々な仕組みが提供されています。
- データ型の制約
- 文字列の長さ、文字エンコーディングの強制
- 数値型の精度の強制
- データの制約
- データ関係の制約
- プロシージャーによるデータバリデーション
データ型の制約
データ型には”正しいデータ形式”があります。使われている文字や値、データ構造は”正しいデータ形式”であることが要求されます。
以下のようなテーブルを作り、動作を確認してみましょう。
CREATE TYPE status AS ENUM('ok', 'ng', 'na'); CREATE TABLE mytable ( "int4" int4, "int8" int8, "float" float, "numeric" numeric, "numeric1" numeric(4), "numeric2" numeric(4,2), "char16" char(16), "varchar16" varchar(16), "text" text, "bool" boolean, "enum" status, "date" date, "timestamp" timestamp, "inet" inet, "uuid" uuid, "json" json, "xml" xml );
整数型
データ型の強制は強いデータ型を持つプログラミング言語のデータ型と同じです。
ただし、RDBMSデータ型はC言語などとは異りオーバーフローを許しません。2
yohgaki@[local] ~=> INSERT INTO mytable (int8) VALUES (9999999999999999999999999); ERROR: 22003: bigint out of range LOCATION: numeric_int8, numeric.c:3005 yohgaki@[local] ~=> INSERT INTO mytable (numeric1) VALUES (9999999999999999999999999); ERROR: 22003: numeric field overflow DETAIL: A field with precision 4, scale 0 must round to an absolute value less than 10^4. LOCATION: apply_typmod, numeric.c:6082
このようにオーバーフロー/アンダーフローするデータを使うとバリデーションエラーになります。
PostgreSQLの場合、大きな数値が取扱える任意精度型のNUMERIC型が用意されています。INT8よりも大きな整数を取り扱う場合はNUMERIC型を利用します。
yohgaki@[local] ~=> INSERT INTO mytable (numeric) VALUES (9999999999999999999999999); INSERT 0 1
弱いデータ型を持つPHPやJavaScriptではオーバーフローすると”不正確な浮動小数点型”になります。
PHP
php > $i = 999999999999999999999; php > var_dump($i); php shell code:1: double(1.0E+21)
JavaScript(ChromeのConsoleで実行)
> var i = 99999999999999999999999999999 undefined > i 1e+29
この動作では”正しいデータが変数に保存できた”とは言えません。”正しい整数”が必要な場合にはこういった動作は検出しないと、”正しいコード”であったとしても”間違った結果”となります。
浮動小数点型
浮動小数点型はそもそも正しい値を保存できません。”正しい値”を保存できるケースは限られています。このためRDBMSでも整数型のような厳しいチェックはありません。
yohgaki@[local] ~=> SELECT float FROM mytable WHERE float IS NOT NULL; float ------- 1e+25 (1 行)
RDBMSでもPHPやJavaScriptのような変換が行われます。
IEEE754の浮動小数点型には無限大(INFまたは-INF)が定義されています。しかし、PostgreSQLの場合は明示的に無限大(表現可能な範囲を超えた値)であると指定しないと保存できません。
yohgaki@[local] ~=> INSERT INTO mytable (float) VALUES (1e1000); ERROR: 22003: "10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" is out of range for type double precision LOCATION: float8in_internal, float.c:538
このように浮動小数点型でもオーバーフローが検出されバリデーションエラーになります。
無限大(INF/-INF)を保存するには明示的に指定しないと保存できません。
yohgaki@[local] ~=> INSERT INTO mytable (float) VALUES ('INF'::float); INSERT 0 1
明示的にINF/-INFを指定したときだけ、浮動小数点型の範囲を超える、つまりオーバーフローする値でも無限大として保存できます。
NUMERIC型で小数点以下の値を保存できるようにした場合も同じです。
yohgaki@[local] ~=> INSERT INTO mytable (numeric2) VALUES (12.34); INSERT 0 1 時間: 22.842 ミリ秒 yohgaki@[local] ~=> INSERT INTO mytable (numeric2) VALUES (12.3456); INSERT 0 1 時間: 192.609 ミリ秒 yohgaki@[local] ~=> SELECT numeric2 FROM mytable WHERE numeric2 IS NOT NULL; numeric2 ---------- 12.34 12.35 (2 行)
指定精度への”丸め”は自動的に行いますが、オーバーフローはバリデーションエラーになります。
yohgaki@[local] ~=> INSERT INTO mytable (numeric2) VALUES (12312.3456); ERROR: 22003: numeric field overflow DETAIL: A field with precision 4, scale 2 must round to an absolute value less than 10^2. LOCATION: apply_typmod, numeric.c:6082
CやPHP、JavaScriptの浮動小数点型はオーバーフローする場合は”不正確な”INF/-INFへの変換を勝手に行いエラー/例外になりません。
PHP
php > var_dump($i); php shell code:1: double(INF)
JavaScript
> var i = 1e1000 undefined > i Infinity
値が何であるか分らないけれども、勝手に取り敢えず無限大にしてしまう、という動作では”正しい動作を保証している”とは言えません。無限大には、符号を変える以外、何をしても正しい結果になりません。
php > $i = 1e1000; php > var_dump($i); php shell code:1: double(INF) php > var_dump($i/1e500); php shell code:1: double(NAN) php > $i = 1e1000; php > var_dump($i * -1); php shell code:1: double(-INF) php > var_dump($i + 1); php shell code:1: double(INF) php > var_dump($i - 1); php shell code:1: double(INF)
文字列型
RDBMSの文字列型は文字列の長さをデータ型として定義できます。
yohgaki@[local] ~=> INSERT INTO mytable (char16) VALUES ('abcdef1234567890'); INSERT 0 1 時間: 195.825 ミリ秒 yohgaki@[local] ~=> INSERT INTO mytable (char16) VALUES ('abcdef1234567890a'); ERROR: 22001: value too long for type character(16) LOCATION: bpchar, varchar.c:309
長過ぎる文字列はエラーになり保存できません。
文字列型には文字エンコーディングがあり、壊れた文字エンコーディングもチェックされます。テストに使っているデータベースの文字エンコーディングはUTF-8です。
php > pg_query_params($db, 'INSERT INTO mytable (char16) VALUES ($1)', [hex2bin('e69720')]); PHP Warning: pg_query_params(): Query failed: ERROR: invalid byte sequence for encoding "UTF8": 0xe6 0x97 0x20 in php shell code on line 1 PHP Stack trace: PHP 1. {main}() php shell code:0 PHP 2. pg_query_params() php shell code:1 Warning: pg_query_params(): Query failed: ERROR: invalid byte sequence for encoding "UTF8": 0xe6 0x97 0x20 in php shell code on line 1
しかし、これで完璧とは言えません。PostgreSQLにencode()/decode()関数があります。これを使うと壊れた文字エンコーディングでも文字エンコーディングチェックをバイパスできます。先ほどと同じ壊れた文字エンコーディングデータをdecode()を使って挿入してみます。
yohgaki@[local] ~=> INSERT INTO mytable (char16) VALUES (decode('5pcg', 'base64')); INSERT 0 1
挿入できてしまいました。
これはdecode()を使うとデータがbytea型として保存されるのでこうなります。
yohgaki@[local] ~=> SELECT char16 FROM mytable WHERE char16::bytea = decode('5pcg', 'base64'); char16 ------------------ \xe69720 (1 行) yohgaki@[local] ~=> SELECT decode('5pcg', 'base64'); decode ---------- \xe69720 (1 行)
データをよく見ると\xから始まっていることが分かります。これは\x以降の文字列がHEXエンコーディングでエンコードされたバイナリであることを意味しています。bytea型データの中身は何でも構いません。UTF-8文字列データとして妥当なので保存できます。
Bytea型にエンコーディングすると壊れた文字エンコーディングでも保存できるように見えたかも知れませんが、仕組的に壊れた文字列データを文字列型データカラムに保存できないようになっていることが分かります。3
他のデータ型
ENUMやinet、uuid、json、xmlとといったデータ型も”指定した形式”の仕様にあっていないとデータを保存できません。XMLのような複雑なデータ型でも中身がバリデーションされます。
yohgaki@[local] ~=> INSERT INTO mytable (xml) VALUES ('<tag><tag>'); ERROR: 2200N: invalid XML content 行 1: INSERT INTO mytable (xml) VALUES ('<tag><tag>'); ^ DETAIL: line 1: Premature end of data in tag tag line 1 <tag><tag> ^ line 1: Premature end of data in tag tag line 1 <tag><tag> ^ LOCATION: xml_ereport, xml.c:1611 時間: 0.717 ミリ秒 yohgaki@[local] ~=> INSERT INTO mytable (xml) VALUES ('<tag></tag>'); INSERT 0 1 時間: 192.541 ミリ秒
サンプルテーブルで定義したデータ型の制約(バリデーション)くらいは紹介しよう、と思っていましたが長くなりすぎるので省略しました。試しに色々な値を挿入してみてください。おかしな値は可能な限り挿入できないようになっています。
データの制約
“データ型として正しい”だけでは”正しいデータ”とは言えません。単純な数値型であっても”正しいデータ”には”正しい範囲”があることが多いです。
PostgreSQLの場合、データ型の範囲外である場合は自動的にバリデーションされエラーになります。しかし、これでは正しいデータとしてまだ不十分です。この為、CHECK制約が用意されています。
CHECK制約
年齢データの場合、0才以上かつ150才未満である、とするのが今のところ妥当でしょう。
CREATE TABLE mytable2 ( "age" int2 CHECK (age >=0 AND age < 150) );
CHECK制約はデータの挿入/更新前に確認され、妥当なデータだけが保存できます。
yohgaki@[local] ~=> INSERT INTO mytable2 (age) VALUES (-1); ERROR: 23514: new row for relation "mytable2" violates check constraint "mytable2_age_check" DETAIL: Failing row contains (-1). SCHEMA NAME: public TABLE NAME: mytable2 CONSTRAINT NAME: mytable2_age_check LOCATION: ExecConstraints, execMain.c:2055 時間: 0.942 ミリ秒 yohgaki@[local] ~=> INSERT INTO mytable2 (age) VALUES (160); ERROR: 23514: new row for relation "mytable2" violates check constraint "mytable2_age_check" DETAIL: Failing row contains (160). SCHEMA NAME: public TABLE NAME: mytable2 CONSTRAINT NAME: mytable2_age_check LOCATION: ExecConstraints, execMain.c:2055 時間: 0.510 ミリ秒 yohgaki@[local] ~=> INSERT INTO mytable2 (age) VALUES (123); INSERT 0 1 時間: 192.343 ミリ秒
文字列型のように何でも保存できてしまうデータ型の場合、データ形式がチェックしないと”正しいデータ”であることを保証できません。
例えば、HEXエンコーディングされた32文字のハッシュ値である場合は以下のようにCHECK制約でバリデーションします。
CREATE TABLE mytable3 ( "hash" text CHECK (char_length(hash) = 32 AND hash ~'\A[0-9a-f]+\Z') );
文字列の場合、実行効率を考えると長さのチェックを最初にすべきです。その後、正規表現で内容をチェックし、データ形式が”確実に正しい”形式であることを保証しています。
yohgaki@[local] ~=> INSERT INTO mytable3 (hash) VALUES (E'0123456789012345678901234567890\n'); ERROR: 23514: new row for relation "mytable3" violates check constraint "mytable3_hash_check" DETAIL: Failing row contains (0123456789012345678901234567890 ). SCHEMA NAME: public TABLE NAME: mytable3 CONSTRAINT NAME: mytable3_hash_check LOCATION: ExecConstraints, execMain.c:2055 時間: 0.639 ミリ秒 yohgaki@[local] ~=> INSERT INTO mytable3 (hash) VALUES (E'0123456789012345678901234567890g'); ERROR: 23514: new row for relation "mytable3" violates check constraint "mytable3_hash_check" DETAIL: Failing row contains (0123456789012345678901234567890g). SCHEMA NAME: public TABLE NAME: mytable3 CONSTRAINT NAME: mytable3_hash_check LOCATION: ExecConstraints, execMain.c:2055 時間: 0.556 ミリ秒
改行などHEX文字列に含まれない文字がある場合はバリデーションエラーとなります。
より複雑なデータ型のCHECK制約
複合型、配列型、JSON型、XML型などのデータ内容によってバリデーションしたい場合があります。
PostgreSQLではこういった場合でも比較的容易に複数の要素を持つデータ型のバリデーションが行えます。
CHECK制約をデータ型の直後に書くと制約が大きく成りすぎて、テータ定義の見通しが悪くなります。制約は別途に記載することも可能です。
CREATE TABLE myjson ( id SERIAL, data JSON, CONSTRAINT validate_name CHECK ((data->>'name') IS NOT NULL AND length(data->>'name') > 0), CONSTRAINT validate_description CHECK ((data->>'description') IS NOT NULL AND length(data->>'description') > 10) );
NOT NULL制約
特定のデータは必ず存在しなければならない、といった場合はNOT NULL制約が利用できます。
CREATE TABLE mytable4 ( "name" text NOT NULL CHECK (char_length(name) >= 1 AND char_length(name) < 100) );
空文字列とNULLでは大きな違いがあり、全く別の物です。CHECK制約を使って1文字以上の制約を付けても、NULLデータの挿入は防げません。mytable4ではNOT NULL制約を付けてNULLデータをバリデーションしています。
yohgaki@[local] ~=> INSERT INTO mytable4 ("name") VALUES (NULL); ERROR: 23502: null value in column "name" violates not-null constraint DETAIL: Failing row contains (null). SCHEMA NAME: public TABLE NAME: mytable4 COLUMN NAME: name LOCATION: ExecConstraints, execMain.c:2008
CHECK制約で文字数をバリデーションしているので’’(空文字列)はエラーです。
yohgaki@[local] ~=> INSERT INTO mytable4 ("name") VALUES (''); ERROR: 23514: new row for relation "mytable4" violates check constraint "mytable4_name_check" DETAIL: Failing row contains (). SCHEMA NAME: public TABLE NAME: mytable4 CONSTRAINT NAME: mytable4_name_check LOCATION: ExecConstraints, execMain.c:2055
文字エンコーディングはデフォルトでバリデーションされます。スペースや記号といった名前として不適切と考えられる文字のチェックも行うべきですが、ここでは省略しています。
UNIQUE制約
ID型データの場合、その値はデータセット中で一意である必要があります。この場合はUNIQUE制約を使います。
CREATE TABLE mytable5 ( "id" text UNIQUE );
UNIQUE制約を適用すると同じ値のデータの存在をバリデーションし、重複を避けます。
yohgaki@[local] ~=> INSERT INTO mytable5 ("id") VALUES (123); INSERT 0 1 時間: 42.233 ミリ秒 yohgaki@[local] ~=> INSERT INTO mytable5 ("id") VALUES (123); ERROR: 23505: duplicate key value violates unique constraint "mytable5_id_key" DETAIL: Key (id)=(123) already exists. SCHEMA NAME: public TABLE NAME: mytable5 CONSTRAINT NAME: mytable5_id_key LOCATION: _bt_check_unique, nbtinsert.c:434
データバリデーションの多くは形式的なチェックですが、UNIQUE制約は論理的な制約の一種です。
データ関係の制約
このデータセットが存在する為には他のデータセットが存在しなければならない、といった制約が必要(親子関係が必要)になることがあります。
RDBMSのRはRelational(関係)で、その名前の通り関係の整合性を強制する仕組みも持っています。
FOREIGN(外部)キー制約
1対多、多対多という関係が正しく保たれないとカラム/テーブルの”データ形式/データセット”として正しくても、”論理的には壊れている”データベースが出来てしまいます。FOREIGN(外部)キー制約を使うと論理的関係の正しさ保証できます。
CREATE TABLE cities ( city varchar(80) primary key, location point ); CREATE TABLE weather ( city varchar(80) references cities(city), temp_lo int, temp_hi int, prcp real, date date );
Weatherテーブルのreferences cities(city)でCitiesテーブルのcityプライマリキーを参照しています。
存在しないcityに対してweather情報を挿入しようとしてもバリデーションエラーとなりクエリに失敗します。
yohgaki@[local] ~=> INSERT INTO cities (city) VALUES ('岡山市'); INSERT 0 1 時間: 195.240 ミリ秒 yohgaki@[local] ~=> INSERT INTO weather (city) VALUES ('高松市'); ERROR: 23503: insert or update on table "weather" violates foreign key constraint "weather_city_fkey" DETAIL: Key (city)=(高松市) is not present in table "cities". SCHEMA NAME: public TABLE NAME: weather CONSTRAINT NAME: weather_city_fkey LOCATION: ri_ReportViolation, ri_triggers.c:3266
REFERENCES制約も論理的な制約の1つです。
プロシージャーによるデータバリデーション
ここまで様々な制約によるデータバリデーションをざっと見てきました。これでも足りない!といった事は十分にあります。
RDBMSの場合、ストアードプロシージャー(プログラム)によるバリデーションが可能です。しかも、事前条件(データ操作前)のチェックのみでなく、事後条件(データ操作後)のチェックも可能です。
例えば「より複雑なデータ型のCHECK制約」の長さチェックをストアードプロシージャーでチェックするとこうなります。
CREATE FUNCTION is_valid_description(descr text) RETURNS BOOLEAN AS 'SELECT length($1) > 10;' LANGUAGE sql; CREATE TABLE myjson ( id SERIAL, data JSON, CONSTRAINT validate_name CHECK ((data->>'name') IS NOT NULL AND length(data->>'name') > 0), CONSTRAINT validate_description CHECK ((data->>'description') IS NOT NULL AND (is_valid_description(data->>'description'))) );
PostgreSQLの場合、PL/PgSQL以外にもPerl、Python、PHP、JavaScriptといった言語でもストアードプロシージャーを記述できます。
ストアードプロシージャーはプログラムなので、どのような複雑なバリデーションでも自由自在に行うことが可能です。
例えば銀行口座などの取引で、取引後の口座残高は必ず0以上でなければならない、といった場合には以下のようにプロシージャーでバリデーションできます。
CREATE TRIGGER check_update BEFORE UPDATE ON accounts FOR EACH ROW WHEN (OLD.balance IS DISTINCT FROM NEW.balance) EXECUTE PROCEDURE check_account_update();
RDBMSのデータバリデーションから学ぶプログラムのデータバリデーション
データベースはデータを”セキュア”に保存するためのシステムです。データのセキュリティに関係する仕様や制約により安全にデータを保存する(おかしなデータはバリデーションで弾く)仕組みであることが分かりました。
「正しい(妥当)なデータ」であることがとても重要です。
アプリケーションも正しいコードだけではなく、
- 正しい(妥当)なデータ
であることを保証する(=バリデーションする)ことがとても重要です。「正しい(妥当)なデータ」でしかプログラムは正しく動作しないので重要というよりも、「正しい(妥当)なデータ」は必須の必要条件です。
ライブラリレベルでは”正しい(妥当)なデータ”であることを保証するコードを書かないことがとても多いです。仕組的に不可能であるモノも少なくありません。
コマンド実行はライブラリ呼び出しではありませんが、ライブラリ関数呼び出しでも同じようなことが起きます。
DBでも雑なデータセキュリティ対策
RDBMSはかなりしっかりしたデータセキュリティ対策が取れる仕組みを提供しています。しかし、多くのシステムは”かなり緩い”、キツい言い方をすると”雑な”データセキュリティ対策しかしていません。
DB側で厳格にバリデーションしない正当な理由として”パフォーマンス問題”があると思います。しかし、多くの場合は”更新そこそこ”で”参照のみパフォーマンスが必要”といった場合でもかなり雑なデータセキュリティ対策しか実装していないです。
アプリのデータセキュリティ対策はDBよりももっと緩い
プログラムが正しく動作する為には正しい(妥当な)データが必要なのですが、Webアプリのほとんどはデータセキュリティ対策があってもとても緩いデータセキュリティ対策であるか、多くの場合はそもそも対策していないケースが目立ちます。
インジェクション攻撃対策は出力対策として行うモノ、と考えている方も少なくないと思います。実際、送信され保存されるデータの内容がデタラメであるかどうか、は別にして出力する際には「出力データが出力先で誤作動を起こさない無害化されたデータであること」を保証することは必須です。
しかし、インジェクション攻撃対策は出力対策”だけ”で行うセキュリティ対策はとても雑なセキュリティ対策です。そこら中で出鱈目なデータが保存される可能性、そこら中で対処が難しいセキュリティ問題が発生する可能性があるので「雑なセキュリティ対策」と言えます。
雑で不十分とはいっても、「プリペアードクエリだけでSQLインジェクション対策は十分!」とする不十分/不完全な対策、「コマンドインジェクション対策はコマンドと引数の分離で十分!」とする雑な対策、だけでもそれなりには効果を上げてきています。
効果を上げてきているからそれで良いという物ではなく、そもそも「出鱈目なデータはアプリケーションレベルで全て完全に廃除する」のが丁寧なセキュリティ対策に欠かせないモノです。これを書き出すととても長くなるので、
データの正しさ(妥当性)をバリデーションしていないアプリケーションのセキュリティは”ユルユルです”と言われても仕方ない状況に現在はあります
と書いて結びにします。丁寧なセキュリティ対策を実装したアプリケーションには、丁寧なテーブル定義をしたデータベースと同等以上のデータセキュリティ対策が必要です。詳しくは参考リンクをご覧ください。
参考リンク:
- IPAは基礎的誤りを明示し、正しい原則を開発者に啓蒙すべき
- CERT Top 10 Secure Coding Practices
- 2017年度版 OWASP TOP 10 で変るWebセキュリティのルール
- バリデーションには3種類のバリデーションがある
- ほぼ全てのインジェクション攻撃を無効化/防止する入力バリデーション
- 完全なSQLインジェクション対策