Web アプリを作成して SQL インジェクションから保護してみた

記事タイトルとURLをコピーする

当記事は みずほリサーチ&テクノロジーズ × G-gen エンジニアコラボレーション企画 で執筆されたものです。

Cloud Armor は Google Cloud でセキュアな Web アプリケーションを構築するために欠かせないプロダクトです。 代表的なアプリケーションへの攻撃である SQL インジェクションを題材に、CloudArmor の機能を体験できるハンズオンを整備しました。


G-gen の片岩です。 当記事ではサーバレスな Web アプリケーションを構築し、SQL インジェクション攻撃から保護するまでの手順をご紹介します。

Cloud Armor

はじめに

Cloud Armor とは

Cloud Armor は Google 製のクラウド型 WAF (Web Application Firewall) です。 WAF とは SQL インジェクション、クロスサイトスクリプティング、DDoS など、 Web アプリケーションに対するアプリケーションレイヤの攻撃を検知・防御するための仕組みです。 アクセス元の IP アドレスを制限することも可能です。

当記事の概要

今回は以下の手順に沿って Web アプリケーションの構築および SQL インジェクション対策を実施しました。

  • Cloud SQL 構築
  • サービスアカウントの作成
  • Docker コンテナの作成
  • Cloud Run 上で動作する Web アプリケーションの作成
  • ロードバランサの作成
  • Cloud Armor による SQL インジェクション対策の確認

関連記事

利用するサービスについては以下の記事にて詳しく解説しています。

構成図

Web アプリケーションの構成図を以下に示します。

作成するアプリケーション

作成するアプリケーションの画面です。

事前準備

Google Cloud にアクセス

Google Cloud へアクセスします。

プロジェクトIDの確認

はじめに、ご自身のプロジェクトIDを確認します。

Cloud Shell の起動

今回は Cloud Shell を利用して実装します。 Cloud Shell では専用の仮想マシンが払い出され、開発用のクライアントとして使用できます。 gcloud コマンドリファレンスはこちらです。

それでは、コンソール画面右上の Cloud Shell を起動するアイコンをクリックします。

変数PROJECT_IDの設定

以降のコマンドにて PROJECT_ID を設定する手間を省くため、変数 PROJECT_ID を設定します。 プロジェクトIDをご自身のプロジェクトIDに読み替えて以下のコマンドを実行します。

PROJECT_ID=プロジェクトID

Cloud SQL で RDB を準備

インスタンスの作成

まず Cloud SQL のインスタンスを作成します。 インスタンス ID は「my-cloudsql」とします。パスワードには任意のパスワードを設定してください。 なお、Cloud SQL インスタンスの作成には 2 〜 3 分掛かります。

gcloud sql instances create my-cloudsql \
 --database-version=POSTGRES_14 \
 --cpu=1 \
 --memory=4GB \
 --region=asia-northeast1 \
 --root-password=rootpassword

APIが有効でない場合、APIを有効にするか確認されるので「y」を入力します。

API [sqladmin.googleapis.com] not enabled on project [xxxxxxxxxxxx]. Would you like to enable and retry (this will take a few minutes)? (y/N)?

※Cloud SQL 以外のサービスでも同様です。API を有効にして以降の手順を進めてください。

データベースの作成

次にデータベースを作成します。データベース名は「my-db」とします。

gcloud sql databases create my-db --instance=my-cloudsql

ユーザーの作成

ユーザーを作成します。ユーザー名は「my-user」とします。 パスワードは任意で構いません。値を忘れないようメモします。
※当記事では以降 password と記載しますが、安易なパスワードの設定は非推奨です。

gcloud sql users create my-user \
  --instance=my-cloudsql \
  --password=password

サービスアカウントの作成

サービスアカウントの作成

Cloud Run から Cloud SQL にアクセスできる権限を持ったサービスアカウントを作成します。サービスアカウント名を「cloudsql-client」とします。

gcloud iam service-accounts create cloudsql-client \
  --display-name="cloudsql-client"

サービスアカウントに権限を付与

Cloud SQL クライアントのロールを付与します。

