この記事は私のコースから特に需要が多い内容を抜き出して記事にしています。詳しく学びたい方はこちらからどうぞ。

「ChatGPTやClaudeにFlaskのアプリを頼んだら、ちゃんと動いた。でも、これをそのまま部署で使って大丈夫?」「どこを見れば安全かどうか分かるのか、そもそも分からない…」

そう感じたことはありませんか? AIは、動くコードをすばやく書いてくれます。でも「動く」と「安全」は別です。危ない書き方は、動かしてもエラーが出ないので、見た目では気づけません。

この記事では、AIが書いたFlaskのコードを受け取ったときに最初に見るべき5か所を、「危ない書き方」と「直した書き方」の両方を実際に動かして確かめます。

  1. SQLを f-string で組み立てていないか(SQLインジェクション)
  2. 入力した値を、そのままHTMLに入れていないか(XSS)
  3. POSTのフォームに、なりすまし送信の対策があるか(CSRF)
  4. パスワードをそのまま保存していないか
  5. エラーの中身を画面に出していないか(debug=True を含む)

対象は、Flaskで簡単なアプリを動かしたことがある方です。攻撃の手口を覚えるための記事ではなく、「どこを見て、どう直してもらえばいいか」を知るための記事です。試すときは、必ず自分のパソコンの中で、自分で作った練習用アプリだけにしてください。他人のWebサイトやサービスで試すと、不正アクセス禁止法などの法律に触れるおそれがあります。

動作確認は Python 3.13・Flask 3.1.3・Flask-WTF 1.3.0 で行っています。

題材:動くけれど危ない「メモ検索アプリ」

code review security
code review security / Photo by Jakub Zerdzicki via Pexels

まず、5つの危ない書き方をわざと集めた練習用のアプリです。メモを検索して、利用者を登録できる、ありがちな小さなアプリです。本番では絶対に使わないでください。

# 動くけれど危ない書き方を集めた練習用アプリ(本番では使わないこと)
import sqlite3

from flask import Flask, request

app = Flask(__name__)
app.secret_key = "abc123"

db = sqlite3.connect(":memory:", check_same_thread=False)
db.executescript("""
CREATE TABLE users (name TEXT, password TEXT);
INSERT INTO users VALUES ('sato', 'sato-pass-2026');
CREATE TABLE memos (owner TEXT, body TEXT);
INSERT INTO memos VALUES ('sato', '来週の会議資料');
INSERT INTO memos VALUES ('suzuki', '給与明細の保管場所');
""")


@app.route("/search")
def search():
    q = request.args.get("q", "")
    # ① 値を f-string で SQL に埋め込んでいる
    rows = db.execute(f"SELECT body FROM memos WHERE owner = 'sato' AND body LIKE '%{q}%'").fetchall()
    # ② 入力値をそのまま HTML に埋め込んでいる
    items = "".join(f"<li>{r[0]}</li>" for r in rows)
    return f"<h1>「{q}」の検索結果</h1><ul>{items}</ul>"


@app.route("/register", methods=["POST"])
def register():
    # ④ パスワードをそのまま保存している
    db.execute("INSERT INTO users VALUES (?, ?)", (request.form["name"], request.form["password"]))
    return "登録しました"


@app.route("/memos", methods=["POST"])
def add_memo():
    # ③ 誰が送ってきたフォームかを確かめていない(CSRF 対策なし)
    db.execute("INSERT INTO memos VALUES ('sato', ?)", (request.form["body"],))
    return "保存しました"


@app.route("/report")
def report():
    try:
        rows = db.execute("SELECT body, created_at FROM memos").fetchall()
        return {"count": len(rows)}
    except Exception as e:
        # ⑤ エラーの中身をそのまま画面に返している
        return f"エラーが発生しました: {e!r}", 500


if __name__ == "__main__":
    app.run(debug=True)

このアプリ、ふつうに使う分には何の問題もなく動きます。「会議」で検索すれば、佐藤さんの「来週の会議資料」が出てきます。だからこそ、危なさに気づきにくいんです。

ブラウザで1つずつ試す代わりに、Flaskの test_client(サーバーを起動せずに、Pythonの中からアプリにアクセスできる道具)でまとめて試します。

from bad_app import app, db

client = app.test_client()

print("① SQLインジェクション")
r = client.get("/search", query_string={"q": "会議"})
print("  ふつうの検索 :", r.get_data(as_text=True))
r = client.get("/search", query_string={"q": "' OR 1=1 --"})
print("  細工した検索 :", r.get_data(as_text=True))

