如何编写高质量的Go语言代码

308次阅读
没有评论

实际开发中很多的bug,大部分是因为开发人员的不小心或者嫌麻烦导致,比如内存溢出、下标越界、空指针异常等等。一个经验丰富的开发人员,往往在开发的时候,就会潜意识的处理掉这些问题。

但是,谁不是从菜鸟起步的呢?如果你是菜鸟,那也不用慌,我这不是帮你从GitHub上找来了腾讯发布的Go代码安全指南嘛,这就让你一下子晋升老油条?
https://github.com/Tencent/secguide/blob/main/Go%E5%AE%89%E5%85%A8%E6%8C%87%E5%8D%97.md

一、通用类代码

1. 内存管理

1.1.1 切片长度校验【必须

对slice进行操作时,必须判断长度合法性,防止panic


// bad: 未校验slice的长度,可能导致 index out of range
func parse(data []string) bool {
    if data[0] == "dustin" {
        return true
    }
    return false
}

// good: 使用前先判断长度是否合法
func parse(data []string) bool {
    if len(data) >= 1 {
        if data[0] == "dustin" {
            return true
        }
    }
    return false
}

1.1.2 nil 空指针判断【必须

进行指针操作时,必须判断该指针是否为nil,防止程序panic,尤其在进行结构体Unmarshal时


type Config struct {
    Switch uint8
    Data   *Data
}

type Data struct {
    Stat uint8
    Len  uint8
    Buf  [8]byte
}

func (c *Config) UnmarshalBinary(b []byte) error {
    if len(b) < 1 {
        return io.EOF
    }
    c.Switch = b[0]
    // 当Config.Switch == 1,并且b的长度超过1,才会 new Data
    if c.Switch == 1 && len(b) > 1 {
        c.Data = new(Data)
    }
    return nil
}

// bad: 未判断指针是否为nil
func main() {
    config := new(Config)
    b := make([]byte, 1)
    if err := config.UnmarshalBinary(b); err != nil {
        fmt.Println("Failed to unmarshal config")
        return
    }
    fmt.Printf("Stat: %v", config.Data.Stat)
}

// good: 判断Data指针是否为nil
func main() {
    config := new(Config)
    b := make([]byte, 1)
    if err := config.UnmarshalBinary(b); err != nil {
        fmt.Println("Failed to unmarshal config")
        return
    }
    if config.Data == nil {
        fmt.Println("data is nil")
        return
    }
    fmt.Printf("Stat: %v", config.Data.Stat)
}

1.1.3 整数【必须

  • 在进行数字运算操作时,需要做好长度限制,防止外部输入运算导致异常:
    • 确保无符号整数运算时不会反转
    • 确保有符号整数运算时不会出现溢出
    • 确保整型转换时不会出现截断错误
    • 确保整型转换时不会出现符号错误
  • 以下场景必须严格进行长度限制:
    • 作为数组索引
    • 作为对象的长度或者大小
    • 作为数组的边界(如作为循环计数器)

// bad: 未限制长度,导致整数溢出
func overflow(num int8) {
    var numInt8 int8 = 0
    numInt8 = num + 1
    fmt.Printf("%d", numInt8)
}

// good: 长度限制判断
func overflow(num int8) {
    var numInt8 int8 = 0
    numInt8 = num + 1
    if numInt8 < 0 {
    fmt.Println("integer overflow")
    }
    fmt.Printf("%d", numInt8)

}

func main() {
    overflow(127)
}

1.1.4 make分配长度验证【必须

在进行make分配内存时,需要对外部可控的长度进行校验,防止程序panic。


// bad: 对外部传入的size,未做长度控制
func parse(lenByUser int, data []byte) {
    size := lenByUser
    buffer := make([]byte, size)
    copy(buffer, data)
}

