🗺️ 程式互動式地圖
資安線・安全編碼

SQL 注入與參數化查詢:把資料和指令分開

用 f-string 拼出來的 SQL,平常測試都正常,直到輸入裡出現一個單引號。這一頁說明輸入為什麼會變成 SQL 語法、參數化查詢怎麼讓它只能當資料,以及欄位名、ORDER BY 這些參數化不到的地方該怎麼補、怎麼測試自己的程式。

組字串觀察器 15 張安全寫法判斷卡 縱深防禦配對

💡 先搞懂問題

虛構的「晴空咖啡」有一個會員後台,客服可以輸入帳號查會員資料。工程師寫得很快:sql = f"SELECT id, username, role FROM members WHERE username = '{name}'",再交給 conn.execute(sql)。測 amy、bob 都查得到,測一個不存在的帳號也正確地回傳空結果,於是就上線了。

幾天後客服回報:查一位叫 O'Brien 的會員,頁面直接出錯,畫面上還印出一串 sqlite3.OperationalError: near "Brien": syntax error。O'Brien 是再正常不過的姓氏,問題在於名字裡的單引號,剛好和 SQL 字串的引號是同一個字元。資料庫讀到 'O' 就以為字串結束了,接下來的 Brien 被當成 SQL 語法去解析,當然看不懂。同一個缺口,如果輸入是刻意設計過的,就不只是出錯,而是 WHERE 條件被改變,查詢回傳的筆數和程式原本的設計不同。這就是 SQL 注入(SQL injection):使用者提供的資料,被資料庫當成 SQL 指令的一部分執行。

新手最常卡在三個想法。第一是「我有加引號,所以它是字串」:引號是你寫在 SQL 文字裡的,使用者也能輸入引號把它提早關掉。第二是「我有檢查長度、只有內部人員會用」:一個姓氏就足以弄壞它,惡意輸入也不需要很長。第三是「要把特殊字元都過濾掉」:過濾清單永遠列不完,而且 O'Brien 這種合法資料本來就含有引號,不能一律刪掉。真正的成因只有一個:SQL 語法和資料被混在同一個字串裡送給資料庫,資料庫的解析器只看到一整段文字,分不出哪幾個字是你寫的、哪幾個字是使用者給的。

解法是參數化查詢(parameterized query,也常叫 prepared statement):SQL 文字裡只放一個佔位符(placeholder),例如 sqlite3 的 ?,值放在另一個參數裡交給驅動程式(driver)。資料庫先把 SQL 範本解析成固定的結構,值之後才被填進那個位置,不論內容是什麼,它都只會被拿去比對,不會再被當成語法讀一次。

拼接:輸入和 SQL 混成同一段文字 WHERE username = '{name}' name = ' OR '1'='1 WHERE username = '' OR '1'='1' ← 變成條件 查詢條件被改變 → 回傳 5 列(示意) 資料庫只看到一整段文字, 分不出哪幾個字是使用者給的 參數化:SQL 範本和值分開送 WHERE username = ? ("' OR '1'='1",) ① 先解析語法 ② 值填進 ? ③ 執行比對 帳號剛好等於這串字? 沒有這個帳號 → 0 列(示意) 值進來的時候,語法已經定好, 引號只是資料裡的一個字元
同一串輸入,兩種送法。上半的拼接寫法在 Python 端就把輸入接進 SQL 文字,資料庫解析時 OR 之後的部分變成了條件;下半的參數化寫法讓資料庫先確定語法結構,值最後才填進 ? 的位置,只會被拿來和 username 欄位比對。筆數依本頁實驗室的 5 筆虛構資料。

生活比喻:銀行臨櫃的存款單

到銀行臨櫃存錢,行員會給你一張印好欄位的存款單:「帳號」一格、「金額」一格。你在帳號那格寫什麼,行員就拿去系統裡查那個帳號;就算你在格子裡寫「0012-345,另外再幫我加辦一件事」,行員也只會把這整串當成一個帳號去查,查不到就退件。格子的位置和用途是銀行事先印好的,你能決定的只有格子裡的內容。

危險的做法,是讓客戶拿一張白紙自己寫整段指示,行員照著念、照著做。大部分客戶只會寫「請存 5,000 元到 0012-345」,看起來和存款單沒有差別;但只要有人在句子後面多加一句,行員就會連同那一句一起執行。更麻煩的是,連善意的客戶也會出事:有人在白紙上寫了一個行員看不懂的符號,整張單子就辦不下去,這正是 O'Brien 遇到的狀況。