print("② XSS")
r = client.get("/search", query_string={"q": "<script>alert(1)</script>"})
print("  返ってきたHTML:", r.get_data(as_text=True))

print("③ CSRF")
r = client.post("/memos", data={"body": "ほかのサイトから送られたメモ"})
print("  トークンなしのPOST:", r.status_code, r.get_data(as_text=True))

print("④ パスワード")
client.post("/register", data={"name": "tanaka", "password": "tanaka-pass-2026"})
print("  usersテーブル:", db.execute("SELECT * FROM users").fetchall())

print("⑤ エラーの中身")
r = client.get("/report")
print("  ", r.status_code, r.get_data(as_text=True))
危ない書き方のアプリで5つを試した結果
危ない書き方のアプリで5つを試した結果

実行結果は上の画像のとおりです。1つずつ見ていきましょう。

① SQLを f-string で組み立てていないか

検索の行を見てください。

rows = db.execute(f"SELECT body FROM memos WHERE owner = 'sato' AND body LIKE '%{q}%'").fetchall()

⚠️ ここから先の入力例は、自分のパソコンの練習用アプリで試すためのものです。他人のサイトで試すと法律に触れるおそれがあるので、絶対にしないでください。

このアプリは「佐藤さんのメモだけ」を検索するつもりで書かれています。ところが検索欄に ' OR 1=1 -- と入れると、結果に鈴木さんの「給与明細の保管場所」まで出てきました。

入力した文字が、そのまま SQL の一部になってしまったからです。' で文字列が閉じられ、OR 1=1(いつも成り立つ条件)が足され、-- で後ろが無視されました。これが SQLインジェクションです。

直し方は、値を ? の場所に、SQLとは別に渡すことです。

rows = db.execute("SELECT body FROM memos WHERE owner = 'sato' AND body LIKE ?",
                  (f"%{q}%",)).fetchall()

こうすると、入力はあくまで「探す文字」として扱われ、SQLの命令にはなりません。f-string を使っているのは % を付ける部分だけで、SQL の文には入れていないところがポイントです。

👀 見るところ:execute( の中に f" や + や .format( があったら要注意。

② 入力した値を、そのままHTMLに入れていないか

検索結果の見出しは、f-string でHTMLを組み立てています。検索欄に <script>alert(1)</script> と入れると、返ってきたHTMLに <script> がそのまま入っていました。ブラウザで開けば、このスクリプトが実行されます。

これが XSS(クロスサイトスクリプティング)です。alert なら警告が出るだけですが、本物の攻撃では、ログイン中の人の画面で好きな処理を動かされてしまいます。細工したURLを送りつけて開かせる、という手口がよく知られています。

直し方は、HTMLはテンプレートで組み立てて、値は {{ }} で入れることです。Flaskのテンプレート(Jinja2)は、< や > を無害な文字に自動で置き換えてくれます。

👀 見るところ:HTMLを f-string や + で作っていないか。テンプレートの中で、入力した値に |safe を付けていないか(|safe は自動の置き換えを止める指定です)。

③ POSTのフォームに、なりすまし送信の対策があるか

/memos へのPOSTは、どこから送られてきたフォームなのかを確かめていません。結果は「200 保存しました」。つまり、ほかのサイトに仕込まれたフォームからでも、ログイン中の人になりすましてメモを保存できてしまいます。これが CSRF(クロスサイトリクエストフォージェリ)です。

Flask本体にはCSRFの対策が入っていないので、Flask-WTF という拡張機能を使うのが手軽です。

pip install flask-wtf

アプリに CSRFProtect(app) を1行足すと、POSTのたびに「合言葉(トークン)」を確かめるようになります。HTMLのフォームには、その合言葉を入れる1行を足します。

<form method="post" action="/memos">
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  <input name="body">
  <button>保存</button>
</form>

👀 見るところ:method="post" のフォームに csrf_token があるか。設定に WTF_CSRF_ENABLED = False が残っていないか(テストのときだけ切ることはあります)。

④ パスワードをそのまま保存していないか

登録のあとで users テーブルを見ると、'tanaka-pass-2026' のようにパスワードがそのまま入っていました。データベースのファイルが1つ漏れただけで、全員のパスワードが読まれてしまいます。同じパスワードをほかのサービスでも使っている人がいれば、被害はそこにも広がります。

直し方は、Flaskと一緒に入っている Werkzeug の generate_password_hash でハッシュ(元に戻せない変換)にしてから保存し、ログインのときは check_password_hash で照合することです。

👀 見るところ:INSERT にパスワードがそのまま渡っていないか。ログインで == を使ってパスワードを比べていないか。

⑤ エラーの中身を画面に出していないか

/report は、存在しない列 created_at を読もうとして失敗し、OperationalError('no such column: created_at') を画面にそのまま返しています。テーブルや列の名前は、攻撃する側にとって大事なヒントです。

もっと危ないのが、最後の行の app.run(debug=True) です。デバッグモードで動かすと、エラーのときにこんな画面が出ます。

debug=True のときのエラー画面。コードの中身がそのまま見える(ファイルの場所は表示用に短くし、Flask内部の行は省いています)
debug=True のときのエラー画面。コードの中身がそのまま見える(ファイルの場所は表示用に短くし、Flask内部の行は省いています)

SQLを組み立てている行が、まるごと見えてしまっていますね。しかもこの画面には、ブラウザからパソコンの中でPythonを動かせる「コンソール」が付いています(PIN番号の鍵はかかっていますが、ほかの人の目に触れる場所で動かす理由はありません)。デバッグモードは、自分1人で開発しているときだけの機能です。

直し方は2つです。

  • 起動に debug=True・--debug・FLASK_DEBUG=1 を使わない
  • エラーの中身はログ(ターミナル)にだけ残し、画面には「エラーが起きました」とだけ出す(@app.errorhandler(500))

ここまでの5か所は、筆者のFlaskの講座で、章ごとの「AIに書かせたらここを見る」として取り上げ、最後のレッスンでチェックリストにまとめているものです。講座では読書記録アプリを作りながら、それぞれ「なぜダメか」を自分の手で確かめてから直します。FastAPIの講座にも、APIの場合のチェックリストがあります。

→ 「作って学ぶFlask・FastAPI入門」の紹介を見る

5か所を直したアプリで、もう一度試す

5か所を直したのがこちらです。秘密の鍵(SECRET_KEY)も、コードに書かずに環境変数で渡すようにしました。

import os
import sqlite3

from flask import Flask, render_template_string, request
from flask_wtf.csrf import CSRFProtect
from werkzeug.security import check_password_hash, generate_password_hash

app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]  # 鍵はコードに書かず、環境変数で渡す
csrf = CSRFProtect(app)  # ③ POST のたびにトークンを確かめる

