Go 安全编程实战——SQL 注入防御、JWT 安全、密钥管理最佳实践
Go 安全编程实战——SQL 注入防御、JWT 安全、密钥管理最佳实践
适读人群:需要把安全做到生产级别的 Go 工程师 | 阅读时长:约17分钟 | 核心价值:把最常见的几类安全漏洞的正确防御方式说清楚,不留死角
一次安全审计把我们的代码问题全部揪出来
去年公司请了一家安全公司做渗透测试,我们的 Go 服务被扫出了好几个问题:一个 API 参数没做过滤、JWT 密钥硬编码在代码里、日志里打印了完整的请求体(包含用户密码)。
每一个问题单独看都不是很严重,但组合起来就是一条完整的攻击路径。
那次之后我系统地整理了 Go 服务里最常见的安全漏洞和对应的防御方案。今天这篇文章是那份整理的精华版。
SQL 注入防御
这是最基础也是最容易犯错的安全问题。
package security
import (
"context"
"database/sql"
"fmt"
"regexp"
"strings"
)
// 错误示范:直接拼接 SQL(高危!)
func GetUserByNameUnsafe(db *sql.DB, username string) error {
// 如果 username = "'; DROP TABLE users; --"
// 这条 SQL 就变成了删表语句!
query := fmt.Sprintf("SELECT * FROM users WHERE username = '%s'", username)
_, err := db.Query(query)
return err
}
// 正确方式一:参数化查询(推荐)
func GetUserByNameSafe(ctx context.Context, db *sql.DB, username string) (*User, error) {
// ? 是占位符,驱动会正确转义参数
// PostgreSQL 用 $1, $2...
query := "SELECT id, username, email FROM users WHERE username = ? AND deleted_at IS NULL"
row := db.QueryRowContext(ctx, query, username)
var user User
err := row.Scan(&user.ID, &user.Username, &user.Email)
if err == sql.ErrNoRows {
return nil, nil
}
return &user, err
}
// 正确方式二:用 ORM(sqlx/gorm)
// gorm 默认使用参数化查询
func GetUserWithGORM(db *gorm.DB, username string) (*User, error) {
var user User
// gorm 会自动处理参数化
result := db.Where("username = ?", username).First(&user)
return &user, result.Error
}
// 动态 ORDER BY 的安全处理(不能用参数化,需要白名单)
var allowedOrderColumns = map[string]bool{
"created_at": true,
"updated_at": true,
"username": true,
"id": true,
}
func GetUsersWithOrder(ctx context.Context, db *sql.DB, orderBy string, desc bool) error {
// 白名单校验 orderBy 字段
if !allowedOrderColumns[orderBy] {
return fmt.Errorf("不允许的排序字段: %s", orderBy)
}
direction := "ASC"
if desc {
direction = "DESC"
}
// 字段名用白名单验证过,可以直接拼接
query := fmt.Sprintf("SELECT * FROM users ORDER BY %s %s LIMIT 100", orderBy, direction)
_, err := db.QueryContext(ctx, query)
return err
}
type User struct {
ID int64
Username string
Email string
}JWT 安全:不只是"加签名"这么简单
package jwt
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// 错误示范:密钥太弱、算法不安全
var weakSecret = []byte("secret") // 高危:密钥太短且可预测
// 正确方式:使用足够强的密钥 + 安全配置
type JWTManager struct {
secretKey []byte
issuer string
expiry time.Duration
}
func NewJWTManager(secretKey []byte, issuer string, expiry time.Duration) *JWTManager {
if len(secretKey) < 32 {
panic("JWT 密钥长度至少需要 32 字节")
}
return &JWTManager{
secretKey: secretKey,
issuer: issuer,
expiry: expiry,
}
}
// Claims 自定义 JWT 声明
type Claims struct {
UserID string `json:"uid"`
Username string `json:"username"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
// GenerateToken 生成 JWT
func (m *JWTManager) GenerateToken(userID, username string, roles []string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Username: username,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: m.issuer,
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.expiry)),
// 注意:不要在这里加 NotBefore,除非你真的需要
},
}
// 使用 HS256 是可以的,但更安全的是 RS256(非对称)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secretKey)
}
// ValidateToken 验证并解析 JWT
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(
tokenString,
&Claims{},
func(token *jwt.Token) (interface{}, error) {
// 关键:验证算法
// 防止攻击者把 alg 改成 "none" 绕过签名验证
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("意外的签名算法: %v", token.Header["alg"])
}
return m.secretKey, nil
},
// 启用严格的验证选项
jwt.WithIssuedAt(),
jwt.WithIssuer(m.issuer),
)
if err != nil {
return nil, fmt.Errorf("JWT 验证失败: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("无效的 JWT")
}
return claims, nil
}
// GenerateSecureSecret 生成安全的随机密钥
func GenerateSecureSecret() ([]byte, error) {
secret := make([]byte, 64) // 512 位
if _, err := rand.Read(secret); err != nil {
return nil, err
}
return secret, nil
}
// GenerateSecureSecretBase64 生成 base64 编码的密钥(便于存储到环境变量)
func GenerateSecureSecretBase64() (string, error) {
secret, err := GenerateSecureSecret()
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(secret), nil
}密钥管理:不能硬编码在代码里
package config
import (
"encoding/base64"
"fmt"
"os"
)
// 错误示范:密钥硬编码
const hardcodedSecret = "mysecretkey123" // 高危!会被提交到 git
// 正确方式:从环境变量或密钥管理服务读取
type SecretConfig struct {
JWTSecret []byte
DBPassword string
RedisPassword string
AESKey []byte
}
func LoadSecretConfig() (*SecretConfig, error) {
// 方式一:从环境变量读取
jwtSecretB64 := os.Getenv("JWT_SECRET")
if jwtSecretB64 == "" {
return nil, fmt.Errorf("JWT_SECRET 环境变量未设置")
}
jwtSecret, err := base64.StdEncoding.DecodeString(jwtSecretB64)
if err != nil {
return nil, fmt.Errorf("JWT_SECRET 格式错误(需要 base64 编码): %w", err)
}
if len(jwtSecret) < 32 {
return nil, fmt.Errorf("JWT_SECRET 长度不够(至少 32 字节)")
}
return &SecretConfig{
JWTSecret: jwtSecret,
DBPassword: requireEnv("DB_PASSWORD"),
RedisPassword: os.Getenv("REDIS_PASSWORD"), // 可选
}, nil
}
func requireEnv(key string) string {
v := os.Getenv(key)
if v == "" {
panic(fmt.Sprintf("必须设置环境变量: %s", key))
}
return v
}
// 在 K8s 里,通过 Secret 挂载环境变量
// apiVersion: v1
// kind: Secret
// metadata:
// name: app-secrets
// stringData:
// JWT_SECRET: "你的base64密钥"
// DB_PASSWORD: "你的数据库密码"输入验证与 XSS 防御
package validate
import (
"html"
"net/http"
"regexp"
"strings"
"unicode/utf8"
)
// 常用的输入验证函数
var (
usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]{3,50}$`)
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
phoneRegex = regexp.MustCompile(`^1[3-9]\d{9}$`)
)
// ValidateUsername 验证用户名
func ValidateUsername(username string) error {
if !utf8.ValidString(username) {
return fmt.Errorf("用户名包含无效字符")
}
if !usernameRegex.MatchString(username) {
return fmt.Errorf("用户名只能包含字母、数字、下划线、横线、点,长度 3-50")
}
return nil
}
// SanitizeHTML 对 HTML 内容进行转义,防止 XSS
// 用于展示用户输入的内容时
func SanitizeHTML(input string) string {
return html.EscapeString(input)
}
// LimitedReader 限制请求体大小,防止 DoS 攻击
func LimitBodySize(next http.Handler, maxBytes int64) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
// TruncateString 安全截断字符串(按 rune 而不是 byte)
func TruncateString(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen])
}安全的日志记录
package logging
import (
"regexp"
"strings"
)
// 敏感字段列表
var sensitiveFields = []string{
"password", "passwd", "pwd",
"secret", "token", "api_key",
"credit_card", "card_number",
"cvv", "pin",
"ssn", "social_security",
}
// MaskSensitiveFields 对日志里的敏感字段进行脱敏
func MaskSensitiveFields(logStr string) string {
for _, field := range sensitiveFields {
// 匹配类似 "password":"xxxxx" 或 "password": "xxxxx"
pattern := regexp.MustCompile(
fmt.Sprintf(`(?i)"%s"\s*:\s*"[^"]*"`, regexp.QuoteMeta(field)),
)
logStr = pattern.ReplaceAllString(logStr, fmt.Sprintf(`"%s":"***"`, field))
}
return logStr
}
// MaskPhone 手机号脱敏(保留前3位和后4位)
func MaskPhone(phone string) string {
if len(phone) != 11 {
return "***"
}
return phone[:3] + "****" + phone[7:]
}
// MaskEmail 邮箱脱敏
func MaskEmail(email string) string {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return "***"
}
user := parts[0]
if len(user) <= 2 {
return "**@" + parts[1]
}
return user[:2] + "***@" + parts[1]
}三个踩坑实录
坑一:JWT alg=none 攻击
现象:安全测试发现,把 JWT 的 Header 里的 alg 改成 none,移除签名部分后,仍然可以通过验证。
原因:某些 JWT 库如果不显式指定允许的算法,会接受 alg: none,完全绕过签名验证。
解法:在 jwt.ParseWithClaims 时,在 keyFunc 里显式验证算法类型,如上文代码所示。
坑二:密码明文存储(是的,这在实际项目里真的发生过)
现象:数据库被拖库,所有用户密码明文泄露。
解法:密码必须用 bcrypt 或 argon2 哈希存储,永远不要存明文或 MD5/SHA1:
import "golang.org/x/crypto/bcrypt"
// 存储密码
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// 验证密码
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}坑三:CORS 配置过于宽松
现象:某个接口设置了 Access-Control-Allow-Origin: *,攻击者可以通过恶意网站发起跨站请求,读取用户数据。
原因:* 允许任何来源访问,配合 Cookie 认证就是 CSRF 攻击的完美条件。
解法:CORS 的 Allow-Origin 要配置具体的域名白名单,不要用 *(除非是真正的公开 API)。
Java 对比
Java 的 Spring Security 框架把很多安全功能做了高度封装,开发者往往只需要配置,不需要自己实现。但这也导致开发者往往不理解背后的原理,配置错了也不知道为什么。
Go 里很多安全功能需要显式实现,代码量更多,但理解更深。这实际上是件好事——被迫理解的安全知识,比点几下配置界面更不容易出错。
小结
- SQL 注入:永远用参数化查询,动态字段用白名单
- JWT:验证算法,密钥 ≥ 32 字节,从环境变量读取
- 密码:bcrypt/argon2 哈希,永远不存明文
- 日志:敏感字段脱敏,不打印密码/token
- CORS:配置具体域名白名单,不用
* - 输入验证:所有外部输入必须验证,限制长度,特殊字符转义
