AIが書いたFlaskのコードで最初に見る5か所|SQLインジェクション・XSS・CSRFを実際に試す
この記事は私のコースから特に需要が多い内容を抜き出して記事にしています。詳しく学びたい方はこちらからどうぞ。
「ChatGPTやClaudeにFlaskのアプリを頼んだら、ちゃんと動いた。でも、これをそのまま部署で使って大丈夫?」「どこを見れば安全かどうか分かるのか、そもそも分からない…」
そう感じたことはありませんか? AIは、動くコードをすばやく書いてくれます。でも「動く」と「安全」は別です。危ない書き方は、動かしてもエラーが出ないので、見た目では気づけません。
この記事では、AIが書いたFlaskのコードを受け取ったときに最初に見るべき5か所を、「危ない書き方」と「直した書き方」の両方を実際に動かして確かめます。
- SQLを f-string で組み立てていないか(SQLインジェクション)
- 入力した値を、そのままHTMLに入れていないか(XSS)
- POSTのフォームに、なりすまし送信の対策があるか(CSRF)
- パスワードをそのまま保存していないか
- エラーの中身を画面に出していないか(debug=True を含む)
対象は、Flaskで簡単なアプリを動かしたことがある方です。攻撃の手口を覚えるための記事ではなく、「どこを見て、どう直してもらえばいいか」を知るための記事です。試すときは、必ず自分のパソコンの中で、自分で作った練習用アプリだけにしてください。他人のWebサイトやサービスで試すと、不正アクセス禁止法などの法律に触れるおそれがあります。
動作確認は Python 3.13・Flask 3.1.3・Flask-WTF 1.3.0 で行っています。
題材:動くけれど危ない「メモ検索アプリ」

まず、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))

実行結果は上の画像のとおりです。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) です。デバッグモードで動かすと、エラーのときにこんな画面が出ます。

SQLを組み立てている行が、まるごと見えてしまっていますね。しかもこの画面には、ブラウザからパソコンの中でPythonを動かせる「コンソール」が付いています(PIN番号の鍵はかかっていますが、ほかの人の目に触れる場所で動かす理由はありません)。デバッグモードは、自分1人で開発しているときだけの機能です。
直し方は2つです。
- 起動に
debug=True・--debug・FLASK_DEBUG=1を使わない - エラーの中身はログ(ターミナル)にだけ残し、画面には「エラーが起きました」とだけ出す(
@app.errorhandler(500))
ここまでの5か所は、筆者のFlaskの講座で、章ごとの「AIに書かせたらここを見る」として取り上げ、最後のレッスンでチェックリストにまとめているものです。講座では読書記録アプリを作りながら、それぞれ「なぜダメか」を自分の手で確かめてから直します。FastAPIの講座にも、APIの場合のチェックリストがあります。
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つとも変わりました。
| 直す前 | 直したあと | |
|---|---|---|
| ① SQLインジェクション | 他人のメモまで出た | 何も出ない(ただの文字として検索された) |
| ② XSS | <script> がそのまま |
<script> に置き換わった |
| ③ 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レッスン)からどうぞ。
→ 無料の入門コースを見る
こちらも読まれています
📚 関連商品・おすすめ書籍
※本記事にはアフィリエイトリンクが含まれます。