db = sqlite3.connect(":memory:", check_same_thread=False)
db.executescript("""
CREATE TABLE users (name TEXT, password TEXT);
CREATE TABLE memos (owner TEXT, body TEXT);
INSERT INTO memos VALUES ('sato', '来週の会議資料');
INSERT INTO memos VALUES ('suzuki', '給与明細の保管場所');
""")

RESULT = """<h1>「{{ q }}」の検索結果</h1>
<ul>{% for row in rows %}<li>{{ row[0] }}</li>{% endfor %}</ul>"""


@app.route("/search")
def search():
    q = request.args.get("q", "")
    # ① 値は ? の場所に、SQL とは別に渡す
    rows = db.execute("SELECT body FROM memos WHERE owner = 'sato' AND body LIKE ?",
                      (f"%{q}%",)).fetchall()
    # ② テンプレートの {{ }} が < > を無害な文字に置き換える
    return render_template_string(RESULT, q=q, rows=rows)


@app.route("/register", methods=["POST"])
def register():
    # ④ パスワードはハッシュにしてから保存する
    hashed = generate_password_hash(request.form["password"])
    db.execute("INSERT INTO users VALUES (?, ?)", (request.form["name"], hashed))
    return "登録しました"


@app.route("/memos", methods=["POST"])
def add_memo():
    db.execute("INSERT INTO memos VALUES ('sato', ?)", (request.form["body"],))
    return "保存しました"


@app.route("/report")
def report():
    rows = db.execute("SELECT body, created_at FROM memos").fetchall()
    return {"count": len(rows)}


@app.errorhandler(500)
def server_error(e):
    # ⑤ 中身はログ(ターミナル)にだけ残し、画面には出さない
    return "すみません、エラーが起きました。時間をおいてお試しください。", 500

同じ5つを試します(④の確認のときだけ、トークンの確認を一時的に外しています)。

import os
os.environ.setdefault("FLASK_SECRET_KEY", "test-only-key")
from werkzeug.security import check_password_hash
from good_app import app, db

client = app.test_client()

print("① SQLインジェクション")
r = client.get("/search", query_string={"q": "' OR 1=1 --"})
print("  細工した検索 :", r.get_data(as_text=True))

print("② XSS")
r = client.get("/search", query_string={"q": "<script>alert(1)</script>"})
print("  返ってきたHTML:", r.get_data(as_text=True))