gcloud projects add-iam-policy-binding ${PROJECT_ID} \
  --member="serviceAccount:cloudsql-client@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/cloudsql.client"

コンテナイメージの作成

概要

Python と Flask を利用したオリジナルのアプリを作成します。
ここでは以下の 5 つのファイルを準備します。

ファイル名 説明
index.html 画面レイアウトを記述したファイル
app.py アプリケーションの挙動を記述したファイル
connect_unix.py Cloud SQL に接続するためのファイル (*1)
requirements.txt 利用するパッケージを記述したファイル (*1)
Dockerfile コンテナをビルドするときに使用するファイル (*1)

(*1) Google Cloud が提供している Python のサンプルアプリコードを利用。

エディタの起動

それではファイルの作成に着手します。 まずは「エディタを開く」をクリックし、コードの編集画面を表示します。

ファイルの配置

次に以下の構成でファイルを配置します。 コードはコピー&ペーストしてください。

index.html
画面レイアウトを記述したファイルです。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <title>テストWebアプリ</title>
</head>
<body>
<div class="container mt-4">
    <h2>商品登録</h2>
    <form action="/add" method="POST">
        <input name="product" class="form-control" type="text" placeholder="商品名"></input>
        <button type="submit" class="btn btn-primary">商品登録</button>
    </form>
</div>
<div class="container mt-4">
    <h2>商品削除</h2>
    <form action="/delete" method="POST">
        <input name="id" class="form-control" type="text" placeholder="#"></input>
        <button type="submit" class="btn btn-primary">商品削除</button>
    </form>
</div>
<div class="container mt-4">
    <h2>商品一覧</h2>
    <table class="table table-striped">
        <thead>
            <tr>
              <th scope="col">#</th>
              <th scope="col">名前</th>
              <th scope="col"></th>
            </tr>
          </thead>
          <tbody>
            {% for product in products %}
            <tr>
              <th scope="row">{{ product.id }}</th>
              <td>
                <a class="text-decoration-none">
                  {{ product.product }}
                </a>
              </td>
            </tr>
            {% endfor %}
          </tbody>
    </table>
</div>
</body>
</html>

app.py
アプリケーションの挙動を記述したファイルです。

import os
import sqlalchemy
from typing import Dict
from flask import Flask, render_template, request, Response, redirect, url_for
from connect_unix import connect_unix_socket
  
  
app = Flask(__name__)
db = None
  
  
def init_connection_pool() -> sqlalchemy.engine.base.Engine:
    return connect_unix_socket()
  
  
@app.before_first_request
def init_db() -> sqlalchemy.engine.base.Engine:
    global db
    db = init_connection_pool()
    with db.connect() as conn:
        conn.execute(
            "DROP TABLE IF EXISTS products;"
            "CREATE TABLE products ( id SERIAL NOT NULL, product VARCHAR(30) , PRIMARY KEY (id) );"
        )
  
  
@app.route("/", methods=["GET", "POST"])
def render_index() -> str:
    products = []
    with db.connect() as conn:
        results = conn.execute("SELECT id, product FROM products").fetchall()
        for row in results:
            products.append({"id": row[0], "product": row[1]})
    context = {"products": products}
    return render_template("index.html", **context)
  
  
@app.route("/add", methods=["POST"])
def product_add():
    with db.connect() as conn:
        sample_sql="INSERT INTO products(product) VALUES('" + request.form.get('product') + "');"
        conn.execute(sample_sql)
    return redirect(url_for('render_index'))
  
  
@app.route("/delete", methods=["POST"])
def product_delete():
    with db.connect() as conn:
        sample_sql="DELETE FROM products WHERE id = '" + request.form.get('id') + "'"
        conn.execute(sample_sql)
    return redirect(url_for('render_index'))
  
  
if __name__ == "__main__":
    app.run(host="127.0.0.1", port=8080, debug=True)

connect_unix.py
Cloud SQL に接続するためのファイルです。