// good: 对外部传入的size,长度控制在可控的范围
func parse(lenByUser int, data []byte) ([]byte, error) {
    size := lenByUser
    if size > 64*1024*1024 {
        return nil, errors.New("size too large")
    }
    buffer := make([]byte, size)
    copy(buffer, data)
    return buffer, nil
}

1.1.5 禁止 SetFinalizer 和 指针循环引用 同时使用【必须

当一个对象从被GC选中到移除内存之前,runtime.SetFinalizer() 都不会执行,即使程序正常结束或发生错误。由指针构成的“循环引用”虽然能被GC正确处理,但是由于无法确定Finalizer依赖顺序,从而无法调用 runtime.SetFinalizer(),导致目标对象无法变成可达状态,从而造成内存无法被回收


type Data struct {
    o *Data
}

// bad
func foo() {
    var a, b Data
    a.o = &b
    b.o = &a

    // 指针循环引用,SetFinalizer()无法正常调用
    runtime.SetFinalizer(&a, func(d *Data) {
    fmt.Printf("a %p final.n", d)
    })
    runtime.SetFinalizer(&b, func(d *Data) {
    fmt.Printf("b %p final.n", d)
    })
}

func main() {
    for {
    foo()
    time.Sleep(time.Millisecond)
    }
}

1.1.6 禁止重复释放 channel【必须

关闭一个已经关闭的chan会导致panic


// bad
func foo(c chan int) {
    defer close(c)
    err := processBusiness()
    if err != nil {
    c <- 0
    close(c) // 重复释放channel
    return
    }
    c <- 1
}

// good
func foo(c chan int) {
    defer close(c) // 使用defer延迟关闭channel
    err := processBusiness()
    if err != nil {
    c <- 0
    return
    }
    c <- 1
}

1.1.7 确保每个协程都能退出【必须

启动一个协程就会做一个入栈操作,在系统不退出的情况下,协程也没有设置退出条件,则相当于协程失去了控制,它占用的资源无法回收,可能会导致内存泄露。


// bad: 协程没有设置退出条件
func doWaiter(name string, second int) {
    for {
    time.Sleep(time.Duration(second) * time.Second)
    fmt.Println(name, " is ready!")
    }
}

1.1.8 不使用slice作为函数入参

slice在作为函数入参时,函数内部对于slice的修改会影响到原始数据

  
// bad
// slice作为函数入参时包含原始数组指针
func modify(array []int) {
    array[0] = 10 // 对入参slice的元素修改会影响原始数据
}

func main() {
    array := []int{1, 2, 3, 4, 5}

    modify(array)
    fmt.Println(array) // output:[10 2 3 4 5]
}

// good
// 数组作为函数入参,而不是slice
func modify(array [5]int) {
  array[0] = 10
}

func main() {
    // 传入数组,注意数组与slice的区别
    array := [5]int{1, 2, 3, 4, 5}

    modify(array)
    fmt.Println(array)
}
  

2. 文件操作

1.2.1 【必须】路径穿透检查

在进行文件操作时,如果对外部传入的文件名未做限制,可能导致任意文件读取或任意文件写入


// bad: 任意文件读取
func handler(w http.ResponseWriter, r *http.Request) {
    path := r.URL.Query()["path"][0]

    // 未过滤文件路径,可能导致任意文件读取
    data, _ := ioutil.ReadFile(path)
    w.Write(data)

    // 对外部传入的文件名变量,还需要验证是否存在../等路径穿越的文件名
    data, _ = ioutil.ReadFile(filepath.Join("/home/user/", path))
    w.Write(data)
}

// bad: 任意文件写入
func unzip(f string) {
    r, _ := zip.OpenReader(f)
    for _, f := range r.File {
        p, _ := filepath.Abs(f.Name)
        // 未验证压缩文件名,可能导致../等路径穿越,任意文件路径写入
        ioutil.WriteFile(p, []byte("present"), 0640)
    }
}