print("③ CSRF")
r = client.post("/memos", data={"body": "ほかのサイトから送られたメモ"})
print("  トークンなしのPOST:", r.status_code, r.get_data(as_text=True).split("<p>")[-1].split("</p>")[0])

print("④ パスワード")
app.config["WTF_CSRF_ENABLED"] = False  # ここだけ確認用にトークン確認を外す
client.post("/register", data={"name": "tanaka", "password": "tanaka-pass-2026"})
name, saved = db.execute("SELECT * FROM users").fetchone()
print("  保存された値:", saved[:60] + "...")
print("  照合(正しいパスワード):", check_password_hash(saved, "tanaka-pass-2026"))
print("  照合(違うパスワード)  :", check_password_hash(saved, "tanaka-pass-2025"))

print("⑤ エラーの中身")
r = client.get("/report")
print("  ", r.status_code, r.get_data(as_text=True))
5か所を直したアプリで同じ5つを試した結果
5か所を直したアプリで同じ5つを試した結果

結果は上の画像のとおり、5つとも変わりました。

直す前 直したあと
① SQLインジェクション 他人のメモまで出た 何も出ない(ただの文字として検索された)
② XSS <script> がそのまま &lt;script&gt; に置き換わった
③ CSRF 200 保存しました 400 The CSRF token is missing.
④ パスワード そのまま保存 scrypt:32768:8:1$… というハッシュ。照合は正しいものだけ True
⑤ エラー表示 エラーの中身を表示 「すみません、エラーが起きました。」だけ。中身はターミナルのログに出る

⑤のエラーの中身(no such column: created_at の Traceback)は、実行したターミナルの方にきちんと出ていました。作った人には見えて、使う人には見えない。これが正しい形です。

AIに直してもらうときの頼み方

5か所のどれかに当てはまったら、AIに「安全にして」とだけ頼むより、直してほしい所を具体的に伝えるほうが確実です。

  • 「search 関数の SQL を、f-string ではなく ? のプレースホルダで値を渡す形に直して」
  • 「HTML を f-string で作っている所を、render_template とテンプレートの {{ }} に直して」
  • 「Flask-WTF の CSRFProtect を入れて、POST のフォームすべてに csrf_token の hidden を入れて」
  • 「パスワードは generate_password_hash で保存して、ログインは check_password_hash で照合して」
  • 「debug=True を外して、500 エラーは errorhandler で決まった文だけを返す形にして」

直ってきたら、この記事の try_good.py のように自分でもう一度試すところまでがセットです。AIの「直しました」は、確かめるまでは「直したつもり」かもしれませんよ。

⚠️ なお、この5か所で全部ではありません。ログインが必要なアプリなら「他人のデータを開けないか」(URLの番号を変えただけで他人の記録が見えないか)も、AIのコードでよく抜ける所です。

まとめ

見る所 危ない形 直した形
① SQL f"... '{q}'" で値を埋め込む ? で値を別に渡す
② HTML f-string でHTMLを作る・|safe テンプレートの {{ }}
③ CSRF POSTのフォームに対策なし Flask-WTF の CSRFProtect と csrf_token
④ パスワード そのまま保存・== で比較 generate_password_hash/check_password_hash
⑤ エラー debug=True・例外の文を画面に返す debugなし・errorhandler で決まった文だけ

どれも、動かしてもエラーにならないので、知っていて目で確かめるしかない所です。逆に言えば、見る場所さえ分かっていれば、AIのコードのこの5か所は数分で確かめられます。ぜひ、手元のコードで5か所をチェックしてみてください!🚀

\ Webアプリを一から作ってみたい方へ /

筆者は、Pythonの入門を終えた人向けに、Webアプリを作りながら学ぶ講座を2つ公開しています。「作って学ぶFlask Webアプリ入門」では読書記録アプリを、ログイン・データベース・テストまで。「作って学ぶFastAPI入門」では単語帳のAPIを作ります。どちらも、同じネットワークのスマホから使う方法と、AIが書いたコードを見直すチェックリストまで扱います。

→ 「作って学ぶFlask・FastAPI入門」の紹介を見る

Pythonの文法に不安がある方は、先に無料の入門コース「作って学ぶPythonプログラミング入門」(全7章52レッスン)からどうぞ。
→ 無料の入門コースを見る

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る
徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

もしも

徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

電子工作とプログラミングを同時に学べる

Amazonで見る
実践Claude Code入門―現場で活用するためのAIコーディングの思考法

もしも

実践Claude Code入門―現場で活用するためのAIコーディングの思考法

AIコーディングの現場活用法を学ぶ一冊

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。