回到程式:剛才的存款單對應的就是參數化查詢。"... WHERE username = ?" 是事先印好的單子,? 是那個「帳號」格子,(name,) 是客戶填進格子的內容;資料庫像行員一樣,只會把格子裡的東西拿去比對 username 欄位。f-string 拼接則是那張白紙:使用者的字和你寫的 SQL 混成同一段指示,資料庫從頭到尾照語法讀一遍,讀到什麼就做什麼。
印好欄位的存款單 存款單 帳號 0012-345,另外加辦一件事 金額 5,000 整格當帳號查 → 查無此帳號 一張白紙寫整段指示 請存 5,000 元到 0012-345, 另外再加辦一件 單子上沒有的事。 照著念、照著做 印好的欄位 格子裡寫的字 白紙上的整段指示 SQL 裡的 ? 佔位符 另外傳的參數 (name,) f-string 拼出來的整段 SQL
上排是臨櫃的兩種做法,下排是對應的程式寫法。存款單的格子位置是事先印好的,客戶只能決定格子裡的內容;白紙則讓客戶的字和行員要做的事混在一起。

這個比喻有兩個地方要小心。第一,真正的行員看到奇怪的備註會起疑、會打電話問主管,資料庫不會,它只照語法執行,所以「應該不會有人這樣輸入吧」不能當成防線。第二,存款單只解決了「值」的問題;如果連「要查哪一張表、依哪個欄位排序」都讓客戶決定,就像讓客戶自己決定單子上要印哪些欄位,這時佔位符幫不上忙,要改用白名單,下面原理補完會說明。

🎮 互動實驗室一:組字串觀察器

按下方三個輸入之一,看同一個值經過兩種寫法會送出什麼給資料庫。左邊是字串拼接實際組出來的 SQL,輸入的每個字都被標色:綠底代表它還在引號裡、只是資料,紅底代表它落在引號外、被資料庫當成 SQL 語法。右邊是參數化寫法送出的兩樣東西:固定不變的 SQL 範本,以及另外傳的參數值。最下面的 5 筆虛構會員表會標出兩種寫法各回傳哪幾列,結果都和 Python 3 的 sqlite3 實際執行一致。

❌ 字串拼接(f-string)

sql = f"SELECT id, username, role FROM members WHERE username = '{name}'"
rows = conn.execute(sql).fetchall()
資料庫實際收到的 SQL

✅ 參數化查詢(? 佔位符)

sql = "SELECT id, username, role FROM members WHERE username = ?"
rows = conn.execute(sql, (name,)).fetchall()
送出的 SQL 範本(永遠不變)
SELECT id, username, role FROM members WHERE username = ?
另外傳的參數(Python 的寫法)
留在引號內:資料落在引號外:被當成語法佔位符
idusernamerole拼接回傳參數化回傳
畫面說明:載入中。

🎮 互動實驗室二:安全寫法判斷卡

每張卡是一段讀取或寫入資料庫的程式,conn 連到上面那張 5 筆的 members 表,name、kw、form[...] 都來自使用者。請判斷它屬於哪一種:安全、有注入風險,或者「不是注入,但會出錯或結果不對」。判斷時只問一件事:使用者的輸入最後是變成 SQL 文字的一部分,還是經由佔位符當成值送出?答完會說明原因,並附上實際執行的結果。

答對 0 / 0
連續答對 0
畫面說明:載入中。

🎮 互動實驗室三:縱深防禦配對

參數化查詢是根本修正,但一套系統不會只靠一道防線。下面 8 張卡是晴空咖啡開發團隊在程式碼審查時記下的狀況,下方有 10 個防禦做法,其中 2 個是常見但不可靠的做法。先點一張狀況卡,再點你認為最適合的防禦做法;也可以先點防禦做法再點卡片,電腦上可以直接把防禦做法拖到卡片上。配對成功的卡片會標出它屬於哪一層:根本修正、限制損害,或偵測與預防回歸。

已配對 0 / 8
一次就對 0
配錯次數 0
畫面說明:載入中。

📘 原理補完

實驗室裡的直覺可以濃縮成一句話:使用者的輸入只要變成 SQL 文字的一部分,就有機會被當成語法;只要它經由佔位符當成值送出,就只會是資料。下面把這句話接回正式用語,再補上參數化管不到的地方,以及上線前怎麼檢查自己的程式。

