Go,Gorm 和 Mysql 是如何防止 SQL 注入的
SQL 注入和 SQL 預編譯技術
什么是 SQL 注入
所謂SQL注入(sql inject),就是通過把SQL命令插入到Web表單提交或輸入域名或頁面請求的查詢字符串,最終達到欺騙服務器執行惡意的SQL命令。具體來說,它是利用現有應用程序,將(惡意的)SQL命令注入到后臺數據庫引擎執行的能力,它可以通過在Web表單中輸入(惡意)SQL語句得到一個存在安全漏洞的網站上的數據庫,而不是按照設計者意圖去執行SQL語句。
SQL 注入例子
如下所示,是一個用戶進行登錄時,輸入用戶名和密碼,再將數據通過表單傳送到后端進行查詢的 SQL 語句。
sql = "SELECT USERNAME,PASSWORD FROM USER WHERE USERNAME='" + username + "' AND PASSWORD='" + password + "'";
上面這個 SQL 語句就存在 SQL 注入的安全漏洞。
假如 user 表中有用戶名為 123456 ,密碼為 123456 的記錄,而在前臺頁面提交表單的時候用戶輸入的用戶名和密碼是隨便輸入的,這樣當然是不能登錄成功的。
但是如果后臺處理的 SQL 語句是如上所寫,前臺頁面用戶名也是隨便輸入,而用戶輸入的密碼是這樣的 aaa' or '1'='1 ,處理登錄的 SQL 語句就相當于是這樣的:
SELECT USERNAME,PASSWORD FROM USER WHERE USERNAME='123456' AND PASSWORD='aaa' or '1'='1';
我們知道,1=1 是 true,所以上面這個 SQL 語句是可以執行成功的,這是一個 SQL 注入問題。
SQL 注入的解決
上述 SQL 注入問題產生的原因就是用戶的輸入是包含 SQL 語句的,而且后端執行 SQL 語句時直接將用戶的輸入和查詢的 SQL 語句進行了拼接。
因此,簡單的拼接用戶輸入的數據和后端的查詢 SQL 語句,是不可行的,我們需要將用戶的輸入作為一個完整的字符串,而忽略內部的 SQL 語句。當用戶輸入的密碼是這樣的 aaa’ or ‘1’='1 ,處理登錄的 SQL 語句實際應該執行的是:
SELECT USERNAME,PASSWORD FROM USER WHERE USERNAME='123456' AND PASSWORD="aaa' or '1'='1";
這樣就可以避免 SQL 注入導致的安全漏洞。
SQL 預編譯技術
解決 SQL 注入問題的這個方案的關鍵要點實際上是將 SQL 語句和用戶輸入的查詢數據分別進行處理,而不是一視同仁的作為 SQL 語句的不同部分進行拼接處理。在這個基礎上,就產生了 SQL 預編譯技術。
通常我們的一條 SQL 在 DB 接收到最終執行完畢返回可以分為下面三個過程:
詞法和語義解析
優化 SQL 語句,制定執行計劃
執行并返回結果
但是我們可以將其中需要用戶輸入的值用占位符替代,可以視為將 SQL 語句模板化或者說參數化,再將這樣的 SQL 語句進行預編譯的處理,在實際運行的時候,再傳入用戶輸入的數據。
使用這樣的 SQL 預編譯技術,除了可以防止 SQL 注入外,還可以對預編譯的 SQL 語句進行緩存,之后的運行就省去了解析優化 SQL 語句的過程,可以加速 SQL 的查詢。
Gorm 和 Go 端的 SQL 預編譯
在 Gorm 中,就為我們封裝了 SQL 預編譯技術,可以供我們使用。
db = db.Where("merchant_id = ?", merchantId)
在執行這樣的語句的時候實際上我們就用到了 SQL 預編譯技術,其中預編譯的 SQL 語句merchant_id = ?和 SQL 查詢的數據merchantId將被分開傳輸至 DB 后端進行處理。
db = db.Where(fmt.Sprintf("merchant_id = %s", merchantId))
而當你使用這種寫法時,即表示 SQL 由用戶來進行拼裝,而不使用預編譯技術,隨之可能帶來的,就是 SQL 注入的風險。
Gorm 端的 SQL 預編譯
// SQLCommon is the minimal database connection functionality gorm requires. Implemented by *sql.DB.
type SQLCommon interface {
Exec(query string, args ...interface{}) (sql.Result, error)
......
}
Go 端的 SQL 預編譯
// src/database/sql/sql.go
func (db *DB) execDC(ctx context.Context, dc *driverConn, release func(error), query string, args []interface{}) (res Result, err error) {
......
resi, err = ctxDriverExec(ctx, execerCtx, execer, query, nvdargs)
......
if err != driver.ErrSkip {
......
return driverResult{dc, resi}, nil
}
......
si, err = ctxDriverPrepare(ctx, dc.ci, query)
......
ds := &driverStmt{Locker: dc, si: si}
......
return resultFromStatement(ctx, dc.ci, ds, args...)
}
實際的實現最終還是落到了go-sql-driver上,如下面代碼所示go-sql-driver支持開啟預編譯和關閉預編譯,由mc.cfg.InterpolateParams = false、true決定,可以看出gorm中mc.cfg.InterpolateParams = true,即開啟了預編譯
func (mc *mysqlConn) Exec(query string, args []driver.Value) (driver.Result, error) {
......
if len(args) != 0 {
if !mc.cfg.InterpolateParams {
return nil, driver.ErrSkip
}
prepared, err := mc.interpolateParams(query, args)
if err != nil {
return nil, err
}
query = prepared
}
......
err := mc.exec(query)
......
return nil, mc.markBadConn(err)
}
Mysql 端的SQL 預編譯
在MySQL中是如何實現預編譯的,MySQL在4.1后支持了預編譯,其中涉及預編譯的指令實例如下
可以通過PREPARE預編譯指令,SET傳入數據,通過EXECUTE執行命令
mysql> PREPARE stmt1 FROM 'SELECT SQRT(POW(?,2) + POW(?,2)) AS hypotenuse';
Query OK, 0 rows affected (0.00 sec)
Statement prepared
mysql> SET @a = 3;
Query OK, 0 rows affected (0.00 sec)
mysql> SET @b = 4;
Query OK, 0 rows affected (0.00 sec)
mysql> EXECUTE stmt1 USING @a, @b;
+------------+
| hypotenuse |
+------------+
| 5 |
+------------+
1 row in set (0.00 sec)
mysql> DEALLOCATE PREPARE stmt1;
Query OK, 0 rows affected (0.00 sec)
首先我們先簡單回顧下客戶端使用 Prepare 請求過程:
客戶端發起 Prepare 命令將帶 “?” 參數占位符的 SQL 語句發送到數據庫,成功后返回 stmtID。
具體執行 SQL 時,客戶端使用之前返回的 stmtID,并帶上請求參數發起 Execute 命令來執行 SQL。
不再需要 Prepare 的語句時,關閉 stmtID 對應的 Prepare 語句。
這里展示不使用 sql 預編譯和使用 sql 預編譯時的 Mysql 的日志。
2020-06-30T08:14:02.430089Z 10 Query COMMIT
2020-06-30T08:14:02.432995Z 10 Query select * from user where merchant_id='123456'
2020-06-30T08:15:10.581287Z 12 Query COMMIT
2020-06-30T08:15:10.584109Z 12 Prepare select * from user where merchant_id =?
2020-06-30T08:15:10.584725Z 12 Execute select * from user where merchant_id ='123456'