Go语言安全编码规范-翻译
本文翻译原文由:blood_zer0、Lingfighting完成 如果翻译的有问题:联系我(Lzero2012)。匆忙翻译肯定会有很多错误,欢迎大家一起讨论Go语言安全能力建设。
介绍
Go语言-Web应用程序安全编码实践是为了给任何使用Go进行编程与Web开发的人员提供指导。
这本书是Checkmarx安全研究团队共同努力的结晶,它遵循OWASP安全编码实践快速参考指南。
这本书主要的目的是为了帮助开发人员避免常见错误,同时通过"实践方法"学习编程语言心得。本书提供了关于"如何安全执行"详细信息,展示了在开发过程中可能出现的安全问题。
关于Checkmarx
Checkmarx是一家应用程序软件安全公司,公司使命是为企业组织提供应用程序安全测试产品与服务,提高开发人员交付安全应用程序。拥有1000家客服包括全球十大软件供应商中的物价,美国顶级银行中的四家,以及许多财富500强和政府机构,包括SAP、三星和Salesforce.com
关于Checkmarx的更多信息,可以访问checkmarx.com或者关注我们的twitter:@checkmarx
关于OWASP安全编程实践
"安全编程实践快速参考指南"是OWASP开源Web安全项目。它是一种"技术无关的通用软件安全编程实践集,采用全面的列表格式,可以集成到开发生命周期中"
OWASP本身是"一个开放的社区,致力于使组织能够构思、开发、获取、操作和维护可信任的应用程序。所有的OWASP工具、文档、论坛和章节都是免费的,并且对任何有兴趣提高应用程序安全性的人开放。"
输入验证
在Web应用程序安全性中,如果未对用户输入及相关数据进行验证则会存在安全风险。我们通过"输入验证"与"输出处理"技术来解决这些问题。根据服务器的功能,应在应用程序的每个层中执行这些验证。重要的一点是,所有数据验证程序必须在可信系统上(即在服务器上)完成。
如"OWASP SCP快速参考指南"中所述,有16个要点涵盖了开发人员在处理输入验证时应注意的问题。在开发应用程序时缺乏对这些安全风险的考虑是注入"OWASP Top 10"中排名第一的主要原因之一。
用户交互是Web应用程序当前开发范例的主要内容。随着Web应用程序内容和可能性越来越丰富,用户交互和提交的用户数据也会增加。正是在这种背景下,输入验证起着重要作用。
当应用程序处理用户数据时,默认情况下提交的数据必须被视为不安全,并且只有在进行了适当的安全检查后才能接受。还必须将数据源标识为受信任或不受信任,并且在不受信任的源的情况下,必须进行验证检查。
验证
在验证检查中,根据一组条件检查用户输入,以保证用户确实输入了预期数据。
重要信息:如果验证失败,则必须拒绝输入。
这不仅从安全角度而且从数据一致性和完整性的角度来看很重要,因为数据通常用于各种系统和应用程序。
本文列出了开发人员在Go中开发Web应用程序时应注意的安全风险。
用户交互
允许用户输入的应用程序的任何部分都存在潜在的安全风险。 问题不仅可能来自寻求危害应用程序的方法,也可能来自人为错误导致的错误输入(统计上,大多数无效数据情况通常是由人为错误引起的)。 在Go中,有几种方法可以防止此类问题。
Go具有本机库,其中包括有助于确保不会发生此类错误的方法。 在处理字符串时,我们可以使用类似以下示例的包:
-
strconv
包处理到其他数据类型的字符串转换。- Atoi
- ParseBool
- ParseFloat
- ParseInt
-
strings
包包含处理字符串及其属性的所有函数。- Trim
- ToLower
- ToTitle
-
regexp
包支持正则表达式以适应自定义格式。 utf8
包实现函数和常量以支持以UTF-8编码的文本。它包括在runes和utf-8字节序列之间转换的函数。- Valid(验证UTF-8编码)
- ValidRune(验证UTF-8编码)
- ValidString(验证UTF-8编码)
- EncodeRune(UTF-8编码)
- DecodeLastRune(UTF-8解码)
- DecodeLastRuneInString(UTF-8解码)
注意:Form被go视为字符串值的映射。
确保数据有效性的其他技术包括:
- 白名单-尽可能根据允许的字符白名单验证输入。请参见Validation - Strip tags。
- 边界检查-应验证数据和数字长度。
- 字符转义-用于特殊字符,如独立引号。
- 数字验证-如果输入是数字。
- 检查空字节-(%00)
- 检查新行字符-%0d,%0a,\r\n
- 检查路径更改字符-../或\..
- 检查扩展的UTF-8-检查特殊字符的可选表示形式
注意:确保HTTP请求和响应头只包含ASCII字符。
存在处理go中安全性的第三方软件包:
- Gorilla是Web应用程序安全性最常用的包之一。它支持websockets、cookie会话、rpc等。
- Form 将url.values解码为go值,并将go值编码为url.values。Dual Array和Full map支持。
- Validator 进行Go 结构体和字段验证,包括跨字段、跨结构体、映射以及切片和数组。
文件操作
当需要使用文件时(read或write文件)也应该进行验证,因为大多数文件操作操作都处理用户数据。
其他文件检查过程包括"文件存在性检查",以验证文件名是否存在。
附加文件信息在文件管理部分,有关错误处理的信息可以在文档的错误处理部分找到。
数据源
当数据从受信任的源传递到不受信任的源时,应进行完整性检查。这保证了数据没有被篡改,我们正在接收预期的数据。其他数据源检查包括:
- 跨系统一致性检查
- Hash统计
- 参照完整性
注意:在现代关系数据库中,如果主键字段中的值不受数据库内部机制的约束,那么应该对它们进行验证:
- 唯一性检查
- 表查询检查
POST验证操作
根据数据验证的最佳实践,输入验证只是数据验证指南的第一部分。因此,还应执行验证后操作。使用的验证后操作因上下文而异,分为三类:
- 强制执行:为了更好地保证我们的应用和数据,存在着几种执行类型。
- 通知用户提交的数据不符合要求,因此应修改数据以符合要求。
- 在服务器端修改用户提交的数据,而不通知用户所做的更改,这最适用于具有交互使用的系统。
注意:后者主要用于外观更改(修改用户敏感数据可能导致截断等问题,从而导致数据丢失)。
- 咨询:建议操作通常允许输入不变的数据,但消息来源参与者被告知所述数据存在问题。这最适用于非交互式系统。
- 验证:验证是指建议操作中的特殊情况。在这些情况下,用户提交数据,源参与者要求用户验证所述数据并建议更改。然后,用户接受这些更改或保留其原始输入。
一个简单的方法来说明这是一个账单地址表单,用户输入他的地址,系统建议与帐户相关的地址。然后,用户接受其中一个建议或发送到最初输入的地址。
处理
处理是指删除或替换提交的数据的过程。在处理数据时,在进行了正确的验证检查之后,通常会采取一个额外的步骤来加强数据安全性,即处理。
最常用的处理方法如下:
将小于字符的单个字符转化为实体
在本机包HTML中,有两个用于清理的函数:一个用于转义HTML文本,另一个用于取消转义HTML。函数escapeString()
接受一个字符串并返回带有特殊转义字符的相同字符串。即,<变为<;。请注意,此函数只转义以下五个字符:<、>、&、\'和\'。相反,还有unescapeString()
函数可以从实体转换为字符。
删除所有标签
虽然html/template
包有striptags()
函数,但它是未导出的。由于没有其他本机包具有去除所有标记的功能,因此可以选择使用第三方库,或者复制整个函数以及它的私有类和函数。
一些第三方库可以实现这一点:
- https://github.com/kennygrant/sanitize
- https://github.com/maxwells/sanitize
- https://github.com/microcosm-cc/bluemonday
删除换行符、制表符和多余的空格
text/template
和html/template
包括一种从模板中删除空白的方法,方法是在操作的分隔符内使用减号。
使用源代码执行模板
{{- 23}} < {{45 -}}
将导致以下输出
23<45
注意:如果减号不在开始动作分隔符{{之后或结束动作分隔符之前}},减号-将应用于该值。
模板源
{{ -3 }}
输出
-3
URL请求路径
在net/http
包中有一个称为ServeMux
的HTTP请求多路复用器类型。它用于将传入请求与注册模式匹配,并调用与请求的URL最匹配的处理程序。除了主要目的外,它还负责清理URL请求路径,重定向包含的任何请求。.或..元素或重复斜杠到一个等效的、更清晰的URL。
一个简单的Mux示例说明:
func main() {
mux := http.NewServeMux()
rh := http.RedirectHandler("http://yourDomain.org", 307)
mux.Handle("/login", rh)
log.Println("Listening...")
http.ListenAndServe(":3000", mux)
}
注意:请记住,ServeMux
不会更改connect请求的URL请求路径,因此,如果不限制允许的请求方法,可能会使应用程序容易受到路径遍历攻击。
第三方软件包:
输出编码
虽然在OWASP SCP快速参考指南中只有6个项目符号部分,但是在Web应用程序开发中,输出编码的错误做法非常普遍,因此导致了第一大漏洞:注入。
随着Web应用程序变得复杂和丰富,它们拥有的数据源越多:用户、数据库、三十方服务等。在某个时间点,收集到的数据被输出到具有特定上下文的某些媒体(如Web浏览器)。如果没有强的输出编码策略,则正好发生注入。
当然,您已经听说了我们将在本节中讨论的所有安全问题,但是您真的知道这些问题是如何发生的和/或如何避免的吗?
XSS跨站脚本
大多数开发人员都听说过,但大多数人从未尝试过使用XSS开发Web应用程序。
自2003年以来,跨站点脚本一直位于OWASP的前10位,它仍然是一个常见的漏洞。2013年的版本非常详细地介绍了XSS:攻击向量、安全弱点、技术影响和业务影响。
简而言之
如果不确保所有用户提供的输入都被正确转义,或者在将该输入包含到输出页之前,不通过服务器端输入验证来验证其安全性,那么您将很容易受到攻击。(source)
Go,就像其他多用途编程语言一样,尽管文档中明确说明了如何使用html/template
包,但它拥有所有需要处理的东西,并使您容易受到XSS的攻击。很容易找到使用net/http和io包的"hello world"示例,在不了解它的情况下,您很容易受到XSS的攻击。
代码:
package main
import "net/http"
import "io"
func handler (w http.ResponseWriter, r *http.Request) {
io.WriteString(w, r.URL.Query().Get("param1"))
}
func main () {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
此代码段创建并启动一个HTTP服务器,侦听端口8080(main()),处理服务器根目录(/)上的请求。
处理请求的handler()函数需要一个查询字符串参数param1
,然后将其值写入响应流(w)。
由于Content-Type
HTTP响应头,将使用go http.detectcontenttype默认值,该值遵循WhatWG规范。
因此,使param1
等于"test",将导致Content-Type
HTTP响应头以text/plain格式发送。
但如果param1
的第一个字符是<h1>
,则Content-Type
将是text/html。
您可能认为,使param1
等于任何HTML标记都会导致相同的行为,但它不会:使param1
等于<h2>
、<span>
或<form>
将使Content-Type
以plain/text形式发送,而不是以预期的text/html形式发送。
现在,让我们使param1
等于
<script>alert(1)</script>
根据whatwg-spec,内容类型http-response-header将以文本/html形式发送,将呈现param1值,并…这里是XSS跨站点脚本。
在与谷歌讨论了这一情况后,他们告诉我们:
它实际上非常方便,旨在能够打印HTML并自动设置内容类型。我们希望程序员将使用HTML/模板进行适当的转义。
谷歌声明开发人员负责清理和保护他们的代码。我们完全同意,但是在安全性是优先考虑的语言中,除了默认的text/plain之外,允许自动设置Content-Type
并不是最好的方式。
让我们澄清一下:text/plain
和/或text/template
包不会让您远离XSS,因为它不会清理用户输入。
package main
import "net/http"
import "text/template"
func handler(w http.ResponseWriter, r *http.Request) {
param1 := r.URL.Query().Get("param1")
tmpl := template.New("hello")
tmpl, _ = tmpl.Parse(`{{define "T"}}{{.}}{{end}}`)
tmpl.ExecuteTemplate(w, "T", param1)
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
使param1
等于<h1>
将导致内容类型作为text/html发送,这使您容易受到XSS的攻击。
将text/template包替换为html/template包,您就可以安全地继续了。
package main
import "net/http"
import "html/template"
func handler(w http.ResponseWriter, r *http.Request) {
param1 := r.URL.Query().Get("param1")
tmpl := template.New("hello")
tmpl, _ = tmpl.Parse(`{{define "T"}}{{.}}{{end}}`)
tmpl.ExecuteTemplate(w, "T", param1)
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
当param1等于<h1>
时,不仅Content-Type
HTTP响应头将以text/plain格式发送。
但是,param1也
被正确编码到输出媒体:浏览器。
SQL注入
由于缺乏正确的输出编码,另一个常见的注入是SQL注入,这主要是由于一个旧的错误做法:字符串串联。
简而言之:只要将包含任意字符(例如对数据库管理系统有特殊意义的字符)的值的变量简单地添加到(部分)SQL查询中,就容易受到SQL注入的攻击。
假设您有如下查询:
ctx := context.Background()
customerId := r.URL.Query().Get("id")
query := "SELECT number, expireDate, cvv FROM creditcards WHERE customerId = " + customerId
row, _ := db.QueryContext(ctx, query)
你将毁了它。
当提供有效的customerId时,您将只列出该客户的信用卡,但如果customerId变为1或1=1会怎么样?
您的查询将如下所示:
SELECT number, expireDate, cvv FROM creditcards WHERE customerId = 1 OR 1=1
您将转储所有表记录(是的,任何记录的1=1都是真的)!
只有一种方法可以保证数据库的安全:Prepared Statements。
ctx := context.Background()
customerId := r.URL.Query().Get("id")
query := "SELECT number, expireDate, cvv FROM creditcards WHERE customerId = ?"
stmt, _ := db.QueryContext(ctx, query, customerId)
注意到占位符了?以及您的查询方式:
- 可读性强
- 较短
- 安全的
准备好的语句中的占位符语法是特定于数据库的。例如,比较mysql、postgresql和oracle:
MySQL | PostgreSQL | Oracle |
---|---|---|
WHERE col = ? | WHERE col = $1 | WHERE col = :col |
VALUES(?, ?, ?) | VALUES($1, $2, $3) | VALUES(:val1, :val2, :val3) |
检查本指南中的"数据库安全性"部分,以获取有关此主题的详细信息。
认证与密码管理
认证与密码管理
OWASP安全编码实践是一个便利的文档,可以帮助开发人员验证在项目实现期间是否遵循了所有的最佳实践。身份验证和密码管理是任何系统的关键部分,从用户注册到凭证存储、密码重置和个人资源访问都有详细介绍。
为了更深入的细节,可以对一些指导原则进行分组。这里源代码示例来说明。
经验规则
让我们从经验规则开始:"所有身份验证控制都必须在受信任的系统上强制执行",通常是运行应用程序后端的服务器。
为了系统的简单性和减少故障点,您应该使用标准的和经过测试的认证服务:通常框架拥有这样的模块,并且有许多人开发、维护和使用它们,我们鼓励您使用它们作为一种集中的认证机制。不过,您应该"仔细检查代码以确保它不受任何恶意代码的影响",并确保它遵循最佳实践。
身份验证的过程不应该是资源自身来执行它,相反,应该使用"从集中式身份验证控件重定向"。小心处理重定向:您应该只重定向到本地和/或安全资源。
当认证需要"连接到涉及敏感信息或功能的外部系统"时,它不仅应该由应用程序的用户使用,而且还应该由您自己的应用程序使用。在这种情况下,"访问应用程序外部服务的身份验证凭据应加密并存储在受信任系统(如服务器)上的受保护位置。存储在代码中不是安全的位置"。
身份验证数据通信
在本节中,"通信"在更广泛的意义上使用,包括用户体验(UX)和CS通信。
不仅"密码输入应在用户屏幕上隐藏",而且"记住我"功能应禁用。
您可以使用输入字段type="password"并将autocomplete属性设置为off来完成这两项工作。
<input type="password" name="passwd" autocomplete="off" />
身份验证凭据只能在HTTP POST请求上使用加密连接(HTTPS)发送。加密连接的例外可能是电子邮件重置相关联的临时密码。
虽然通过tls/ssl(https)的HTTP GET请求看起来和HTTP POST请求一样安全,但一般情况下,HTTP服务器(如apache2、nginx)会将请求的URL写入访问日志。
xxx.xxx.xxx.xxx - - [27/Feb/2017:01:55:09 +0000] "GET /?username=user&password=70pS3cure/oassw0rd HTTP/1.1" 200 235 "-" "Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:51.0) Gecko/20100101 Firefox/51.0"
前端代码如下:
<form method="post" action="https://somedomain.com/user/signin" autocomplete="off">
<input type="hidden" name="csrf" value="CSRF-TOKEN" />
<label>Username <input type="text" name="username" /></label>
<label>Password <input type="password" name="password" /></label>
<input type="submit" value="Submit" />
</form>
在处理身份验证错误时,应用程序不应公开身份验证数据的哪一部分不正确。不要使用"无效用户名"或"无效密码",只需交替使用"无效用户名和/或密码":
<form method="post" action="https://somedomain.com/user/signin" autocomplete="off">
<input type="hidden" name="csrf" value="CSRF-TOKEN" />
<div class="error">
<p>Invalid username and/or password</p>
</div>
<label>Username <input type="text" name="username" /></label>
<label>Password <input type="password" name="password" /></label>
<input type="submit" value="Submit" />
</form>
对于一般性信息,您不披露:
- 注册人:"无效密码"表示用户名存在。
- 系统的工作方式:"无效密码"可能会显示应用程序的工作方式,首先查询数据库中的用户名,然后比较内存中的密码。
验证和存储部分提供了如何执行验证数据验证(和存储)的示例。
成功登录后,应通知用户上次成功或不成功的访问日期/时间,以便用户能够检测和报告可疑活动。有关日志记录的更多信息可以在文档的错误处理和日志记录中找到。此外,为了防止攻击,建议在检查密码时使用恒定时间比较功能,包括分析具有不同输入的多个请求之间的时间差。在这种情况下,表单record==password比较不匹配的第一个字符处将会返回false,提交的密码时间越近,响应时间越长。通过利用这个漏洞,攻击者可以猜测密码。请注意,即使记录不存在,我们也总是强制执行带有空值的subtle.ConstantTimeCompare以便与用户输入进行比较。
验证与存储
验证
本节的关键主题是身份验证数据存储,因为用户帐户数据库经常在Internet上泄漏,这是不可取的。当然,这并不能保证发生,但在这种情况下,如果正确存储身份验证数据,特别是密码,就可以避免附带的损害。
首先,让我们明确“所有认证控制都应该安全失败”。建议您阅读所有其他身份验证和密码管理部分,因为它们包括有关报告错误身份验证数据和如何处理日志记录的建议。
另一个初步建议是:对于顺序认证的实现(像Google现在做的那样),验证应该只在所有数据输入完成后,在可信系统(如服务器)上进行。
安全存储密码理论
package main
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"context"
"fmt"
)
const saltSize = 32
func main() {
ctx := context.Background()
email := []byte("[email protected]")
password := []byte("47;u5:B(95m72;Xq")
// create random word
salt := make([]byte, saltSize)
_, err := rand.Read(salt)
if err != nil {
panic(err)
}
// let\'s create SHA256(password+salt)
hash := sha256.New()
hash.Write(password)
hash.Write(salt)
// this is here just for demo purposes
//
// fmt.Printf("email : %s\n", string(email))
// fmt.Printf("password: %s\n", string(password))
// fmt.Printf("salt : %x\n", salt)
// fmt.Printf("hash : %x\n", hash.Sum(nil))
// you\'re supposed to have a database connection
stmt, err := db.PrepareContext(ctx, "INSERT INTO accounts SET hash=?, salt=?, email=?")
if err != nil {
panic(err)
}
result, err := stmt.ExecContext(ctx, email, h, salt)
if err != nil {
panic(err)
}
}
然而,这种方法有几个缺陷,不应该使用。本文仅用一个实例来说明这一理论。下一节将解释如何在现实生活中正确设置密码。
安全存储密码实践
下面的示例演示如何使用bcrypt,这对于大多数情况都是足够好的。BCRYPT的优点是使用起来更简单,因此不易出错。
package main
import (
"database/sql"
"context"
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
ctx := context.Background()
email := []byte("[email protected]")
password := []byte("47;u5:B(95m72;Xq")
// Hash the password with bcrypt
hashedPassword, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
if err != nil {
panic(err)
}
// this is here just for demo purposes
//
// fmt.Printf("email : %s\n", string(email))
// fmt.Printf("password : %s\n", string(password))
// fmt.Printf("hashed password: %x\n", hashedPassword)
// you\'re supposed to have a database connection
stmt, err := db.PrepareContext(ctx, "INSERT INTO accounts SET hash=?, email=?")
if err != nil {
panic(err)
}
result, err := stmt.ExecContext(ctx, hashedPassword, email)
if err != nil {
panic(err)
}
}
bcrypt还提供了一种简单而安全的方法来比较明文密码和已经散列的密码:
ctx := context.Background()
// credentials to validate
email := []byte("[email protected]")
password := []byte("47;u5:B(95m72;Xq")
// fetch the hashed password corresponding to the provided email
record := db.QueryRowContext(ctx, "SELECT hash FROM accounts WHERE email = ? LIMIT 1", email)
var expectedPassword string
if err := record.Scan(&expectedPassword); err != nil {
// user does not exist
// this should be logged (see Error Handling and Logging) but execution
// should continue
}
if bcrypt.CompareHashAndPassword(password, []byte(expectedPassword)) != nil {
// passwords do not match
// passwords mismatch should be logged (see Error Handling and Logging)
// error should be returned so that a GENERIC message "Sign-in attempt has
// failed, please check your credentials" can be shown to the user.
}
密码策略
密码是一种历史资产,是大多数认证系统的一部分,也是攻击者的头号目标。
很多时候一些服务会泄露用户的信息,尽管电子邮件地址和其他个人数据也泄露了,但最大的问题是密码。为什么?因为密码不容易管理和记忆,用户倾向于使用弱密码(例如“123456”)他们可以很容易记住,也可以在不同的服务中使用相同的密码。
如果您的应用程序登录需要密码,您可以做的最好的事情是"强制执行密码复杂性要求,要求使用字母以及数字和/或特殊字符"。密码长度也应该强制要求:"通常使用8个字符,但16个字符更好,或者考虑使用多个单词的密码短语"。
当然,前面的指导原则都不会阻止用户重新使用相同的密码。最好的办法是"强制更改密码",防止密码重复使用。关键系统可能需要更频繁的更改,必须对重置之间的时间进行管理控制”。
重置
即使您没有应用任何额外的密码策略,用户仍然需要能够重置他们的密码。这种机制与注册或登录一样重要,我们鼓励您遵循最佳实践,确保您的系统不会泄露敏感数据,也不会受到危害。
"密码更改时间不能少于1天"。这样可以防止对密码重复使用的攻击。每当使用"基于电子邮件的重置,只发送电子邮件到预先注册的地址与临时链接/密码",这个链接应该有一个很短的到期时间。
每当请求密码重置时应通知用户。同样临时密码也应该在下次使用时更改。
密码重置的一个常见做法是"安全问题",其答案以前是由帐户所有者配置的。密码重置问题应支持足够的随机答案:询问"最喜爱的书"?可能答案总会是"圣经",这使得这个安全问题成为一个坏问题。
其它指南
身份验证是任何系统的关键部分,因此您应该始终采用正确和安全的做法。以下是使您的认证系统更具弹性的一些指导原则:
- 在执行关键操作之前重新验证用户身份;
- 对高度敏感或高价值交易帐户使用多因素身份验证;
- 利用相同的密码,实施监控以识别针对多个用户帐户的攻击。当用户ID可以被获取或猜测时,这种攻击模式用于绕过标准锁定;
- 更改供应商提供的所有默认密码和用户ID或禁用关联帐户;
- 在已确定的无效登录尝试次数(例如:五次尝试是常见的)后强制禁用帐户。必须禁用该帐户一段时间,这段时间必须足以阻止对凭据的野蛮猜测,但不能长到允许执行拒绝服务攻击。
Session管理
在本节中,我们会根据OWASP安全编码实践来介绍会话管理的重要内容。提供了一个示例以及实践原理概述。除此之外,还有一个包含完整程序代码的文件夹。会话进程如图所示:
在处理会话管理时,应用程序应该只识别服务器中的会话管理控件,并在在受信任的系统上创建会话。在提供的代码示例中,我们的应用程序使用JWT生成一个会话,代码如下:
// create a JWT and put in the clients cookie
func setToken(res http.ResponseWriter, req *http.Request) {
...
}
我们必须确保用于生成会话标识符的算法是足够随机的,以防止会话被暴力破解,代码如下:
...
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, _ := token.SignedString([]byte("secret")) //our secret
...
既然又了足够强的令牌,我们必须给cookie设置Domain
、Path
、Expires
、HTTPOnly
、Secure
等参数。通常情况下,我们把Expires值设置为30分钟以此来降低应用程序风险。
// Our cookie parameter
cookie := http.Cookie{
Name: "Auth",
Value: signedToken,
Expires: expireCookie,
HttpOnly: true,
Path: "/",
Domain: "127.0.0.1",
Secure: true
}
http.SetCookie(res, &cookie) //Set the cookie
每次成功登录后都会生成新的会话,历史会话将不会被重新使用,即使它没有过期。我们还可以使用Expire参数强制定期终止会话,以防止会话劫持。Cookie的另外一个重要因素是不允许同一用户同时登录,这可以通过保存登录用户列表完成,将新的登录用户名与列表进行对比,登录用户列表通常保存在数据库中。
会话标识符不允许存储在URL中,仅允许保存在HTTP Cookie头中。一个不好的列子就是使用GET传递会话标识符参数。会话数据还必须受到保护,以防服务器的其它用户未经授权直接访问。
HTTP改为HTTPS,防止网络嗅探会话的MITM攻击。最佳实践是在所有的请求中使用HTTPS,代码如下:
err := http.ListenAndServeTLS(":443", "cert/cert.pem", "cert/key.pem", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
在高敏感或关键操作的请求中,应该为每个请求而不是每个会话生成令牌。始终确保令牌的随机性,并且足有足够的安全长度防止暴力破解。
在会话管理中要考虑的最后一个方面是注销功能。应用程序应该提供一个从所有需要身份验证的页面注销的方法,并完全终止关联的会话和链接。示例中,当用户注销时,cookie需要从客户端删除,也需要从服务端删除。
...
cookie, err := req.Cookie("Auth") //Our auth token
if err != nil {
res.Header().Set("Content-Type", "text/html")
fmt.Fprint(res, "Unauthorized - Please login <br>")
fmt.Fprintf(res, "<a href=\"login\"> Login </a>")
return
}
...
完整的例子,请访问:session.go
访问控制
在处理访问控制时,要采取的第一步是仅使用受信任的系统对象进行访问授权决策。在会话管理部分提供的示例中,我们使用JWT实现了这一点。JSONWeb令牌在服务器端生成会话令牌。
// create a JWT and put in the clients cookie
func setToken(res http.ResponseWriter, req *http.Request) {
//30m Expiration for non-sensitive applications - OWASP
expireToken := time.Now().Add(time.Minute * 30).Unix()
expireCookie := time.Now().Add(time.Minute * 30)
//token Claims
claims := Claims{
{...}
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, _ := token.SignedString([]byte("secret"))
然后我们可以存储和使用这个令牌来验证用户并强制我们的访问控制模型。
用于访问授权的组件应该是一个单一的、在站点范围内使用的组件。这包括调用外部授权服务的库。
如果出现故障,访问控制应安全失效。在Go中,我们可以使用Defer来实现这一点。有关详细信息,请参阅文档的错误日志部分。
如果应用程序无法访问其配置信息,则应拒绝对应用程序的所有访问。
应该对每个请求实施授权控制,包括服务器端脚本以及来自客户端技术(如Ajax或Flash)的请求。
正确地将特权逻辑与应用程序代码的其余部分分开也是很重要的。
为防止未经授权的用户访问,必须执行访问控制的其他重要操作包括:
- 文件和其他资源
- 受保护的URL
- 受保护功能
- 直接对象引用
- 服务
- 应用程序数据
- 用户和数据属性以及策略信息
在提供的示例中,测试简单的直接对象引用。此代码基于会话管理中的示例构建。
在实现这些访问控制时,重要的是验证访问控制规则的服务器端实现和表示层表示是否匹配。
如果状态数据需要存储在客户端,则需要使用加密和完整性检查以防止篡改。
应用程序逻辑流必须符合业务规则。
处理事务时,单个用户或设备在给定时间段内可以执行的事务数必须高于业务要求,但必须足够低,以防止用户执行DoS类型的攻击。
重要的是要注意,仅使用referer HTTP头不足以验证授权,应仅用作补充检查。
对于经过长时间验证的会话,应用程序应定期重新评估用户的授权,以验证用户的权限是否未更改。如果权限已更改,请注销用户并强制他们重新进行身份验证。
为了遵守安全程序,用户帐户还应该有一种审计方法。(例如,在密码过期30天后禁用用户帐户)。
应用程序还必须支持在用户的授权被撤销时禁用帐户和终止会话。(例如角色变更、就业状况等)。
当支持外部服务帐户和支持从外部系统或到外部系统的连接的帐户时,这些帐户必须以尽可能低的权限级别运行。
密码学实践
让我们让第一句话像您的加密技术一样强大:哈希和加密是两种不同的东西。
这是一个普遍的误解,而且大多数时候哈希和加密是交替使用的,错误的。它们是不同的概念,也有不同的用途。
哈希是由(哈希)函数从源数据生成的字符串或数字:
hash := F(data)
哈希的长度固定,其值随输入的微小变化而变化很大(仍可能发生冲突)。好的哈希算法不允许将哈希转换为其原始源。MD5是最流行的散列算法,但安全性blake2被认为是最强和最灵活的。
Go补充加密库提供了blake2b(或仅blake2)和blake2s实现:前者针对64位平台进行了优化,后者针对8到32位平台进行了优化。如果blake2不可用,则sha-256是正确的选项。
每当你有一些你不需要知道它是什么的东西,但只有当它是应该是什么的时候(比如下载后检查文件完整性),你应该使用hashing
package main
import "fmt"
import "io"
import "crypto/md5"
import "crypto/sha256"
import "golang.org/x/crypto/blake2s"
func main () {
h_md5 := md5.New()
h_sha := sha256.New()
h_blake2s, _ := blake2s.New256(nil)
io.WriteString(h_md5, "Welcome to Go Language Secure Coding Practices")
io.WriteString(h_sha, "Welcome to Go Language Secure Coding Practices")
io.WriteString(h_blake2s, "Welcome to Go Language Secure Coding Practices")
fmt.Printf("MD5 : %x\n", h_md5.Sum(nil))
fmt.Printf("SHA256 : %x\n", h_sha.Sum(nil))
fmt.Printf("Blake2s-256: %x\n", h_blake2s.Sum(nil))
}
输出
MD5 : ea9321d8fb0ec6623319e49a634aad92
SHA256 : ba4939528707d791242d1af175e580c584dc0681af8be2a4604a526e864449f6
Blake2s-256: 1d65fa02df8a149c245e5854d980b38855fd2c78f2924ace9b64e8b21b3f2f82
注意:要运行源代码示例,您需要运行$go get golang.org/x/crypto/blake2s
另一方面,加密使用密钥将数据转换为可变长度的数据
encrypted_data := F(data, key)
与散列不同,我们可以使用正确的解密函数和密钥,从加密的数据中计算数据。
data := F⁻¹(encrypted_data, key)
当您需要通信或存储敏感数据时,应使用加密,您或其他人稍后需要访问这些敏感数据进行进一步处理。一个“简单”的加密用例是安全的https-hyper-text传输协议。AES是对称密钥加密的事实标准。该算法和其他对称密码一样,可以在不同的模式下实现。您会注意到在下面的代码示例中,使用了gcm(galois counter模式),而不是更流行的(至少在密码学代码示例中)cbc/ecb。GCM和CBC/ECB之间的主要区别在于前者是一种经过身份验证的密码模式,这意味着在加密阶段之后,在密文中添加一个身份验证标签,然后在消息解密之前对其进行验证,以确保消息没有被篡改。另一方面,您有公钥密码术或使用成对密钥的非对称密码术:public和private。在大多数情况下,公钥密码学的性能不如对称密钥密码学,因此其最常见的用例是使用非对称密码学在双方之间共享对称密钥,这样他们就可以使用对称密钥交换使用对称密码学加密的消息。除了90年代的AES技术外,Go作者已经开始实施和支持更现代的对称加密算法,这些算法也提供身份验证,例如chacha20poly1305。
Go中另一个有趣的包是x/crypto/nacl。这是DanielJ.Bernstein博士的Nacl图书馆的参考资料,它是一个非常流行的现代密码学图书馆。go中的nacl/box和nacl/secretbox是nacl为两个最常见的用例发送加密消息的抽象实现:
- 使用公钥加密(nacl/box)在双方之间发送经过身份验证的加密消息
- 使用对称(即密钥)加密技术在双方之间发送经过身份验证的加密消息
如果符合您的用例,那么最好使用其中一个抽象,而不是直接使用AES。
package main
import "fmt"
import "crypto/aes"
import "crypto/cipher"
import "crypto/rand"
func main() {
key := []byte("Encryption Key should be 32 char")
data := []byte("Welcome to Go Language Secure Coding Practices")
block, err := aes.NewCipher(key)
if err != nil {
panic(err.Error())
}
nonce := make([]byte, 12)
if _, err := rand.Read(nonce); err != nil {
panic(err.Error())
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
panic(err.Error())
}
encrypted_data := aesgcm.Seal(nil, nonce, data, nil)
fmt.Printf("Encrypted: %x\n", encrypted_data)
decrypted_data, err := aesgcm.Open(nil, nonce, encrypted_data, nil)
if err != nil {
panic(err.Error())
}
fmt.Printf("Decrypted: %s\n", decrypted_data)
}
Encrypted: a66bd44db1fac7281c33f6ca40494a320644584d0595e5a0e9a202f8aeb22dae659dc06932d4e409fe35a95d14b1cffacbe3914460dd27cbd274b0c3a561
Decrypted: Welcome to Go Language Secure Coding Practices
请注意,您应该“建立并使用一个如何管理加密密钥的策略和过程”,保护“主秘密不受未经授权的访问”。也就是说:您的加密密钥不应该硬编码在源代码中(如本例中所示)。
go crypto package收集常见的加密常量,但实现有自己的包,如crypto/md5包。
大多数现代密码算法都是在https://godoc.org/golang.org/x/crypto
下实现的,因此开发人员应该关注那些算法,而不是[crypto/*package](https://golang.org/pkg/crypto/)
。
伪随机生成器
在OWASP安全编码实践中,您会发现一条似乎非常复杂的准则:“当这些随机值不可猜测时,所有随机数、随机文件名、随机guid和随机字符串都应使用加密模块批准的随机数生成器生成”,因此让我们来谈谈“随机数”。
密码学依赖于某种随机性,但为了正确起见,大多数编程语言提供的现成的是一个伪随机数生成器:go\'s math/rand不例外。
当文档中声明“顶级函数(如float64和int)使用默认共享源时,您应该仔细阅读该文档,该共享源每次运行程序时都会生成确定的值序列。”(source)
这到底是什么意思?让我们看看
package main
import "fmt"
import "math/rand"
func main() {
fmt.Println("Random Number: ", rand.Intn(1984))
}
运行这个程序几次会导致完全相同的数字/序列,但是为什么呢?
$ for i in {1..5}; do go run rand.go; done
Random Number: 1825
Random Number: 1825
Random Number: 1825
Random Number: 1825
Random Number: 1825
因为Go\'s Math/Rand和其他许多方法一样是一个确定性伪随机数生成器,所以它们使用一个称为seed的源。这个种子只负责确定性伪随机数生成器的随机性——如果已知或可预测,生成的数字序列也会发生同样的情况。
我们可以通过使用math/rand seed function为每个程序执行获取预期的五个不同值来“修复”这个例子,但是因为我们在cryptographic practices部分,所以我们应该遵循go\'s crypto/rand package。
package main
import "fmt"
import "math/big"
import "crypto/rand"
func main() {
rand, err := rand.Int(rand.Reader, big.NewInt(1984))
if err != nil {
panic(err)
}
fmt.Printf("Random Number: %d\n", rand)
}
您可能会注意到运行crypto/rand比math/rand慢,但这是意料之中的:最快的算法并不总是最安全的。crypto的rand实现起来也更安全;一个例子是,您不能种子crypto/rand,库为此使用操作系统随机性,防止开发人员滥用。
$ for i in {1..5}; do go run rand-safe.go; done
Random Number: 277
Random Number: 1572
Random Number: 1793
Random Number: 1328
Random Number: 1378
如果您对如何利用这一点很好奇,那么想想如果您的应用程序在用户注册时创建一个默认密码,通过计算用go\'s math/rand生成的伪随机数的散列值,会发生什么情况,如第一个示例所示?
是的,你猜对了,你就可以预测用户的密码了!
错误处理和记录
错误处理和日志记录是应用程序和基础结构保护的重要组成部分。当提到错误处理时,它是指捕获应用程序逻辑中可能导致系统崩溃的任何错误,除非正确处理。另一方面,日志记录详细说明了系统上发生的所有操作和请求。日志记录不仅允许识别已发生的所有操作,而且有助于确定需要采取哪些措施来保护系统。由于攻击者有时试图通过删除日志来删除其操作的所有跟踪,因此集中化日志至关重要。
错误处理
在Go中,有一个内置的error
类型。error
类型的不同值表示异常状态。通常在go中,如果错误值不是nil,则会发生一个错误,并且必须进行处理,以便允许应用程序在不崩溃的情况下从所述状态恢复。
Go中的一个简单示例如下:
if err != nil {
// handle the error
}
不仅可以使用内置错误,还可以指定自己的错误类型。这可以通过使用erro.New 函数。例子:
{...}
if f < 0 {
return 0, errors.New("math: square root of negative number")
}
//If an error has occurred print it
if err != nil {
fmt.Println(err)
}
{...}
为了防止我们需要格式化包含无效参数的字符串来查看是什么导致了错误,fmt
包中的errorf
函数允许我们这样做。
{...}
if f < 0 {
return 0, fmt.Errorf("math: square root of negative number %g", f)
}
{...}
在处理错误日志时,开发人员应确保在错误响应中不泄漏敏感信息,并确保没有错误处理程序泄漏信息(例如调试或堆栈跟踪信息)。
在Go中还有额外的错误处理函数,这些函数是恐慌、恢复和延迟。当一个应用程序处于死机状态时,它的正常执行被中断,任何延迟语句被执行,然后函数返回给它的调用方。recover通常在defer语句中使用,并允许应用程序重新获得对恐慌例程的控制权,然后返回正常执行。以下代码段基于Go文档解释了执行流程:
func main () {
start()
fmt.Println("Returned normally from start().")
}
func start () {
defer func () {
if r := recover(); r != nil {
fmt.Println("Recovered in start()")
}
}()
fmt.Println("Called start()")
part2(0)
fmt.Println("Returned normally from part2().")
}
func part2 (i int) {
if i > 0 {
fmt.Println("Panicking in part2()!")
panic(fmt.Sprintf("%v", i))
}
defer fmt.Println("Defer in part2()")
fmt.Println("Executing part2()")
part2(i + 1)
}
输出:
Called start()
Executing part2()
Panicking in part2()!
Defer in part2()
Recovered in start()
Returned normally from start().
通过检查输出,我们可以看到Go如何处理紧急情况并从中恢复,从而允许应用程序恢复其正常状态。这些函数允许从原本不可恢复的故障中进行优雅的恢复。
值得注意的是,延迟使用还包括互斥锁解锁,或者在执行了周围的函数(例如页脚)之后加载内容。
在日志包中还有一个log.fatal。致命级别是有效地记录消息,然后调用os.exit(1)。这意味着:
- 将不执行defer语句。
- 缓冲区将不会被刷新。
- 不会删除临时文件和目录。
考虑到前面提到的所有点,我们可以看到log.fatal与恐慌的区别,以及应该谨慎使用它的原因。可能使用log.fatal的一些示例包括:
- 设置日志记录并检查我们是否有健全的环境和参数。如果我们不这样做,那么就不需要执行main()。
- 一个永远不会发生的错误,我们知道它是不可恢复的。
- 如果一个非交互进程遇到错误而无法完成,则无法将此错误通知用户。最好先停止执行,然后再从失败中出现其他问题。
初始化失败的例子说明:
func init(i int) {
...
//This is just to deliberately crash the function.
if i < 2 {
fmt.Printf("Var %d - initialized\n", i)
} else {
//This was never supposed to happen, so we\'ll terminate our program.
log.Fatal("Init failure - Terminating.")
}
}
func main() {
i := 1
for i < 3 {
init(i)
i++
}
fmt.Println("Initialized all variables successfully")
重要的是要确保在与安全控制相关联的错误情况下,默认情况下拒绝访问。
日志
日志记录应始终由应用程序处理,不应依赖服务器配置。
所有日志记录都应该由受信任系统上的主例程实现,开发人员还应该确保日志中不包含敏感数据(例如密码、会话信息、系统详细信息等),也不存在任何调试或堆栈跟踪信息。此外,日志记录应该包括成功和失败的安全事件,重点是重要的日志事件数据。
重要事件数据通常指:
- 所有输入验证失败。
- 所有身份验证尝试,尤其是失败。
- 所有访问控制失败。
- 所有明显的篡改事件,包括对状态数据的意外更改。
- 使用无效或过期的会话令牌进行连接的所有尝试。
- 所有系统异常。
- 所有管理功能,包括对安全配置设置的更改。
- 所有后端TLS连接故障和加密模块故障。
一个简单的日志示例说明了这一点:
func main() {
var buf bytes.Buffer
var RoleLevel int
logger := log.New(&buf, "logger: ", log.Lshortfile)
fmt.Println("Please enter your user level.")
fmt.Scanf("%d", &RoleLevel) //<--- example
switch RoleLevel {
case 1:
// Log successful login
logger.Printf("Login successfull.")
fmt.Print(&buf)
case 2:
// Log unsuccessful Login
logger.Printf("Login unsuccessful - Insufficient access level.")
fmt.Print(&buf)
default:
// Unspecified error
logger.Print("Login error.")
fmt.Print(&buf)
}
}
实现通用错误消息或自定义错误页也是一个好的实践,以确保在发生错误时不会泄漏任何信息。
根据文档,Go log package “实现简单的日志记录”,缺少一些常见和重要的功能,例如级别化的日志记录(例如,调试、信息、警告、错误、致命、死机)和格式化程序支持(例如,logstash):这是使日志可用的两个重要功能(例如,用于与安全信息和事件集成)管理体系)。
大多数(如果不是全部)第三方日志记录包都提供这些功能和其他功能。以下是一些后流行的第三方日志记录包:
- Logrus - https://github.com/Sirupsen/logrus
- glog - https://github.com/golang/glog
- loggo - https://github.com/juju/loggo
关于Go\'s log package的一个重要注意事项是:致命的和紧急的函数都比日志功能做得更多。panic函数在写入日志消息后调用panic库通常不接受的内容,而致命的函数调用os。在写入日志消息后退出(1)可能终止程序以阻止延迟语句运行、要刷新的缓冲区和/或要删除的临时数据。
从日志访问的角度来看,只有授权的个人才可以访问日志。开发人员还应确保设置了允许日志分析的机制,并确保不受信任的数据不会作为代码在预期的日志查看软件或界面中执行。
关于分配的内存清理,Go有一个内置的垃圾收集器。
作为确保日志有效性和完整性的最后一步,应使用加密哈希函数作为附加步骤,以确保不会发生日志篡改。
{...}
// Get our known Log checksum from checksum file.
logChecksum, err := ioutil.ReadFile("log/checksum")
str := string(logChecksum) // convert content to a \'string\'
// Compute our current log\'s MD5
b, err := ComputeMd5("log/log")
if err != nil {
fmt.Printf("Err: %v", err)
} else {
md5Result := hex.EncodeToString(b)
// Compare our calculated hash with our stored hash
if str == md5Result {
// Ok the checksums match.
fmt.Println("Log integrity OK.")
} else {
// The file integrity has been compromised...
fmt.Println("File Tampering detected.")
}
}
{...}
注意:computemd5()
函数计算文件的md5。同样重要的是,必须将日志文件哈希存储在安全的地方,并与当前日志哈希进行比较,以在对日志进行任何更新之前验证完整性。文档中包含完整的源代码。
数据保护
现如今,安全中最重要的事情之一就是数据保护。你不会想要:
简而言之,Web应用程序中的数据需要受到保护,因此在本节中,我们将研究保护数据的不同方法。 你应该注意的第一件事是为每个用户创建和实现正确的权限,并将用户仅限于他们真正需要的功能。 例如
请发表评论