1. 正式說法:為什麼會被當成語法

資料庫收到 SQL 時,第一步是詞法與語法分析(parsing):把文字切成關鍵字、欄位名、運算子、字串常值,再組成查詢的結構。字串常值靠單引號標出開頭和結尾,所以只要資料裡有單引號,而它又是被直接接進 SQL 文字,引號就會改變「字串到哪裡結束」,後面的字也就換了身分。OWASP Top 10:2025 把這類問題歸在 A05 Injection,對應的弱點編號是 CWE-89。Python 官方 sqlite3 文件也直接提醒:不要用 Python 的字串操作組查詢,要用 DB-API 的參數替換。

拼接:資料庫怎麼切開 username = 'O'Brien' WHERE username = 'O' Brien ' 程式寫好的語法 字串只到 O 就結束 被當成語法,看不懂 沒有結尾的引號 OperationalError: near "Brien": syntax error 參數化:範本和值各走各的 WHERE username = ? ("O'Brien",) 整串 O'Brien 是一個值 和 username 比對 → 找到 id 3(示意資料)
上半是拼接寫法,名字裡的單引號讓字串提早結束,資料庫只能回報語法錯誤;這是合法輸入造成的失敗,和資安無關也一樣要修。下半是參數化寫法,值根本不經過語法分析,引號只是資料裡的一個字元。

同樣的機制換成教科書示範輸入時,結果不是出錯,而是查詢條件被改變。下圖把拼接後的 WHERE 條件拆開來看:原本只有「username 等於某個值」一個比較,現在多了一個 OR,而 OR 右邊是一個對每一列都成立的比較,所以回傳筆數和原本的設計不同。

WHERE username = '' OR '1'='1' OR username = '' '1' = '1' 帳號(示意)左邊比較右邊比較整個條件 amy不成立成立成立 bob不成立成立成立 O'Brien不成立成立成立 chen不成立成立成立 dora不成立成立成立 拼接:回傳 5 列 參數化:條件不變,回傳 0 列
拼接後的條件多了一個 OR,右邊的比較和資料無關、每一列都成立,於是整個條件對每一列都成立。參數化時同一串字只是 username 要比對的值,條件結構維持程式原本寫的樣子。筆數依本頁 5 筆虛構資料。

2. 參數化為什麼有效

參數化查詢把「決定查詢結構」和「提供資料」拆成兩個步驟。SQL 範本先被解析,結構在這時就固定了:要查哪張表、比對哪個欄位、條件怎麼組合。值是之後才綁定(bind)到佔位符的位置,它不會再經過一次語法分析,所以裡面有沒有引號、有沒有 OR 都不影響結構。這也是它比「把危險字元過濾掉」可靠的原因:你不需要預測使用者會輸入什麼,因為不論輸入什麼,它都沒有機會變成語法。

① 送出範本含 ? 的 SQL ② 解析語法結構在這裡固定 ③ 綁定值值填進 ? 的位置 ④ 執行只做比對與讀寫 值進來時,語法分析已經結束 誰負責把值和語法分開? sqlite3:SQLite 函式庫在同一個程式裡先編譯範本、再綁定值 psycopg 3:預設把 SQL 與值分開送到 PostgreSQL 伺服器(伺服器端綁定) PyMySQL:由驅動程式依型別正確跳脫後組合,同樣不是你自己組
不同驅動程式實作細節不同,但對寫程式的人來說規則一樣:把值交給佔位符,讓驅動程式或資料庫負責分開處理,自己不要先把值併進 SQL 文字。

每個驅動程式支援的佔位符寫法叫 paramstyle,由 Python 的 DB-API 規範(PEP 249)定義。下表是 AI 與資料分析最常碰到的幾種:

套件paramstyle佔位符寫法值怎麼傳容易寫錯的地方
sqlite3(標準函式庫)qmark(也支援 named)?、:name(name,) 或 {"name": v}單一值要寫 (name,);Python 3.14 起具名佔位符搭配 tuple 會丟 ProgrammingError
psycopg(PostgreSQL)pyformat%s、%(name)scur.execute(sql, (v,))不分型別都寫 %s,不要再加引號,也不是 Python 的 % 格式化
PyMySQL(MySQL)pyformat%s、%(name)scur.execute(sql, (v,))同上;寫成 sql % v 就變回拼接
SQLAlchemy text()由 SQLAlchemy 轉換:nameconn.execute(text(sql), {"name": v})在 text() 裡用 f-string 一樣會出事
pandas read_sql沿用底層驅動程式依連線而定params=(v,) 或 dict佔位符要配合底層驅動程式,sqlite3 連線用 ?

