問題與適用範圍
一個多租戶訂單平台提供內部搜尋端點。伺服器可以從已驗證的工作階段取得使用者所屬租戶。 請求可以包含客戶電子郵件、訂單狀態清單、排序欄位、排序方向與回傳筆數。程式碼審查發現類似 下列實作:
const sql = `
SELECT id, customer_email, status, total_cents, created_at
FROM orders
WHERE tenant_id = '${tenantId}'
AND customer_email = '${input.email}'
AND status IN (${input.statuses.join(",")})
ORDER BY ${input.sort} ${input.direction}
LIMIT ${input.limit}
`請重新設計這個查詢邊界,讓攻擊者可控制的文字無法改變 SQL 語法結構。答案需要涵蓋一般值、 選用篩選條件、清單參數、排序欄位等識別符、預存程序、ORM 的原生查詢逃生口、資料庫權限、 紀錄,以及已經存入資料庫、之後又被動態 SQL 使用的資料。
核心規則是:SQL 程式碼只能由可信任的應用程式碼決定,外部資料必須透過伺服器端參數繫結介面 傳給資料庫。輸入驗證與最小權限可以增加防線,但不能讓字串串接變成安全查詢。
這是一道後端題,因為核心能力是查詢建構、資料庫驅動程式行為、伺服器端授權邊界、資料庫 權限與正式環境驗證。使用哪一種程式語言不影響分類。
面試官在評估什麼
第一個訊號是應徵者能否辨識所有語法邊界,而不只回答「使用預備敘述」。租戶 ID、電子郵件、 狀態與回傳筆數都屬於資料,應繫結為參數。資料表名稱、欄位名稱、SQL 關鍵字與排序方向通常 不能放進一般值預留位置,因此需要重新設計查詢,或把很小的公開列舉映射到伺服器固定 SQL 片段。
第二個訊號是能否區分 Injection 防護與授權。即使 tenant_id 已參數化,如果它來自請求 內容,呼叫端仍可能選擇其他租戶,租戶隔離依然不安全。伺服器應從已驗證身分推導租戶,並將 租戶範圍加入每個相關查詢。參數化只能阻止值改變查詢結構,不能證明呼叫端有權存取該值。
第三個訊號是能否找出隱藏的執行邊界。只有正確使用參數化 API 時,ORM 才能提供安全邊界。 原生查詢方法、字串建構篩選器、移轉工具、報表工作與執行動態 SQL 的預存程序,都可能重新 引入相同缺陷。今天安全儲存的資料,如果日後被另一個工作串接進 SQL,也可能形成二次 SQL Injection。
最後一個訊號是能否分層驗證。高品質答案會結合程式碼審查、伺服器端參數繫結、識別符允許 清單、最小權限資料庫角色、安全錯誤處理、惡意字串整合測試、租戶隔離斷言與資料庫錯誤監控。 它不會把 Web 應用程式防火牆或手動跳脫當成主要修正方案。
回答前需要釐清的問題
- 正式環境使用哪一種資料庫與驅動程式? 預留位置語法、陣列繫結、識別符引用工具與預備
敘述行為會隨實作改變。設計原則可以共用,實際 API 必須符合目前驅動程式。
- 請求是否包含
tenantId? 即使包含,也不能用它決定授權範圍。租戶應來自已驗證的
伺服器端情境。管理員跨租戶存取需要獨立且明確授權的路徑。
- 哪些篩選條件是選用的? 選用述詞可以從固定 SQL 片段加入,但對應值仍須使用參數。
允許任意欄位與任意運算子的通用 API 會大幅擴大語法攻擊面。
- 產品真正需要哪些排序欄位? 如果只需要建立時間與總金額,就只公開這兩個鍵。不要接受
任意欄位運算式、函式、定序或逗號分隔的排序子句。
- 端點是否會搜尋多個狀態或 ID? 使用驅動程式的陣列能力、具型別陣列參數,或產生一組
預留位置。禁止把原始值串接進 SQL 文字。
- 路徑中是否使用 ORM 原生查詢 API? 同時檢查明確標示為不安全的方法與看似安全的範本
方法,確認值最後由驅動程式繫結,還是先被插值成字串。
- 預存程序是否建構動態 SQL? 預存程序不會自動安全。它的參數在最後執行 SQL 時仍必須
是資料,動態 EXEC 類路徑需要同樣審查。
- 應用程式資料庫角色有哪些權限? 唯讀搜尋端點通常不應擁有 schema、DDL 或無關資料表
權限。執行階段憑證應與移轉、維運憑證分開。
- 發布前需要哪些證據? 修改程式碼前先定義惡意輸入語料、租戶隔離檢查、原生查詢清單、
資料庫角色驗證與正式環境錯誤訊號。
30 秒回答框架
「我會讓已驗證的伺服器端情境成為 tenantId 的唯一來源,再透過資料庫驅動程式繫結所有 資料值:租戶、電子郵件、狀態陣列與回傳筆數。排序欄位與方向屬於 SQL 語法,所以我會把兩個 公開列舉映射到伺服器固定 SQL 片段,拒絕其他輸入。我也會盤點 ORM 原生查詢與預存程序,因為 它們可能重新引入字串 SQL;資料庫中既有資料進入後續執行邊界時也要再次參數化。最後收緊 執行階段資料庫角色,對客戶端回傳通用錯誤,內部記錄去識別化的詳細失敗資訊,並執行整合 測試,證明惡意字串只能作為字面值,而且任何請求都不能回傳其他租戶的資料。」
分步深入解答
第一步:標記程式碼、資料與授權來源
先替原查詢的每一部分分類:
| 查詢部分 | 類型 | 安全來源 | |---|---|---| | SELECT、資料表、述詞 | SQL 語法 | 靜態應用程式碼 | | 租戶 ID | 資料與授權範圍 | 已驗證伺服器端情境 | | 電子郵件、狀態、回傳筆數 | 資料 | 驅動程式繫結參數 | | 排序欄位 | SQL 識別符 | 伺服器允許清單 | | 排序方向 | SQL 關鍵字 | 伺服器允許清單 |
不安全範例讓請求衍生字串參與 SQL 解析。引號、註解、運算子或額外運算式可能在資料庫判斷 哪些字元原本應是資料之前改變陳述式。檢查少數「可疑字串」無法恢復安全邊界,因為 SQL 還有 方言差異、編碼、註解、函式與解析器行為。
審查範圍應從所有 SQL 執行點開始,而不只看這個端點。搜尋原生查詢 API、查詢方法附近的範本 字串與串接、動態預存程序執行、報表篩選器,以及允許呼叫端傳入欄位名稱或運算子的查詢建構器。 沿著包裝層持續追蹤,直到能說明哪些內容以 SQL 文字進入驅動程式,哪些內容進入參數集合。
第二步:在伺服器端繫結每一個值
PostgreSQL 風格的 TypeScript 實作可以把值與 SQL 文字分開:
const SORT_COLUMNS = {
createdAt: "o.created_at",
total: "o.total_cents",
} as const
const SORT_DIRECTIONS = {
asc: "ASC",
desc: "DESC",
} as const
interface OrderSearchInput {
email: string | null
statuses: string[] | null
sort: string
direction: string
limit: number
}
async function findOrders(authenticatedTenantId: string, input: OrderSearchInput) {
if (
!Object.hasOwn(SORT_COLUMNS, input.sort) ||
!Object.hasOwn(SORT_DIRECTIONS, input.direction)
) {
throw new Error("Unsupported sort option")
}
const sortColumn =
SORT_COLUMNS[input.sort as keyof typeof SORT_COLUMNS]
const sortDirection =
SORT_DIRECTIONS[input.direction as keyof typeof SORT_DIRECTIONS]
const sql = `
SELECT o.id, o.customer_email, o.status, o.total_cents, o.created_at
FROM orders AS o
WHERE o.tenant_id = $1
AND ($2::text IS NULL OR o.customer_email = $2)
AND ($3::text[] IS NULL OR o.status = ANY($3))
ORDER BY ${sortColumn} ${sortDirection}
LIMIT $4
`
return db.query(sql, [
authenticatedTenantId,
input.email,
input.statuses,
input.limit,
])
}保留的字串插值只使用經過執行階段成員檢查後,從常數映射選出的值。任何請求字串都不會成為 識別符或關鍵字。租戶、電子郵件、狀態陣列與回傳筆數始終留在驅動程式參數集合中。
執行前還要把 limit 驗證為產品允許範圍內的整數,用來保護資源與 API 語意。它仍應作為 參數繫結,因為業務驗證與程式碼資料分離解決不同問題。
如果資料庫沒有方便的陣列參數,可以依陣列長度產生預留位置,再逐一繫結元素:
status IN ($3, $4, $5)
values = [tenantId, email, status1, status2, status3]可信任程式碼可以產生預留位置文字,狀態值不能串接進 SQL。空陣列語意也要明確:它可以代表 「不加狀態篩選」,也可以代表「沒有任何符合項目」,不能交給查詢建構器偶然決定。
第三步:讓動態語法維持最小並由伺服器掌握
一般繫結參數表示值,不能代表任意識別符或關鍵字。把 "created_at" 當作 $1 傳入,通常 只會得到一個字串字面值,不會依該欄位排序。使用值繫結介面處理不可信識別符,無法完成產品 需求。
應只公開很小的詞彙表,並映射到固定內部片段:
createdAt -> o.created_at
total -> o.total_cents
asc -> ASC
desc -> DESC未知鍵一律拒絕。不要從請求直接傳入加上引號的識別符、SQL 函式、JSON 路徑、定序、空值 順序或運算式。未來如果產品增加計算排序,應在伺服器端實作運算式,再把一個新的公開鍵映射 到它。
可信任管理工具確實需要動態識別符時,資料庫提供的識別符引用工具可能適用。但一般 API 對 任意使用者輸入做識別符引用,往往仍保留過寬能力。狹窄映射更容易審查與測試。
第四步:獨立確保租戶隔離
從已驗證工作階段、權杖或服務身分推導 authenticatedTenantId。不要信任 JSON 本文、查詢 參數或瀏覽器狀態中重複出現的租戶值。每個讀寫租戶資料的查詢都應包含授權範圍,或透過強制 要求租戶參數的儲存庫層執行。
測試矩陣必須包含屬於其他租戶的有效訂單 ID、電子郵件或狀態。即使所有 SQL 值在語法上都很 普通,查詢也不能回傳該筆資料。這樣才能抓到單純 Injection 測試無法發現的授權缺陷。
如果資料庫資料列層級安全性以可信任的工作階段身分設定,並經過連線池情境測試,它可以再增加 一道邊界。它不能取代伺服器端授權、正確的工作階段變數管理或安全查詢建構。應把它視為具有 獨立設定與測試的額外執行層。
第五步:審查 ORM、預存程序與二次執行路徑
ORM 的一般篩選 API 通常會繫結參數,但原生執行方法可能同時提供安全參數化形式與明確不安全 的字串形式。需要確認實際方法、框架版本與驅動程式路徑。只看 queryRaw 之類方法名稱無法 判斷,必須證明最後 SQL 與參數是否分開傳給驅動程式。
只有輸入在最後 SQL 執行中仍是參數,預存程序才安全。如果預存程序把參數串接進動態 SQL 後再執行,只是把漏洞邊界移到資料庫內。搜尋程序內容中的動態執行陳述式,並審查應用程式角色 取得的執行權限。
二次 SQL Injection 是指惡意文字第一次作為一般資料安全儲存,日後又被另一個程序當成 SQL 語法串接。例如匯入工作可以安全寫入報表名稱,但夜間報表工作之後把該名稱串接進查詢。第一次 寫入可以完全參數化,後續執行仍可能有漏洞。無論值來自直接請求、其他服務、檔案或既有資料庫 資料列,每個執行邊界都要參數化;如果值確實要選擇語法,就必須映射到固定片段。
第六步:加入多層防禦,但不能掩蓋缺陷
執行階段資料庫角色只保留必要資料表與操作權限。唯讀搜尋服務不應擁有 schema,也不應有刪除 資料表、建立擴充功能或更新無關資料的權限。移轉與維運使用獨立憑證。最小權限可以限制遺漏 Injection 或其他應用程式入侵的影響範圍,但不能讓不安全查詢變得可接受。
輸入驗證應執行業務型別、長度、列舉成員與範圍限制,及早拒絕格式錯誤並降低資源濫用。它仍是 第二道防線,因為通過驗證的值放入錯誤 SQL 情境仍可能危險,而且姓名等自由文字本來就可能 包含標點符號。
手動跳脫依賴資料庫方言,也很脆弱。不要建立通用 escapeSql 工具再串接輸出。如果舊路徑 暫時無法替換,應隔離它,使用資料庫廠商明確提供的機制,收緊權限,補上測試並追蹤移除。Web 應用程式防火牆可在事件期間提供暫時偵測或虛擬修補,但不能證明所有資料庫執行路徑安全。
客戶端只收到通用失敗資訊。內部紀錄保留路由、操作、部署版本與資料庫錯誤類別,但不對呼叫端 回顯 SQL、堆疊、連線資訊或敏感參數。紀錄也要避免祕密與完整個人資料,同時保留足夠的調查 識別資訊。
第七步:在查詢邊界驗證修正
使用真實驅動程式與資料庫方言執行整合測試。語料應包含引號、註解符號、分號、Unicode、 萬用字元與看似 SQL 的文字。斷言不能只寫「請求沒有當掉」。值必須被當成字面值,查詢結構 必須維持固定,端點只能回傳已授權資料。
至少涵蓋:
- 包含引號或類似註解字元的電子郵件仍以完整字面值比較。
- 每個允許的排序鍵都產生預期的固定
ORDER BY片段。 - 未知排序鍵、方向、狀態與超出範圍的回傳筆數在查詢前被拒絕。
- 空陣列、單一元素陣列與多元素陣列具有明確語意並使用繫結值。
- 呼叫端無法透過修改任何請求欄位取得其他租戶資料。
- ORM 原生查詢與預存程序路徑使用相同惡意語料。
- 報表或維護工作再次使用已儲存字串時,該值在第二個執行邊界仍是資料。
- 執行階段資料庫角色不能修改 schema,也不能存取無關資料表。
靜態分析與程式碼審查可以防止新增原始插值路徑。可以針對不安全原生查詢方法與 SQL 周邊的 字串建構設定規則或審查檢查點,但仍要保留整合測試,因為簡單文字搜尋未必看得到包裝層、產生 的程式碼、預存程序與實際驅動程式行為。
第八步:發布、觀察與回應
發布時觀察資料庫錯誤率、端點延遲、拒絕輸入數量與查詢形狀。語法錯誤、權限錯誤或非法排序鍵 突然增加,可能代表遺漏的客戶端,也可能是主動探測。不要為了計數而記錄完整惡意內容。
如果懷疑發生真實 Injection,應停用或縮小有漏洞端點,保留相關部署與稽核證據,輪替可能外洩 的資料庫憑證,並依執行階段角色權限判斷攻擊者可能讀寫的範圍。檢查資料庫紀錄與業務資料中的 未授權存取,修正所有同類執行路徑,把已發現的攻擊類別加入迴歸語料後再恢復正常存取。
完成標準是:不安全語法路徑已消失,租戶授權仍被執行,執行階段角色權限受限,所有查詢執行器 已完成盤點,測試證明惡意輸入在立即執行與後續執行中都只能作為資料。
高品質示範回答
「我會先盤點這個端點使用的所有 SQL 執行路徑,包括 ORM 原生方法與預存程序。原查詢混合了 五類問題:租戶、電子郵件、狀態與回傳筆數是資料;排序欄位與方向是 SQL 語法;租戶範圍同時 也是授權決策。
我會從已驗證的伺服器端情境取得租戶,並與電子郵件、具型別狀態陣列、有限範圍整數一起交給 驅動程式繫結。排序只公開 createdAt、total、asc 與 desc,經執行階段成員檢查後 映射到常數 SQL 片段,其他值全部拒絕。如果驅動程式沒有陣列繫結,就只產生預留位置清單, 每個元素仍個別繫結。
我不會因為使用 ORM 就假設所有路徑安全,而會確認實際原生查詢 API。預存程序也要檢查動態 執行。既有資料日後進入報表或批次 SQL 時仍是不可信輸入,必須在新的執行邊界再次參數化。
多層防禦方面,搜尋服務使用只能讀取必要欄位或檢視表、不能修改 schema 的資料庫角色。業務 驗證限制列舉、長度與數量,參數化繼續負責防 Injection。客戶端只收到通用錯誤,內部紀錄操作 與錯誤類別,不記錄 SQL 文字或敏感參數。
最後我會用真實驅動程式執行整合測試。引號、註解、看似 SQL 的文字、Unicode 與陣列元素都 必須維持字面值。測試涵蓋全部允許與拒絕的排序選項、空清單與大清單、ORM 與預存程序、既有 資料再次使用,以及跨租戶識別符。只有查詢結構維持不變,而且任何請求都無法回傳其他租戶 資料,修正才算完成。」
常見錯誤
- 只參數化電子郵件 → 租戶、清單元素、回傳筆數或其他篩選器仍可改變 SQL → **繫結所有
值,並盤點完整執行路徑。**
- 把請求欄位名稱放進一般值預留位置 → 值預留位置不能表示識別符 → **把小型公開列舉
映射到伺服器固定 SQL 片段。**
- 替任意請求識別符加上引號 → 端點仍讓呼叫端控制過寬語法面 → **只公開產品真正需要的
排序鍵,其餘全部拒絕。**
- 把驗證後的狀態清單串接進
IN (...)→ 驗證可能變動,每個元素也重新進入 SQL 文字 →
使用具型別陣列參數,或產生預留位置並逐一繫結。
- 因為
tenantId已參數化就從請求讀取 → 程式碼資料分離不能授權目標租戶 → **從已
驗證的伺服器端情境推導租戶範圍。**
- 認為 ORM 能阻止所有 Injection → 原生或不安全方法可以繞過一般繫結 → **確認實際 API
與最終驅動程式呼叫。**
- 把字串建構移進預存程序 → 程序內動態 SQL 仍然可以被注入 → **讓程序參數一直維持到
最終執行。**
- 只在第一次寫入時清理 → 已儲存惡意文字可能在報表工作中變成 SQL 語法 → **保護每個
執行邊界,防止二次 Injection。**
- 使用自訂工具跳脫引號 → 方言、編碼與情境差異讓手動跳脫脆弱 → **使用驅動程式的
伺服器端參數繫結介面。**
- 因為查詢已修正就給應用程式 owner 權限 → 其他缺陷或憑證外洩仍有過大影響範圍 →
使用最小權限執行階段角色,並分開移轉憑證。
- 為了除錯把資料庫錯誤與 SQL 回傳給客戶端 → 呼叫端會取得 schema 與查詢細節,紀錄也
可能洩漏個人資料 → 回傳通用錯誤,內部記錄結構化且去識別化的證據。
- 只測試一個經典內容就停止 → 授權、陣列、預存程序、其他原生路徑與二次執行仍未涵蓋 →
用多樣語料驗證查詢結構與租戶結果。
追問
追問一:預備敘述能保護動態資料表或欄位名稱嗎?
一般繫結參數表示值,通常不能替換資料表名稱、欄位名稱、運算子或 SQL 關鍵字。應重新設計 API,讓呼叫端只選擇小型列舉,再把每個允許鍵映射到應用程式持有的固定 SQL 片段。如果可信任 管理工具真的需要動態識別符,應使用資料庫廠商提供的識別符機制與更嚴格的授權邊界,不能把 這項能力暴露給一般使用者搜尋端點。
追問二:使用 ORM 就足以防止 SQL Injection 嗎?
只有選定 API 將參數繫結一直保留到最終驅動程式呼叫時才足夠。一般等值與篩選方法通常可以, 原始字串方法、不安全變體、動態欄位名稱、自訂運算子與擴充功能未必可以。檢查正式環境框架 版本,在安全測試環境觀察產生的 SQL 與參數,並執行惡意輸入整合測試。ORM 能降低犯錯機會, 不能免除對逃生口的理解。
追問三:什麼是二次 SQL Injection?
攻擊者可控制的值第一次作為資料儲存,沒有改變原陳述式。後續程序讀取該值並串接動態 SQL, 使它改變新陳述式結構。修正點位於後續執行邊界:把已儲存值繫結為資料;如果它本來用來選擇 語法,就映射到固定片段。當資料庫資料列、檔案、佇列或內部服務影響可執行 SQL 時,都應把 它們視為不可信任來源。
追問四:是否應拒絕所有引號、分號或 SQL 關鍵字?
不應該。姓名、搜尋文字與其他合法資料可能包含標點符號或看似 SQL 的字詞。業務驗證應限制 真實領域型別、長度、格式、列舉與範圍。參數化透過把完整值固定成字面資料來提供安全性。 拒絕清單既不完整,也會破壞合法輸入。
追問五:如何處理很大的動態 IN 清單?
優先使用資料庫具型別陣列或資料表值參數;否則替每個元素產生一個預留位置並分別繫結。為資源 控制設定最大清單長度。非常大的清單可能需要暫存資料表、批次載入機制或重新設計 API,但寫入 值仍應透過繫結或批次通訊協定傳送,不能串接進 SQL 字串。
追問六:確認正式環境發生 SQL Injection 時怎麼辦?
先限制有漏洞的路徑,保留部署、資料庫與應用程式稽核證據,並輪替可能外洩的憑證。依執行階段 角色權限決定調查範圍,檢查未授權讀寫,修正所有同類查詢建構器與預存程序。把實際路徑加入 迴歸測試,在條件允許時繼續收緊權限,只有修正後的查詢與租戶邊界都驗證通過才恢復流量。