import os
import sqlalchemy
  
  
def connect_unix_socket() -> sqlalchemy.engine.base.Engine:
    db_user = os.environ["DB_USER"]
    db_pass = os.environ["DB_PASS"]
    db_name = os.environ["DB_NAME"]
    unix_socket_path = os.environ["INSTANCE_UNIX_SOCKET"]
    pool = sqlalchemy.create_engine(
        sqlalchemy.engine.url.URL.create(
            drivername="postgresql+pg8000",
            username=db_user,
            password=db_pass,
            database=db_name,
            query={"unix_sock": "{}/.s.PGSQL.5432".format(unix_socket_path)},
        ),
        pool_size=5,
        max_overflow=2,
        pool_timeout=30,
        pool_recycle=1800,
    )
    return pool

requirements.txt 利用するパッケージを記述したファイルです。

Flask==2.1.0
pg8000==1.24.2
SQLAlchemy==1.4.38
cloud-sql-python-connector==1.0.0
gunicorn==20.1.0

Dockerfile
コンテナをビルドするときに使用するファイルです。

FROM python:3
COPY requirements.txt ./
RUN set -ex; \
    pip install -r requirements.txt; \
    pip install gunicorn
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . ./
CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 app:app

ターミナルの表示

ファイル配置後はエディタ右上の「ターミナルを開く」をクリックします。

コンテナイメージのビルド

Cloud Build を使用して、Dockerfile を元に Docker コンテナのイメージをビルドします。
cd コマンドにより Dockerfile のあるディレクトリに移動し、以下のコマンドを実行します。コンテナのイメージ名は「my-container」とします。

cd handson-cloudarmor
gcloud builds submit --tag gcr.io/${PROJECT_ID}/my-container

ビルドしたコンテナイメージは、指定したプロジェクトの Container Registry に格納されます。

Cloud Run でアプリケーションを実行

デプロイ

Cloud Run はサーバーレスなコンテナ実行基盤を提供するサービスです。
先程作成したコンテナイメージを Cloud Run にデプロイします。 Cloud SQLの DB の password を読み替えて以下のコマンドを実行します。

gcloud run deploy my-run --image gcr.io/${PROJECT_ID}/my-container \
  --add-cloudsql-instances my-cloudsql \
  --region=asia-northeast1 \
  --service-account=cloudsql-client \
  --allow-unauthenticated \
  --set-env-vars INSTANCE_UNIX_SOCKET="/cloudsql/${PROJECT_ID}:asia-northeast1:my-cloudsql" \
  --set-env-vars DB_NAME="my-db" \
  --set-env-vars DB_USER="my-user" \
  --set-env-vars DB_PASS="password"

動作確認

ターミナルに表示された URL をクリックし、サンプルアプリケーションにアクセスします。

以下のとおりアプリの挙動を確認します。

  • 任意の商品名を入力して商品を登録できること
  • 削除したい商品番号を入力して商品を削除できること

HTTP(S) ロードバランサの作成

ロードバランサの作成

Cloud Run サービスの前段に配置する HTTP(S) ロードバランサを作成します。 Cloud Armor の利用にはロードバランサが必要になります。 また、それ以外にもオリジンの保護や静的IPアドレスの払い出し、CDN との連携などのメリットがあります。

ネットワークエンドポイントグループを作成します。 ネットワークエンドポイントには先程作成した Cloud Run サービスを指定します。

gcloud compute network-endpoint-groups create my-neg \
 --region=asia-northeast1 \
 --network-endpoint-type=serverless \
 --cloud-run-service=my-run

バックエンドサービスを作成し、ネットワークエンドポイントグループを関連付けます。

gcloud compute backend-services create my-backend --global
  
gcloud compute backend-services add-backend my-backend --global \
 --network-endpoint-group=my-neg \
 --network-endpoint-group-region=asia-northeast1

以下の3つのコマンドを順次実行し、ロードバランサーを作成します。

gcloud compute url-maps create my-lb --default-service my-backend
gcloud compute target-http-proxies create my-http-proxy --url-map my-lb
gcloud compute forwarding-rules create my-forwarding-rule --global \
 --target-http-proxy my-http-proxy \
 --ports 80 

動作確認

ロードバランサが作成されたら 以下のコマンドを実行してロードバランサの IP アドレスを確認します。