3. 參數化做不到的地方:識別字與排序

佔位符只能代入值,也就是會出現在比較、寫入或 LIMIT 裡的資料。表名、欄位名這類識別字(identifier),以及 ORDER BY 的欄位與 ASC/DESC,屬於查詢結構本身,不能用佔位符。實驗室二的兩張卡示範了後果:ORDER BY ? 不會出錯,但它是依一個固定字串排序,等於沒有排序;FROM ? 則直接是語法錯誤。這時的正確做法是白名單對照(allowlist):使用者只能從選項裡挑,程式用字典把選項對應到寫死的 SQL 片段,不在清單裡就用預設值。使用者的輸入只拿來「查表」,從頭到尾沒有進入 SQL 文字。PostgreSQL 的 psycopg 另外提供 psycopg.sql.Identifier,可以安全地組出識別字,但「哪些欄位允許排序」仍然應該由白名單決定。

SELECT 欄位 FROM 表名 WHERE role = 值 ORDER BY 欄位 ASC/DESC LIMIT 值 可以用佔位符 不行:用白名單 白名單對照:輸入只用來查表 "newest" SORT 字典(程式寫死) "name" → username "newest" → id DESC ORDER BY id DESC 不在清單內的選項(例如 "role")→ 一律用預設的 username,不會原樣接進 SQL
綠色位置交給佔位符,橘色位置屬於查詢結構,只能從程式事先寫好的選項中挑。白名單的值是程式自己的字串,所以用 f-string 接進去是安全的,但要在旁邊註明理由,方便審查與靜態分析的人確認。

LIKE 搜尋是另一個常見盲點。"... LIKE ?", (f"%{kw}%",) 沒有注入問題,因為 f-string 組的是參數值,整串仍經由佔位符送出;但使用者輸入的 % 和 _ 在 LIKE 裡是萬用字元,搜尋「%」會比對到每一列。若希望它們被當成普通字元,就在程式裡先把它們跳脫,並在 SQL 加上 ESCAPE '\',範例二會示範。這屬於正確性與效能問題,不是注入,但也會讓搜尋結果和使用者預期不同。另外 SQLite 的 LIKE 預設不分 ASCII 大小寫,其他資料庫依定序(collation)而定。

4. ORM 也不是自動安全

ORM(Object-Relational Mapping,把資料表對應成 Python 類別的工具)和 SQLAlchemy Core 在你用它們的查詢 API 時,會自動把值綁定成參數,例如 select(Member).where(Member.username == name)。風險出現在「回到手寫 SQL」的那幾個入口:SQLAlchemy 的 text()、Django 的 raw()、pandas 的 read_sql。這些函式都支援參數(text() 用 :name,raw(sql, params),read_sql(..., params=...)),但它們不會檢查你傳進來的字串是怎麼組成的。text(f"... '{name}'") 在交給 text() 之前就已經拼好了,SQLAlchemy 看到的只是一段普通的 SQL 文字。審查程式時,與其問「有沒有用 ORM」,不如問「SQL 文字裡有沒有使用者的字」。

5. 縱深防禦:最小權限與錯誤訊息處理

參數化與白名單是根本修正,但大型系統裡總有漏網之魚:舊程式、別人寫的模組、新人一時順手。縱深防禦(defense in depth)的意思是再加幾層,讓萬一有一處沒寫好時損害有限、而且會被發現。

