iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0

上一篇我們實際做了一次 SQL Injection,也看到問題的核心:

使用者輸入的「資料」,被資料庫當成「語法」執行了。

今天就來看怎麼防。

方向其實很單純:

不要讓資料有機會變成語法。

具體有三個做法,而且要一起用。

方法一:參數化查詢

這是最關鍵的一個。

參數化查詢(Parameterized Query)的意思是:

SQL 語法和資料分開送,不要自己拼在一起。

Day 18 其實就已經這樣寫了:

cursor.execute("INSERT INTO tasks (name, completed) VALUES (?, ?)", ("洗碗", 0))

那個問號就是「這裡等一下會放一個值」的位置,
真正的值是透過第二個參數另外傳進去的。

把昨天的危險版本改成參數化,只改兩行:

def login(username, password):
    # 安全版本:參數化查詢
    sql = "SELECT * FROM user WHERE username = ? AND password = ?"

    print("實際執行的 SQL:")
    print(sql)

    result = cursor.execute(sql, (username, password)).fetchall()

    if result:
        print("登入成功:", result)
    else:
        print("登入失敗")
    print("-" * 50)

存成 injection_fixed.py,然後跑一次:

python injection_fixed.py

結果:

https://ithelp.ithome.com.tw/upload/images/20261012/20183865pwXaZlBLjL.png

改成參數化之後,同樣的攻擊字串變成登入失敗

同樣的輸入,這次沒有用了。

而且注意印出來的 SQL:不管使用者輸入什麼,它永遠都長一樣,問號不會變。

為什麼參數化有效?

因為送出去的東西變成兩份:

第一份是 SQL 語法:

SELECT * FROM user WHERE username = ? AND password = ?

第二份是資料:

('jolin', "' OR '1'='1")

資料庫會先看語法,決定「這句話要做什麼」,
然後才把值填進問號的位置。

這個時候值只是「一個字串」,不管裡面有單引號、有 OR、有分號,
都只會被當成一段普通的文字拿去比對。

所以 ' OR '1'='1 就真的變成在問:

有沒有一個使用者的密碼剛好是「' OR '1'='1」這串字?

答案當然是沒有,所以登入失敗。

拼接和參數化最大的差別就是:

拼接 → 資料有機會改變語法
參數化 → 語法先決定好,資料只能是資料

不同的資料庫套件寫法可能不太一樣(有的是 ?,有的是 %s,有的是 :name),
但概念都是同一個。

一個要注意的限制

參數化只能用在「值」,不能用在表名、欄位名。

例如這樣是不行的:

# 這樣不會動
sql = "SELECT * FROM ? WHERE id = ?"

如果真的需要讓使用者決定排序欄位之類的,正確做法是用白名單:


ALLOWED_SORT = {"id", "name", "completed"}

if sort_by not in ALLOWED_SORT:
    api.abort(400, "不支援的排序欄位")

sql = f"SELECT * FROM task ORDER BY {sort_by}"

重點是那個值一定要是「我們自己清單裡的」,而不是使用者說了算。

方法二:使用 ORM

我們從 Day 19 開始用的 SQLAlchemy,預設就會幫我們做參數化。

想確認的話,可以在設定裡加上 SQLALCHEMY_ECHO,把 SQLAlchemy 實際送出去的 SQL 印出來:

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tasks.db'
app.config['SQLALCHEMY_ECHO'] = True        # ← 加這行

db = SQLAlchemy(app)

位置要注意:一定要加在 db = SQLAlchemy(app) 的前面。

因為 SQLAlchemy(app) 那一行就會把設定讀走、把連線建好,寫在後面就來不及了(我兩種都試過,放後面完全沒作用)。

不想動到 app.py 的話,也可以另外寫一個小檔案 echo_test.py 來看:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tasks.db'
app.config['SQLALCHEMY_ECHO'] = True          # ← 要驗證的設定

db = SQLAlchemy(app)