// good: 检查压缩的文件名是否包含..路径穿越特征字符,防止任意写入
func unzipGood(f string) bool {
    r, err := zip.OpenReader(f)
    if err != nil {
        fmt.Println("read zip file fail")
        return false
    }
    for _, f := range r.File {
        if !strings.Contains(f.Name, "..") {
            p, _ := filepath.Abs(f.Name)
            ioutil.WriteFile(p, []byte("present"), 0640)
        } else {
            return false
        }
    }
    return true
}

1.2.2 【必须】文件访问权限

创建文件需要根据文件的敏感性设置不同的访问权限,以防权限泄漏


ioutil.WriteFile(p, []byte("content"), 0640)

3. 系统接口

1.3.1 【必须】命令执行检查

  • 使用exec.Commandexec.CommandContextsyscall.StartProcessos.StartProcess等函数时,第一个参数(path)直接取外部输入值时,应使用白名单限定可执行的命令范围,不允许传入bashcmdsh等命令;
  • 使用exec.Commandexec.CommandContext等函数时,通过bashcmdsh等创建shell,-c后的参数(arg)拼接外部输入,应过滤n $ & ; | ‘ ” ( ) `等潜在恶意字符;

// bad
func foo() {
    userInputedVal := "&& echo 'hello'" // 假设外部传入该变量值
    cmdName := "ping " + userInputedVal

    // 未判断外部输入是否存在命令注入字符,结合sh可造成命令注入
    cmd := exec.Command("sh", "-c", cmdName)
    output, _ := cmd.CombinedOutput()
    fmt.Println(string(output))

    cmdName := "ls"
    // 未判断外部输入是否是预期命令
    cmd := exec.Command(cmdName)
    output, _ := cmd.CombinedOutput()
    fmt.Println(string(output))
}

// good
func checkIllegal(cmdName string) bool {
    if strings.Contains(cmdName, "&") || strings.Contains(cmdName, "|") || strings.Contains(cmdName, ";") ||
        strings.Contains(cmdName, "$") || strings.Contains(cmdName, "'") || strings.Contains(cmdName, "`") ||
        strings.Contains(cmdName, "(") || strings.Contains(cmdName, ")") || strings.Contains(cmdName, """) {
        return true
    }
    return false
}

func main() {
    userInputedVal := "&& echo 'hello'"
    cmdName := "ping " + userInputedVal

    if checkIllegal(cmdName) { // 检查传给sh的命令是否有特殊字符
        return // 存在特殊字符直接return
    }

    cmd := exec.Command("sh", "-c", cmdName)
    output, _ := cmd.CombinedOutput()
    fmt.Println(string(output))
}

4. 通信安全

1.4.1 【必须】网络通信采用TLS方式

明文传输的通信协议目前已被验证存在较大安全风险,被中间人劫持后可能导致许多安全风险,因此必须采用至少TLS的安全通信方式保证通信安全,例如gRPC/Websocket都使用TLS1.3。


// good
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
        w.Header().Add("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
        w.Write([]byte("This is an example server.n"))
    })

    // 服务器配置证书与私钥
    log.Fatal(http.ListenAndServeTLS(":443", "yourCert.pem", "yourKey.pem", nil))
}

4.2 【推荐】TLS启用证书验证

TLS证书应当是有效的、未过期的,且配置正确的域名,生产环境的服务端应启用证书验证。


// bad
import (
    "crypto/tls"
    "net/http"
)

func doAuthReq(authReq *http.Request) *http.Response {
    tr := &http.Transport{
        TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
    }
    client := &http.Client{Transport: tr}
    res, _ := client.Do(authReq)
    return res
}

// good
import (
    "crypto/tls"
    "net/http"
)

func doAuthReq(authReq *http.Request) *http.Response {
    tr := &http.Transport{
        TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
    }
    client := &http.Client{Transport: tr}
    res, _ := client.Do(authReq)
    return res
}

5. 敏感数据保护

1.5.1 【必须】敏感信息访问

  • 禁止将敏感信息硬编码在程序中,既可能会将敏感信息暴露给攻击者,也会增加代码管理和维护的难度
  • 使用配置中心系统统一托管密钥等敏感信息

1.5.2 【必须】敏感数据输出

  • 只输出必要的最小数据集,避免多余字段暴露引起敏感信息泄露
  • 不能在日志保存密码(包括明文密码和密文密码)、密钥和其它敏感信息
  • 对于必须输出的敏感信息,必须进行合理脱敏展示

// bad
func serve() {
    http.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
        r.ParseForm()
        user := r.Form.Get("user")
        pw := r.Form.Get("password")

        log.Printf("Registering new user %s with password %s.n", user, pw)
    })
    http.ListenAndServe(":80", nil)
}

// good
func serve1() {
    http.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
        r.ParseForm()
        user := r.Form.Get("user")
        pw := r.Form.Get("password")

        log.Printf("Registering new user %s.n", user)

        // 脱敏
        use(pw)
    })
    http.ListenAndServe(":80", nil)
}
  • 避免通过GET方法、代码注释、自动填充、缓存等方式泄露敏感信息

1.5.3 【必须】敏感数据存储

  • 敏感数据应使用SHA2、RSA等算法进行加密存储
  • 敏感数据应使用独立的存储层,并在访问层开启访问控制
  • 包含敏感信息的临时文件或缓存一旦不再需要应立刻删除

1.5.4 【必须】异常处理和日志记录


defer func () {
    if r := recover(); r != nil {
        fmt.Println("Recovered in start()")
    }
}()

对外环境禁止开启debug模式,或将程序运行日志输出到前端

6. 加密解密

1.6.1 【必须】不得硬编码密码/密钥

在进行用户登陆,加解密算法等操作时,不得在代码里硬编码密钥或密码,可通过变换算法或者配置等方式设置密码或者密钥。

1.6.2 【必须】密钥存储安全

  • 在使用对称密码算法时,需要保护好加密密钥。当算法涉及敏感、业务数据时,可通过非对称算法协商加密密钥。其他较为不敏感的数据加密,可以通过变换算法等方式保护密钥。

1.6.3 【必须】不使用弱密码算法

在使用加密算法时,不建议使用加密强度较弱的算法。


// bad
crypto/des,crypto/md5,crypto/sha1,crypto/rc4等。

// good
crypto/rsa,crypto/aes等。

7. 正则表达式

1.7.1 【推荐】使用regexp进行正则表达式匹配

  • 正则表达式编写不恰当可被用于DoS攻击,造成服务不可用,推荐使用regexp包进行正则表达式匹配。regexp保证了线性时间性能和优雅的失败:对解析器、编译器和执行引擎都进行了内存限制。但regexp不支持以下正则表达式特性,如业务依赖这些特性,则regexp不适合使用。

// good
matched, err := regexp.MatchString(a.b, "aaxbb")
fmt.Println(matched) // true
fmt.Println(err)     // nil

二、服务端代码

1. 输入校验

2.1.1 【必须】按类型进行数据校验

所有外部输入的参数,应使用validator进行白名单校验,校验内容包括但不限于数据长度、数据范围、数据类型与格式,校验不通过的应当拒绝


// good
import (
    "fmt"
    "github.com/go-playground/validator/v10"
)

var validate *validator.Validate

func validateVariable() {
    myEmail := "abc@tencent.com"
    errs := validate.Var(myEmail, "required,email")
    if errs != nil {
        fmt.Println(errs)
        return
        //停止执行
    }
    // 验证通过,继续执行
    ...
}

func main() {
    validate = validator.New()
    validateVariable()
}

2. SQL操作

2.2.1 【必须】SQL语句默认使用预编译并绑定变量

  • 使用database/sql的prepare、Query或使用GORM等ORM执行SQL操作

import (
    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/sqlite"
)

type Product struct {
    gorm.Model
    Code  string
    Price uint
}

...
var product Product
...
db.First(&product, 1)
  • 使用参数化查询,禁止拼接SQL语句,另外对于传入参数用于order by或表名的需要通过校验

// bad
import (
    "database/sql"
    "fmt"
    "net/http"
)

func handler(db *sql.DB, req *http.Request) {
    q := fmt.Sprintf("SELECT ITEM,PRICE FROM PRODUCT WHERE ITEM_CATEGORY='%s' ORDER BY PRICE",
        req.URL.Query()["category"])
    db.Query(q)
}

// good
func handlerGood(db *sql.DB, req *http.Request) {
    // 使用?占位符
    q := "SELECT ITEM,PRICE FROM PRODUCT WHERE ITEM_CATEGORY='?' ORDER BY PRICE"
    db.Query(q, req.URL.Query()["category"])
}

3. Web跨域

2.3.1 【必须】跨域资源共享CORS限制请求来源

  • CORS请求保护不当可导致敏感信息泄漏,因此应当严格设置Access-Control-Allow-Origin使用同源策略进行保护

// good
c := cors.New(cors.Options{
    AllowedOrigins:   []string{"http://qq.com", "https://qq.com"},
    AllowCredentials: true,
    Debug:            false,
})

// 引入中间件
handler = c.Handler(handler)

5. 响应输出

2.5.1 【必须】设置正确的HTTP响应包类型

  • 响应头Content-Type与实际响应内容,应保持一致。如:API响应数据类型是json,则响应头使用application/json;若为xml,则设置为text/xml

2.5.2 【必须】添加安全响应头

  • 所有接口、页面,添加响应头 X-Content-Type-Options: nosniff
  • 所有接口、页面,添加响应头X-Frame-Options 。按需合理设置其允许范围,包括:DENYSAMEORIGINALLOW-FROM origin。用法参考:MDN文档

6. 会话管理

2.6.1 【必须】安全维护session信息

  • 用户登录时应重新生成session,退出登录时应清理session

import (
    "github.com/gorilla/handlers"
    "github.com/gorilla/mux"
    "net/http"
)

// 创建cookie
func setToken(res http.ResponseWriter, req *http.Request) {
    expireToken := time.Now().Add(time.Minute * 30).Unix()
    expireCookie := time.Now().Add(time.Minute * 30)

    //...

    cookie := http.Cookie{
        Name:     "Auth",
        Value:    signedToken,
        Expires:  expireCookie, // 过期失效
        HttpOnly: true,
        Path:     "/",
        Domain:   "127.0.0.1",
        Secure:   true,
    }

    http.SetCookie(res, &cookie)
    http.Redirect(res, req, "/profile", 307)
}

// 删除cookie
func logout(res http.ResponseWriter, req *http.Request) {
    deleteCookie := http.Cookie{
        Name:    "Auth",
        Value:   "none",
        Expires: time.Now(),
    }
    http.SetCookie(res, &deleteCookie)
    return
}

2.6.2 【必须】CSRF防护

  • 涉及系统敏感操作或可读取敏感信息的接口应校验Referer或添加csrf_token

// good
import (
    "github.com/gorilla/csrf"
    "github.com/gorilla/mux"
    "net/http"
)

func main() {
    r := mux.NewRouter()
    r.HandleFunc("/signup", ShowSignupForm)
    r.HandleFunc("/signup/post", SubmitSignupForm)
    // 使用csrf_token验证
    http.ListenAndServe(":8000",
    csrf.Protect([]byte("32-byte-long-auth-key"))(r))
}

7. 访问控制

2.7.1【必须】默认鉴权

  • 除非资源完全可对外开放,否则系统默认进行身份认证,使用白名单的方式放开不需要认证的接口或页面。
  • 根据资源的机密程度和用户角色,以最小权限原则,设置不同级别的权限,如完全公开、登录可读、登录可写、特定用户可读、特定用户可写等
  • 涉及用户自身相关的数据的读写必须验证登录态用户身份及其权限,避免越权操作– 伪代码 select id from table where id=:id and userid=session.userid
  • 没有独立账号体系的外网服务使用QQ微信登录,内网服务使用统一登录服务登录,其他使用账号密码登录的服务需要增加验证码等二次验证

8. 并发保护

2.8.1【必须】禁止在闭包中直接调用循环变量

  • 在循环中启动协程,当协程中使用了循环的索引值,由于多个协程同时共享一个变量,产生资源竞争,造成执行结果异常

// bad
func main() {
    runtime.GOMAXPROCS(runtime.NumCPU())
    var group sync.WaitGroup

    for i := 0; i < 5; i++ {
        group.Add(1)
        go func() {
            defer group.Done()
            fmt.Printf("%-2d", i) // 这里打印的i不是所期望的
        }()
    }
    group.Wait()
}

// good
func main() {
    runtime.GOMAXPROCS(runtime.NumCPU())
    var group sync.WaitGroup

    for i := 0; i < 5; i++ {
        group.Add(1)
        go func(j int) {
            defer func() {
                if r := recover(); r != nil {
                    fmt.Println("Recovered in start()")
                }
                group.Done()
            }()
            fmt.Printf("%-2d", j) // 闭包内部使用局部变量
        }(i) // 把循环变量显式地传给协程
    }
    group.Wait()
}

2.8.2【必须】禁止并发写map


// bad
func main() {
    m := make(map[int]int)
    // 并发读写
    go func() {
        for {
            _ = m[1]
        }
    }()
    go func() {
        for {
            m[2] = 1
        }
    }()
    select {}
}

// good
func main() {
    m := make(map[int]int)
    myLock := new(sync.Mutex)
    // 并发读写
    go func() {
        for {
            myLock.Lock()
            _ = m[1]
            myLock.Unlock()
        }
    }()
    go func() {
        for {
            myLock.Lock()
            m[2] = 1
            myLock.Unlock()
        }
    }()
    select {}
}

2.8.3【必须】确保并发安全

敏感操作如果未作并发安全限制,可导致数据读写异常,造成业务逻辑限制被绕过。可通过同步锁或者原子操作进行防护。

  • 通过同步锁共享内存

// good
var count int

func Count(lock *sync.Mutex) {
    lock.Lock() // 加写锁
    count++
    fmt.Println(count)
    lock.Unlock() // 解写锁,任何一个Lock()或RLock()均需要保证对应有Unlock()或RUnlock()
}

func main() {
    lock := &sync.Mutex{}
    for i := 0; i < 10; i++ {
        go Count(lock) // 传递指针是为了防止函数内的锁和调用锁不一致
    }
    for {
        lock.Lock()
        c := count
        lock.Unlock()
        runtime.Gosched() // 交出时间片给协程
        if c > 10 {
            break
        }
    }
}
  • 通过sync/atomic执行原子操作

// good
import (
    "sync"
    "sync/atomic"
)

func main() {
    type Map map[string]string
    var m atomic.Value
    m.Store(make(Map))
    var mu sync.Mutex // used only by writers
    read := func(key string) (val string) {
        m1 := m.Load().(Map)
        return m1[key]
    }
    insert := func(key, val string) {
        mu.Lock() // 与潜在写入同步
        defer mu.Unlock()
        m1 := m.Load().(Map) // 导入struct当前数据
        m2 := make(Map)      // 创建新值
        for k, v := range m1 {
            m2[k] = v
        }
        m2[key] = val
        m.Store(m2) // 用新的替代当前对象
    }
    _, _ = read, insert
}
正文完
 1
dustin
版权声明:本站原创文章,由 dustin 2022-11-20发表,共计12280字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)
验证码