第一層是最小權限(least privilege):網站連資料庫用專屬帳號,只拿到它真正需要的權限,例如對 members 表只有 SELECT、INSERT、UPDATE,不使用資料庫管理員帳號,也不讓它成為資料表的擁有者;報表功能另開唯讀帳號。第二層是輸入驗證:會員編號就只收 1~6 位數字,格式不對直接拒絕;它擋得住大部分格式錯誤,但不能取代參數化,因為合法資料(像 O'Brien)本來就可能含有引號。第三層是錯誤訊息不外洩:查詢失敗時,完整的錯誤與 SQL 只寫進伺服器日誌,畫面上給通用訊息。實驗室一裡 O'Brien 造成的錯誤訊息若直接顯示在網頁上,等於把查詢的寫法告訴所有人;OWASP Top 10:2025 也把例外狀況處理不當列為 A10。最外層是日誌與告警:短時間內大量查詢失敗,通常代表程式有錯或有人在試探,兩種情況都值得有人去看。

外層:偵測與預防回歸 錯誤訊息不外洩、日誌與告警、Bandit B608、單元測試 中層:限制損害 最小權限資料庫帳號、輸入格式驗證(白名單規則) 核心:根本修正 值一律用佔位符(參數化查詢) 識別字與排序一律用白名單對照
三層的角色不同:核心讓輸入沒有機會變成語法;中層讓萬一出事時能做的事有限;外層讓錯誤被看見,並防止修好的程式日後又被改壞。只有外層而沒有核心,是在等事情發生。

6. 自我檢查:Bandit B608 與測試

人工審查很難每一行都注意到。靜態分析(SAST,不執行程式、直接讀原始碼)可以在每次提交時自動找出「用字串組 SQL」的寫法。Python 專用的 Bandit 有一條規則 B608 hardcoded_sql_expressions,用 f-string、%、.format() 或 + 組出看起來像 SQL 的字串時就會標出來,並對應 CWE-89。下面是用 Bandit 1.9.4 掃描一段示範程式的實際輸出(節錄):

pip install bandit
bandit -r src -ll        # 掃描 src 目錄,只列出中、高嚴重度
# >> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector
#    through string-based query construction.
#    Severity: Medium   Confidence: Low
#    CWE: CWE-89
#    Location: ./app_bad.py:4:10

Bandit 是比對寫法,不懂語意,所以白名單版本的 f"... ORDER BY {order}" 也會被標出來(誤報,false positive)。確認安全後,可以在那一行加上 # nosec B608 並寫明理由,讓之後的審查者知道這是看過的決定,而不是隨手關掉警告;反過來說,零警告也不代表沒有問題,它看不到存取控制這類邏輯錯誤。第二個工具是單元測試:把 O'Brien 這種含引號的正常資料,以及教科書示範輸入寫進測試,斷言回傳的筆數與內容。只要以後有人把查詢改回拼接,測試會立刻失敗。範例一最後兩行就是這種測試。

寫程式查詢用佔位符 Bandit找出 B608 單元測試含引號的輸入 程式審查看 nosec 理由 上線日誌與告警 任一關失敗:擋下合併 測試用的輸入(示意) "amy" → 1 列 "O'Brien" → 1 列 "' OR '1'='1" → 0 列
把檢查放進提交流程,比上線前一次大掃描便宜得多。Bandit 找寫法,測試驗結果,審查看理由,上線後的日誌負責發現前面都沒擋到的情況。

寫法比較與判斷步驟

寫法例子使用者的字進到 SQL 文字了嗎?判斷
f-stringf"... = '{name}'"是,在 Python 端就接好❌ 有注入風險
% 格式化"... = '%s'" % name是,和 psycopg 的 %s 長得像但意義不同❌ 有注入風險
.format()、+"... = '{}'".format(name)是❌ 有注入風險
sqlite3 佔位符"... = ?", (name,)否,值另外傳✅ 安全
psycopg/PyMySQL 佔位符"... = %s", (name,)否,由驅動程式處理✅ 安全
SQLAlchemy 綁定參數text("... = :u"), {"u": name}否✅ 安全
白名單接識別字SORT.get(sort, "username")否,接進去的是程式寫死的字串✅ 安全(加註理由)
佔位符放在識別字位置ORDER BY ?、FROM ?否⚠️ 不是注入,但排序無效或語法錯誤
  1. 先找出這個函式裡所有來自外部的值:表單、網址參數、API、檔案,也包括 LLM 的輸出。
  2. 追它流到哪裡:只要最後會組成 SQL 文字,就看它是怎麼進去的。
  3. 是比較或寫入的值:必須經由佔位符或 ORM 的查詢 API,SQL 文字裡不能出現它。
  4. 是欄位、表名、排序方向:必須經過白名單對照,進入 SQL 的只能是程式自己寫的字串。
  5. 是 LIKE 的搜尋字:用佔位符,並視需要跳脫 % 與 _。
  6. 最後檢查周邊:帳號權限、錯誤訊息是否外洩、有沒有含引號資料的測試。
這個外部值要放進 SQL 的哪裡? 比較或寫入的值 欄位、表名排序方向 LIKE 的搜尋字 整段 SQL由使用者決定 佔位符? / %s / :name值另外傳 白名單對照字典查表查不到用預設值 佔位符+跳脫% 與 _ 先跳脫ESCAPE '\' 重新設計改成固定的篩選選項
四條路只有最右邊沒有安全的寫法:如果功能的設計是讓使用者自己寫 SQL,應該改成提供固定的篩選欄位與選項,或交給專門的唯讀查詢環境,而不是在程式裡想辦法過濾。

完整範例一:sqlite3 的兩種寫法與回歸測試

這段程式建立和實驗室一相同的 5 筆虛構資料,用三個輸入比較兩種寫法,最後用 assert 寫成測試。可以直接貼到 Python 3.11 以上執行,不需要任何外部檔案。

import sqlite3

def make_db():
    conn = sqlite3.connect(":memory:")                 # 記憶體資料庫,示範用
    conn.execute("CREATE TABLE members(id INTEGER, username TEXT, role TEXT)")
    conn.executemany("INSERT INTO members VALUES (?, ?, ?)", [   # 寫入也用佔位符
        (1, "amy", "admin"), (2, "bob", "staff"), (3, "O'Brien", "member"),
        (4, "chen", "member"), (5, "dora", "member")])
    return conn

def find_unsafe(conn, name):                           # ❌ 示範用:輸入被拼進 SQL 文字
    return conn.execute(f"SELECT id FROM members WHERE username = '{name}'").fetchall()

def find_safe(conn, name):                             # ✅ ? 佔位符,值另外傳
    return conn.execute("SELECT id FROM members WHERE username = ?", (name,)).fetchall()

conn = make_db()
for name in ["amy", "O'Brien", "' OR '1'='1"]:         # 一般、含引號的正常姓名、教科書示範輸入
    try:
        bad = len(find_unsafe(conn, name))
    except sqlite3.OperationalError as e:              # 合法姓名也會讓拼接版壞掉
        bad = f"OperationalError: {e}"
    print(f"{name!r:16} 拼接 → {bad}|參數化 → {len(find_safe(conn, name))}")

# 把這些輸入寫成測試:以後有人改回拼接,測試會立刻失敗
assert find_safe(conn, "O'Brien") == [(3,)]
assert find_safe(conn, "' OR '1'='1") == []
print("測試通過")
'amy' 拼接 → 1|參數化 → 1 "O'Brien" 拼接 → OperationalError: near "Brien": syntax error|參數化 → 1 "' OR '1'='1" 拼接 → 5|參數化 → 0 測試通過

完整範例二:SQLAlchemy 的綁定參數、排序白名單與 LIKE 跳脫

一個常見的搜尋功能同時需要三種處理:角色與關鍵字是值,用 :name 綁定;排序欄位用白名單;關鍵字裡的 % 與 _ 先跳脫。

from sqlalchemy import create_engine, text

engine = create_engine("sqlite://")                    # 記憶體 SQLite;正式環境的連線字串從環境變數讀
with engine.begin() as conn:                           # begin():區塊結束自動 commit
    conn.execute(text("CREATE TABLE members(id INTEGER, username TEXT, role TEXT)"))
    conn.execute(text("INSERT INTO members VALUES (:i, :u, :r)"), [   # list of dict → 逐筆綁定
        {"i": 1, "u": "amy", "r": "admin"}, {"i": 2, "u": "bob", "r": "staff"},
        {"i": 3, "u": "O'Brien", "r": "member"}, {"i": 4, "u": "chen", "r": "member"},
        {"i": 5, "u": "dora", "r": "member"}])

SORT = {"name": "username", "newest": "id DESC"}       # 白名單:使用者選項 → 寫死的 SQL 片段

def like_pattern(kw: str) -> str:
    kw = kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")  # % 與 _ 變成普通字元
    return f"%{kw}%"                                   # 前後的 % 才是我們要的萬用字元

def search(conn, kw, role, sort):
    order = SORT.get(sort, "username")                 # 不在清單內就用預設值,絕不直接用輸入
    sql = text("SELECT username FROM members "
               "WHERE role = :role AND username LIKE :pat ESCAPE '\\' "
               f"ORDER BY {order}")  # nosec B608(已用白名單限制)
    return conn.execute(sql, {"role": role, "pat": like_pattern(kw)}).scalars().all()

with engine.connect() as conn:
    print(search(conn, "o", "member", "newest"))      # SQLite 的 LIKE 不分 ASCII 大小寫
    print(search(conn, "%", "member", "name"))        # % 被當成普通字元,沒有人名裡有 %
    print(search(conn, "o", "member", "role DESC"))   # 不在白名單 → 依 username 排序
['dora', "O'Brien"] [] ["O'Brien", 'dora']

完整範例三:格式驗證、錯誤訊息處理與最小權限

驗證放在最前面擋掉格式不對的輸入,驗證過的值仍然走佔位符;資料庫出錯時,細節進日誌,使用者只看到通用訊息。

import logging, re, sqlite3
log = logging.getLogger("cafe")
ID_RULE = re.compile(r"[0-9]{1,6}")                    # 白名單:1~6 位半形數字

def get_member(conn, member_id: str):
    if not ID_RULE.fullmatch(member_id):               # 先驗證格式,不合格直接拒絕
        return {"error": "會員編號格式不正確"}
    try:
        row = conn.execute("SELECT username, role FROM members WHERE id = ?",
                           (int(member_id),)).fetchone()   # 驗證過仍然用佔位符
    except sqlite3.Error:
        log.exception("查詢會員失敗 id=%s", member_id)  # 細節只寫進伺服器日誌
        return {"error": "系統忙碌中,請稍後再試"}       # 對外只給通用訊息,不洩漏 SQL 與表名
    return {"member": row}

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE members(id INTEGER, username TEXT, role TEXT)")
conn.execute("INSERT INTO members VALUES (3, ?, 'member')", ("O'Brien",))
print(get_member(conn, "3"))                           # 正常查詢
print(get_member(conn, "3 OR 1=1"))                    # 格式不符,直接拒絕
print(get_member(sqlite3.connect(":memory:"), "3"))    # 沒有 members 表 → 通用錯誤訊息
{'member': ("O'Brien", 'member')} {'error': '會員編號格式不正確'} {'error': '系統忙碌中,請稍後再試'} (完整的錯誤堆疊只出現在伺服器日誌,畫面上看不到)

權限則是在資料庫端設定。以 PostgreSQL 為例,應用程式用專屬帳號連線,只授予需要的權限:

-- PostgreSQL 示意:應用程式專屬帳號,只拿需要的權限
CREATE ROLE cafe_app LOGIN;                -- 密碼由機密管理服務另外設定,不寫進程式
GRANT SELECT, INSERT, UPDATE ON members TO cafe_app;
-- 不讓 cafe_app 成為資料表擁有者:擁有者才能 DROP、ALTER 資料表
-- 報表功能另開一個只有 SELECT 權限的唯讀帳號

容易寫錯或考錯的地方

看到 %s 就以為安全:要看 % 是誰在處理。cur.execute(sql, (v,)) 的 %s 是交給驅動程式的佔位符;cur.execute(sql % v) 是 Python 先把字串拼好,等同 f-string。程式題常把這兩個並列當干擾選項。
(name) 少了逗號:單一值的 tuple 要寫 (name,)。少了逗號,sqlite3 會把字串的每個字元當成一個參數,丟出 ProgrammingError(綁定數量不符)。這不是注入,但查詢跑不起來。
看到 f-string 就判定有風險:(f"%{kw}%",) 是組參數值,仍經由佔位符送出;白名單接進來的 order 也是程式自己的字串。判斷依據是「SQL 文字裡有沒有使用者的字」,不是有沒有 f-string。
用 ? 代入欄位名或排序:佔位符只代入值。ORDER BY ? 不報錯但排序無效,FROM ? 是語法錯誤,兩者都要改成白名單。
用跳脫或刪除單引號當主要防線:OWASP 認為自行跳脫很脆弱、不建議當主要做法;刪掉單引號還會弄壞 O'Brien 這種合法資料。前端 JavaScript 的檢查,在請求不經過表單時就不會執行,只算使用者體驗。
以為用了 ORM 就一定安全:text()、raw()、read_sql 裡自己拼字串,ORM 也救不了。
版本差異:sqlite3 在 Python 3.12、3.13 對「具名佔位符搭配 tuple」只發出 DeprecationWarning,3.14 起改丟 ProgrammingError;具名佔位符請一律傳 dict。

✅ 自我檢測

6 題原創程式閱讀題。除了特別註明,conn 都是 sqlite3 連線,members 表有 5 筆虛構資料,依 id 1~5 依序是 amy、bob、O'Brien、chen、dora。所有輸出都用 Python 3 實際執行確認過。目前得分:0 / 6

Q1.執行後印出什麼?

name = "' OR '1'='1"                      # 教科書示範輸入
rows = conn.execute(f"SELECT id FROM members WHERE username = '{name}'").fetchall()
print(len(rows))
拼接後的條件是 username = '' OR '1'='1',多出來的 OR 右邊對每一列都成立,所以查詢條件被改變,回傳 5 列。程式原本的設計是找帳號剛好等於這串字的人,應該是 0 列;改成 "... = ?", (name,) 後確實印出 0。

Q2.執行後會發生什麼事?

name = "dora"
print(conn.execute("SELECT role FROM members WHERE username = ?", (name)).fetchall())
(name) 只是加了括號的字串,不是 tuple。sqlite3 把字串當成序列,"dora" 有 4 個字元就算 4 個參數,而 SQL 只有 1 個 ?,實際訊息是 Incorrect number of bindings supplied. The current statement uses 1, and there are 4 supplied. 改成 (name,) 就會印出 [('member',)]。

Q3.改用 PostgreSQL 與 psycopg 3 後,下列哪一行是安全的寫法?(cur 是 conn.cursor())

psycopg 的 paramstyle 是 pyformat,%s 是佔位符,值必須當成 execute 的第二個參數傳入(C)。A 和 B 用的是 Python 的 % 運算子,在送出前就把值併進 SQL 文字;D 用 repr 加上引號,看起來像字串常值,但仍是自己組 SQL,Python 的引號規則也和 SQL 不同。

Q4.在這個 sqlite3 範例中,執行後印出什麼?

rows = conn.execute("SELECT username FROM members ORDER BY ?", ("username",)).fetchall()
print([r[0] for r in rows][:2])
佔位符只代入值,所以這裡是「依一個固定字串 'username' 排序」,每一列的排序鍵都一樣,結果沒有被排序,實際印出寫入時的順序 ['amy', 'bob'](沒有有效排序時,SQL 不保證順序,不要依賴它)。若真的依 username 排序,大寫的 O 排在小寫前面,會得到 A;正確寫法是白名單對照後把欄位名接進 SQL。

Q5.搜尋框裡使用者只輸入了一個百分號,執行後印出什麼?

kw = "%"
sql = "SELECT count(*) FROM members WHERE username LIKE ?"
print(conn.execute(sql, (f"%{kw}%",)).fetchone()[0])
值經由佔位符送出,沒有注入問題;但參數值是 "%%%",三個 % 都是 LIKE 的萬用字元,每個帳號都符合,所以印出 5。若要把使用者打的 % 當普通字元,先把 % 與 _ 跳脫並加上 ESCAPE '\',同一個查詢會得到 0。

Q6.程式碼審查時,下列函式中哪一行最需要修正?

def list_members(conn, role, sort):
    SORT = {"name": "username", "newest": "id DESC"}            # (A)
    order = SORT.get(sort, "username")                          # (B)
    sql = f"SELECT username FROM members WHERE role = '{role}' ORDER BY {order}"  # (C)
    return conn.execute(sql).fetchall()                         # (D)
排序欄位已經用白名單處理,order 只可能是程式寫死的字串;問題在 role 被直接拼進引號裡。實跑時正常的 "member" 回傳 3 列,教科書示範輸入則回傳 5 列。修正方式是 WHERE role = ? ORDER BY {order} 並傳 (role,),同樣的示範輸入就只回傳 0 列。

🎯 重點整理

  1. SQL 注入的成因是 SQL 語法與資料混在同一個字串:使用者的字只要變成 SQL 文字的一部分,就可能改變查詢條件,連 O'Brien 這種合法輸入都會讓查詢出錯。
  2. 參數化查詢讓資料庫先固定查詢結構、再綁定值,值不會再被當成語法讀一次。sqlite3 用 ? 或 :name,psycopg 與 PyMySQL 用 %s,SQLAlchemy text() 用 :name,單一值寫成 (name,)。
  3. 欄位名、表名、ORDER BY 不能參數化,要用白名單對照;LIKE 的 % 與 _ 視需要跳脫。
  4. ORM 只有在用它的查詢 API 時才自動綁定;text()、raw()、read_sql 裡自己拼字串一樣有風險。
  5. 縱深防禦:最小權限帳號、輸入格式驗證、錯誤訊息只寫日誌,再加上 Bandit B608 與含引號輸入的單元測試,防止修好的程式又被改壞。