class Task(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    completed = db.Column(db.Boolean, nullable=False, default=False)
    due_date = db.Column(db.String(20))
    priority = db.Column(db.Integer, nullable=False, default=0)


with app.app_context():
    db.session.scalars(db.select(Task).filter_by(name='洗碗')).all()

跑一次:

python echo_test.py

終端機會印出:

2026-10-12 01:14:53,346 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2026-10-12 01:14:53,348 INFO sqlalchemy.engine.Engine SELECT task.id, task.name, task.completed, task.due_date, task.priority
FROM task
WHERE task.name = ?
2026-10-12 01:14:53,348 INFO sqlalchemy.engine.Engine [generated in 0.00024s] ('洗碗',)
2026-10-12 01:14:53,350 INFO sqlalchemy.engine.Engine ROLLBACK

https://ithelp.ithome.com.tw/upload/images/20261012/20183865kWLrcMZmgX.png

開啟 SQLALCHEMY_ECHO 之後,終端機顯示的 SQL 用的是問號

重點在中間那三行:

WHERE 後面是一個問號 ?,不是「洗碗」這兩個字。
真正的值在下一行的 ('洗碗',) 單獨列出來。

也就是說,語法和資料是分開送給資料庫的 —— 這正是參數化查詢。

(頭尾那兩行 BEGIN (implicit) 和 ROLLBACK 可以不用管。
SQLAlchemy 查詢前會自動開一個交易,這個小程式只是查資料、沒有寫入,
結束的時候就把交易收掉了,不是出錯。)

看完記得把這行關掉或註解起來:

# app.config['SQLALCHEMY_ECHO'] = True     # 需要看 SQL 的時候再打開

不然之後每一個請求都會在終端機吐一大串 SQL,真的出錯的時候反而找不到錯誤訊息。

不過用 ORM 不等於一定安全

這點我實際跑了一次給自己看。

寫一個小程式 text_test.py,放在跟 app.py 同一個資料夾(這樣才找得到 instance/tasks.db):

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import text

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tasks.db'
db = SQLAlchemy(app)


class Task(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    completed = db.Column(db.Boolean, nullable=False, default=False)
    due_date = db.Column(db.String(20))
    priority = db.Column(db.Integer, nullable=False, default=0)


attack = "' OR '1'='1"

with app.app_context():
    total = len(db.session.scalars(db.select(Task)).all())
    print('資料表總共有', total, '筆')

    print('--- 1 ORM filter_by(安全)')
    rows = db.session.scalars(db.select(Task).filter_by(name=attack)).all()
    print('    查到', len(rows), '筆')

    print('--- 2 text() + f-string(危險)')
    rows = db.session.execute(text("SELECT * FROM task WHERE name = '%s'" % attack)).fetchall()
    print('    查到', len(rows), '筆  ← 整張表都被撈出來')

    print('--- 3 text() + 綁定參數(安全)')
    rows = db.session.execute(text("SELECT * FROM task WHERE name = :name"), {"name": attack}).fetchall()
    print('    查到', len(rows), '筆')

三種寫法的差別就在中間那三段:ORM 的 filter_by、text() 自己拼字串、text() 用綁定參數。

跑一次:

python text_test.py

結果:

資料表總共有 3 筆
--- 1 ORM filter_by(安全)
    查到 0 筆
--- 2 text() + f-string(危險)
    查到 3 筆  ← 整張表都被撈出來
--- 3 text() + 綁定參數(安全)
    查到 0 筆

https://ithelp.ithome.com.tw/upload/images/20261012/20183865po5LlyUJHF.png

第 2 種就是昨天說的「用了 ORM 還是會中」。

雖然是透過 SQLAlchemy 執行的,但因為我們自己把字串拼起來,
它就只是幫我們把那句話送給資料庫而已,該中的還是會中。

所以如果真的需要自己寫 SQL(有些複雜查詢用 ORM 反而難寫),
正確的寫法是第 3 種:用 :name 當參數位置,值另外用字典傳進去。

(筆數會跟你資料表裡有幾筆有關,重點是「安全的查到 0 筆、危險的把整張表都撈出來」。)

方法三:輸入驗證

Day 25 做的事情,在這裡也是一層防護。

例如:

name 必須是字串、不能超過 100 個字
completed 必須是布林值
id 一定是整數(我們用 <int:task_id>,非整數根本進不來)

這樣可以擋掉一部分奇怪的輸入。

但是要講清楚一個觀念:

輸入驗證是「加分」,不是「主要防線」。

真正的主要防線是參數化查詢。

因為驗證再嚴格,只要哪天有一個地方忘了驗證、或規則寫得不夠周全,
只要那裡是用字串拼 SQL,就會中。

反過來說,只要全部都用參數化,就算使用者輸入一整段 SQL,也只是一段文字而已。

順序是:

參數化查詢 → 一定要做
輸入驗證 → 再多一層保險

實際驗證我們自己的 API

來做一個有趣的測試。

拿昨天那種攻擊字串,直接打我們自己的 API(記得先 Authorize):

POST /tasks

{
  "name": "Robert'); DROP TABLE task;--",
  "completed": false
}

Execute。

結果是:新增成功,回傳 201。

https://ithelp.ithome.com.tw/upload/images/20261012/201838656dwbhnGI7R.png

用攻擊字串新增作業,回傳 201

然後用 GET /tasks 看,最後新增的那一筆長這樣:

{
  "id": 5,
  "name": "Robert'); DROP TABLE task;--",
  "completed": false,
  "due_date": null,
  "priority": 0
}

(截圖裡有兩筆一樣的名稱,是因為我前面測過一次,不影響結果。)

它就只是一筆「名字很奇怪」的作業而已。

https://ithelp.ithome.com.tw/upload/images/20261012/20183865LOxLhOCZFL.png

GET /tasks 顯示那串字被當成普通的名稱存起來

再用 check_db.py 確認資料表還在不在:

python check_db.py

=== 資料庫裡有哪些表 ===

[('alembic_version',), ('task',)]

=== task 表裡面的資料 ===

(2, '洗碗', 1)
(3, '寫 Day 26', 0)
(4, "Robert'); DROP TABLE task;--", 0)
(5, "Robert'); DROP TABLE task;--", 0)

資料表沒有被刪掉,資料都還在,那串字只是一個名稱

task 表還在,其他資料也都在。

這就是參數化查詢在保護我們,而我們其實什麼都沒特別做,
只是因為一直都用 ORM,沒有自己拼 SQL 字串。

其他也要注意的事

除了上面三個,還有幾個習慣:

資料庫帳號用最小權限
應用程式用的帳號,只給它真正需要的權限。
例如只需要讀寫資料,就不要給它刪除資料表的權限。
這樣就算真的被攻擊,損害也會小很多。

錯誤訊息不要外洩
Day 24 和 Day 27 都提過,把詳細錯誤留在伺服器端的 log。

套件要更新
資料庫驅動、ORM 都可能有漏洞,定期更新。

不要只靠前端
前端的檢查對攻擊者來說是不存在的(Day 25 已經示範過了)。

一個小檢查清單

寫完程式可以自己看一下:

有沒有地方用 f-string 或 + 把變數拼進 SQL?
有沒有用 text() 又把變數拼進去?
表名、欄位名有沒有讓使用者決定?如果有,是不是用白名單?
錯誤訊息會不會把資料庫的細節回傳給使用者?
輸入有沒有基本的驗證?

只要第一項和第二項都沒有,其實就已經避開九成的風險了。

今天整理一下防護的方式:

參數化查詢 → 語法和資料分開送,最重要的一招
ORM → 預設會幫我們參數化,但 text() 拼字串一樣會中(實測過了)
輸入驗證 → 第二層保險,不是主要防線
最小權限、不外洩錯誤訊息、定期更新 → 習慣問題

也實際驗證了我們的 API 現在可以擋住這種攻擊。

到這裡,錯誤處理和基本資安的部分就告一段落了。

前面 28 天,我們從「前端後端是什麼」一路走到 Flask、Flask-RESTX、資料庫、Migration、錯誤處理和資安。

下一篇就把這些全部串在一起,做出一個完整的 API。


上一篇
Day 27| SQL Injection 是什麼?
下一篇
Day 29|把全部串起來
系列文
從零開始的後端開發:用 Flask 實作 REST API,搞懂 API 與資料庫之間如何協作 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言