gcloud compute forwarding-rules describe my-forwarding-rule --global

ブラウザからアクセスすると画面が表示されることを確認します。

ロードバランサの作成にはしばらく時間がかかります。アクセスできない場合は、しばらく待機してから再度アクセスしてください。

SQL インジェクション対策

SQL インジェクション攻撃

この Web アプリでは、削除したい商品の番号を入力して商品を削除します。
具体的には以下の SQL を発行しています。詳細は app.py の product_delete 関数をご確認ください。

DELETE FROM products WHERE id = '入力した番号'

この実装は SQL インジェクションが可能で、脆弱です。 '入力した番号'に、WHERE 句が必ず True になるような値を設定すると全データが削除できてしまいます。 例えば、以下のような SQL は WHERE 句が必ず True になり、全データが削除対象になってしまいます。

DELETE FROM products WHERE id = '1' or '1' = '1'

上記の SQL を実行させるためにテキストボックスに 1' or '1' = '1 と入力して商品を削除すると、すべてのデータが削除されてしまうことを確認してください。

Cloud Armor による保護

それでは Cloud Armor により SQL インジェクション攻撃から保護します。

gcloud compute security-policies create my-policy
gcloud compute security-policies rules create 100 --project=${PROJECT_ID} --action=deny-403 --security-policy=my-policy --expression=evaluatePreconfiguredExpr\(\'sqli-v33-stable\'\)
gcloud compute backend-services update my-backend --security-policy my-policy --global 

今回は expression=evaluatePreconfiguredExpr\(\'sqli-v33-stable\'\) を設定することにより、SQLインジェクション攻撃に対処しています。 ほかの値を設定することも可能です。詳しくは コチラ をご参照ください。

保護確認

それでは確認用のデータを登録し、先程と同様にテキストボックスに 1' or '1' = '1 と入力して攻撃します。 すると403エラーが発生するはずです。

Cloud Armor により、SQL インジェクションをもくろむリクエストが検知され、403 エラーが返却されています。 再度画面にアクセスすると、データは削除されていないことがわかります。 これは Cloud Run で実行している Web アプリケーションを呼び出すことなく、ロードバランサ+Cloud Armor によって 403 エラーが返却されるためです。

WAF を迂回したアクセスの禁止

ここまでの手順で Cloud Armor によって SQL インジェクションを対策できました。しかし、これだけでは不十分です。Web アプリケーションにアクセスするには以下 2 つのルートがあります。

  • ①ロードバランサを経由するルート
  • ②直接 Cloud Run サービスの URL にアクセスするルート

先程は①のルートを保護しましたが、②のルートを保護できていません。

--ingress=internal-and-cloud-load-balancing の指定を追加した Cloud Run のデプロイコマンドを実行します。

gcloud run deploy my-run --image gcr.io/${PROJECT_ID}/my-container \
  --add-cloudsql-instances my-cloudsql \
  --region=asia-northeast1 \
  --ingress=internal-and-cloud-load-balancing \
  --service-account=cloudsql-client \
  --allow-unauthenticated \
  --set-env-vars INSTANCE_UNIX_SOCKET="/cloudsql/${PROJECT_ID}:asia-northeast1:my-cloudsql" \
  --set-env-vars DB_NAME="my-db" \
  --set-env-vars DB_USER="my-user" \
  --set-env-vars DB_PASS="password"

--ingress=internal-and-cloud-load-balancing の指定は、internal-and-cloud-load-balancing と記述されているとおり、Cloud Run サービスに対して Google Cloud 内部やロードバランサからのアクセスのみを許可する設定で、インターネットからの直接のアクセスを許可しません。

これで ②直接 Cloud Run サービスの URL にアクセスするルート を禁止できました。 SQL インジェクション対策ができたと言えます。

片岩 裕貴 (記事一覧)

データアナリティクス準備室

2022年5月にG-gen にジョイン。
AI/ML系に関心が強く、ディープラーニングE資格とProfessional Machine Learningを取得。最近話題のGenerative AIにも興味がある。毎日の日課は三人乗りの電動自転車で子供を幼稚園に送り迎えすること。和歌山県在住。