Skip to content

接口安全:让每一道检查都有明确的边界 ​

一个接口可以同时使用 HTTPS、JWT 和限流,却仍然接受不该写入的字段、重复执行同一个请求,或者把敏感数据发给错误的人。安全机制要解决具体问题,不能靠“加了一层中间件”来判断。

本文按请求经过服务端的顺序,说明输入校验、签名与防重放、限流、防刷、输出编码、脱敏、安全响应头和 CORS。实验是内存中的 HTTP Handler 测试,不开放外部服务;固定密钥和请求内容都是公开测试夹具。

请求处理顺序,首先决定攻击者能消耗多少资源 ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart TD
    R["到达边缘或服务入口"] --> E["连接、超时、头部与粗粒度配额"]
    E --> M["允许的方法、路径、内容类型"]
    M --> B["有界读取请求体"]
    B --> A["认证或签名验证"]
    A --> P["时间窗口与原子防重放"]
    P --> V["结构和业务输入校验"]
    V --> Z["主体、对象和租户授权"]
    Z --> W["幂等或事务保护的业务操作"]
    W --> O["最小输出、上下文编码和安全头"]
    O --> L["记录有限审计字段"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart TD
    R["到达边缘或服务入口"] --> E["连接、超时、头部与粗粒度配额"]
    E --> M["允许的方法、路径、内容类型"]
    M --> B["有界读取请求体"]
    B --> A["认证或签名验证"]
    A --> P["时间窗口与原子防重放"]
    P --> V["结构和业务输入校验"]
    V --> Z["主体、对象和租户授权"]
    Z --> W["幂等或事务保护的业务操作"]
    W --> O["最小输出、上下文编码和安全头"]
    O --> L["记录有限审计字段"]

这不是唯一的中间件排列。便宜的格式检查可以放在认证前,昂贵密码 KDF、复杂解析和后端查询则应被更早的限额保护。错误请求越早拒绝,越少消耗后面的资源;但拒绝原因也不能无意暴露账号是否存在或对象的私密信息。OWASP REST Security

示例实现了方法、路径、内容类型、体积、HMAC、时间与 nonce、JSON 校验及部分响应头。它没有执行真实数据修改,没有对象授权、生产限流、持久化幂等、密钥轮换或审计流水线。下面的输出只能验证这些已实现的局部边界。

输入校验分三层,不能相互替代 ​

层次要检查什么例子通过以后仍然不知道什么
传输与资源方法、内容类型、体积、时间只收 POST JSON,请求体不超过上限JSON 字段是否符合业务
结构与格式类型、未知字段、重复字段、数值范围name 必须是字符串,拒绝多份 JSON请求者是否能修改目标对象
业务与授权状态、字段关系、所有权、租户已完成订单不能再次取消外部依赖调用是否成功

输入校验最好使用允许规则:列出可接受的类型、长度、枚举和状态。字符串黑名单容易漏掉表示方式,也会误伤正常数据。Unicode 长度要说明按字节、码点还是用户感知字符计算;金额应采用清楚的最小单位或适合的十进制表示,不把任意浮点数直接用于结算。OWASP Input Validation

本例先限制读取,再读入内存:

go
r.Body = http.MaxBytesReader(w, r.Body, 128)
body, err := io.ReadAll(r.Body)

128 字节是为了让测试简单,不是通用 API 推荐值。超限测试发送 129 字节,实际返回:

text
large-body: 413

先 ReadAll 再判断长度,已经承担了大内存开销;读取上限应该在读取时生效。生产还要考虑压缩前后大小、头部大小、慢速发送、连接数、上传临时空间和解析深度。这里没有启动真实 http.Server,因此没有测连接级超时。MaxBytesReader

选择解码语义时,也是在选择协议 ​

本例使用 Go 1.27.2 的 encoding/json/v2,要求一个 JSON 值,并开启拒绝未知成员。v2 默认拒绝重复对象成员名;测试还覆盖大小写不匹配和尾随第二份 JSON。json/v2、jsontext 重复键选项

go
json.Unmarshal(body, &in, json.RejectUnknownMembers(true))
text
duplicate-json: 400

传统 encoding/json 的 DisallowUnknownFields 不具有全部相同默认行为。网关、签名方与后端若对重复键、大小写或数字作不同解释,可能一个组件检查的是 A,另一个组件使用的是 B。协议应写清解析规则,测试异常输入,不要只在一端“清洗”后让另一端自行解释。

示例名称只按字节限制为 1–20,适合演示边界,不是面向多语言姓名的完整规则。客户端可写字段也只包含 name,避免把用户输入直接赋给带 Role、TenantID 等服务端字段的持久化对象。

请求签名:签的是准确字节和用途,不能只签时间戳 ​

签名或 MAC 能验证消息完整性与密钥持有关系。若只签时间戳,攻击者仍可能在有效窗口内替换正文;若不覆盖路径,同一份签名可能被拿去调用另一动作。需要签哪些部分,取决于协议要保护哪些语义。

RFC 9421 HTTP Message Signatures 定义了 HTTP 消息签名的组件与构造规则。接入第三方 webhook 时应严格遵循提供方协议;自建通用互操作协议可以评估这个标准。本文采用一个刻意简单的本地格式,只用来展示边界,不是 RFC 9421 实现,也不建议拿它取代经过评审的生产协议。

本例按固定顺序覆盖版本、方法、Host、转义后的路径、原始 query、Content-Type、key ID、时间、nonce 和正文 SHA-256。字段使用长度前缀,避免直接拼接出现 ab+c 与 a+bc 的歧义。正文按原始字节计算摘要,不先解析 JSON 再随意重编码。反向代理若会改写这些部分,必须约定到底签名哪个层次的表示。

go
h := hmac.New(sha256.New, testKey)
h.Write(canonical(r, body))
if !hmac.Equal(got, h.Sum(nil)) {
    return errors.New("signature")
}

同一份正常请求先提交,再改动正文但保留签名,本机输出:

text
first: 201
tampered: 401

测试还逐项修改方法、主机、路径和 query,确认原签名失效,并拒绝重复的关键签名头。hmac.Equal 只负责认证码比较;选择正确密钥、限制算法、规范化一致、TLS 保护和密钥轮换仍然要另外设计。Go hmac

HMAC 秘密不能长期埋在浏览器 JavaScript 或公开客户端里。示例里的 HMAC Handler 和 CORS 测试只是把两个边界放在同一测试夹具中,不是教前端保存共享密钥。真实浏览器通常应使用合适的会话或 OIDC/OAuth 方案,服务间调用再按其威胁模型选择消息签名。

防重放:有效签名还需要一次性状态 ​

收到一份有正确签名的旧请求,验签仍然成功。时间戳只把可重放范围缩小到一个窗口;要拒绝窗口内重复请求,还需要 nonce、业务序列号或其他可验证的一次性状态。RFC 9421 §7.2.2

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
stateDiagram-v2
    [*] --> Unseen
    Unseen --> Rejected: 签名错误或时间不在窗口
    Unseen --> Consumed: 验签成功且原子写入 nonce
    Consumed --> Rejected: 窗口内重复 nonce
    Consumed --> Expired: 接受窗口结束后清理
    Expired --> Rejected: 原签名时间已过期
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
stateDiagram-v2
    [*] --> Unseen
    Unseen --> Rejected: 签名错误或时间不在窗口
    Unseen --> Consumed: 验签成功且原子写入 nonce
    Consumed --> Rejected: 窗口内重复 nonce
    Consumed --> Expired: 接受窗口结束后清理
    Expired --> Rejected: 原签名时间已过期

本例只接受测试时刻前后 60 秒的时间戳,使用 (clientID, nonce) 作为去重键。验签成功后,在一把锁内检查并写入;失效记录被清理,活动记录达到 1024 条时拒绝新请求,不驱逐尚有效的记录来腾空间。否则被驱逐的请求可能再次被接受。

text
first: 201
replay: 401

两次并发提交相同请求的测试,断言恰好一次得到 201,另一次得到 401。另一个测试先提交坏签名,再提交相同 nonce 的正确请求,确认坏签名不会提前占用 nonce。这里的锁只在单进程有效,重启会丢失状态;多实例需要具有原子“仅当不存在时写入”语义的共享状态,TTL 应覆盖完整接受窗口和合理时钟偏差。

防重放与幂等处理的是不同问题 ​

如果服务端已完成操作,但响应丢失,客户端重试是正常需求。严格 nonce 防重放可能把重试拒绝掉,却没有告诉客户端原操作结果。幂等键则应把同一业务请求绑定到持久化结果,重复到来时返回同一结果;同一个键配不同业务内容应当拒绝。

本例在校验 JSON 前消费 nonce,因此即使后续输入错误也不能复用这个 nonce。这是一个明确的演示选择,不是完整事务协议。真实写操作要设计 未见 → 处理中 → 已完成/失败 的持久化状态,并把业务副作用与幂等结果放在可解释的一致性边界内。不能声称一个内存 map 能保证跨崩溃的“恰好一次”。

限流与防刷:额度应该对应你要保护的资源 ​

只按 IP 限流,会让公司出口或运营商 NAT 后的正常用户互相影响,也可能被分布式来源绕过;只按账号限流,则挡不住未登录入口和大量新账号。应按接口成本组合全局、租户、账号、凭据、IP 与并发额度,不盲目信任客户端自报的用户或来源 IP。OWASP REST 限流建议

算法或限制约束方式好处主要代价或盲点
固定窗口计数每个窗口最多 N 次简单,存储开销小窗口边界可能出现短时双倍突发
滑动窗口统计最近一段时间限制更贴近实际时间范围精确实现需要更多状态或近似算法
令牌桶按速率补充令牌,桶容量允许突发能表达平均速率和短期突发需要正确时钟、并发扣减和分布式策略
并发上限同时处理的请求不超过 N直接保护连接池和昂贵任务不能单独限制大量快速请求
业务额度每个主体可创建/导出/消耗多少更贴近真实滥用成本依赖身份、幂等和业务状态

超额通常返回 429,并可用 Retry-After 提示重试时机;客户端还应退避并加抖动。限流存储不可用时怎样处理,要按接口风险决定,不能在 catch 分支里无条件放行。RFC 6585 §4

防刷还可能需要异常行为检测、设备与账号关联、分级挑战、审批或经济成本约束。验证码只能增加部分自动化成本,不能替代授权或保护高价值业务。登录口还要避免攻击者利用严格账号锁定反过来锁死正常用户。本文没有实现限流器,也没有进行防刷效果或分布式配额压测。

经过代理时,只接受已配置可信代理传来的转发地址信息,并由边缘覆盖不可信来路头。任意请求自己提供的 X-Forwarded-For 不能用来绕过 IP 配额;同时保留代理链及原连接来源的可追溯关系。

输出编码和脱敏:发出去之前,先决定该不该发 ​

输出编码处理“数据在接收上下文中会不会变成代码”,授权和数据最小化处理“接收者该不该看到这些数据”。这三个问题不能合并。JSON 响应用 JSON 编码器和正确 Content-Type;HTML 用可信模板和上下文编码;不要把“JSON 默认转义了某些字符”当成所有 HTML/JavaScript 嵌入都安全。

接口 DTO 应显式选择字段,避免把数据库结构体整份序列化。脱敏只能减少展示细节,例如隐藏电话号码的中间部分;它不等于匿名化,多个片段仍可能被关联识别,也不能让未授权用户获得原本不该看到的记录。

数据位置推荐处理方式容易留下的泄漏
返回给普通用户仅返回该功能必要且已授权的字段管理字段、租户内部标识被顺带序列化
应用日志记录允许字段清单和稳定关联 ID全请求体、Cookie、Authorization 被统一拦截器打印
错误响应给客户端有限错误类别与请求 ID数据库语句、内部路径、堆栈、用户存在性
指标标签使用低基数、非敏感维度把邮箱、令牌或完整 URL 当标签

日志中不应保存访问令牌、密码、会话标识和其他秘密;结构化日志有助于避免换行注入,但不能阻止你主动把秘密放进一个合法字段。OWASP Logging

安全响应头:按响应类型配置,而不是复制一整套 ​

响应头用途适用边界
Content-Type + X-Content-Type-Options: nosniff声明并约束内容类型解释仍需正确编码与正确文件类型
Cache-Control: no-store要求缓存不保存敏感响应不能收回已泄漏副本,不代表端点授权
Content-Security-Policy限制文档加载和脚本执行等能力主要针对页面;需要与真实资源来源匹配
CSP frame-ancestors限制哪些页面可以嵌入该文档不等同于 CORS;旧浏览器兼容另行评估
Strict-Transport-Security让浏览器后续按策略使用 HTTPS只在 HTTPS 场景有效;includeSubDomains/preload 有运维约束
Referrer-Policy减少导航时泄露来源 URL 信息不应因此把秘密放进 URL
Permissions-Policy限制页面可使用的部分浏览器能力不是服务端资源授权

HSTS 的域和子域策略要核对部署能力后再启用,不能在测试 HTTP 服务里设置一个头就声称 HTTPS 安全已验证。RFC 6797 CSP 与其他响应头的组合参见 OWASP HTTP Headers。不要默认依赖旧式 X-XSS-Protection;现代页面更应重视编码和 CSP。

CORS:决定浏览器能否读取响应,不替服务器认证调用者 ​

CORS 是浏览器跨源读取响应的协议。服务器告诉浏览器哪些 Origin、方法和头允许跨源访问;某些请求先预检,另一些请求可以直接发送,浏览器随后再决定是否把响应交给页面脚本。因此 CORS 不是 API 防火墙,也不能替代 CSRF 防护。非浏览器客户端不受这套读取限制,而且可以自报 Origin。WHATWG Fetch CORS 协议

场景服务端应该明确什么常见错误
公共、无凭据资源是否允许任意站点读取把公共读取规则复制给私有 API
指定前端访问精确 Origin、允许方法和头无条件回显任意 Origin
带 Cookie 等凭据明确 Origin 与 credentials 策略与 Access-Control-Allow-Origin: * 混用
动态 Origin 响应按 Origin 区分缓存漏掉 Vary: Origin 导致缓存混淆
OPTIONS 预检验证目标方法和请求头,返回允许能力把预检成功当成业务请求已认证

Origin 包含协议、主机和端口;https://app.example 与 http://app.example 不是同源。null 也不是“无来源所以安全”,若无明确业务需求不应放行。允许列表应精确匹配解析后的设计值,不用任意后缀匹配替代。

本例只允许 https://app.example,不启用跨源 Cookie 凭据。预检允许 POST 和固定头集合,正常输出为:

text
preflight: 204 https://app.example
allowed-origin-without-valid-signature: 401

第二行说明即使 Origin 在允许列表里,业务请求仍必须通过独立认证。测试中的未允许 Origin 会被服务端策略主动拒绝;这是一项额外策略选择,不能推导“其他客户端无法伪造允许 Origin”。真正的身份边界是服务端认证与授权。

这些测试使用 httptest 检查状态码和响应头,没有运行浏览器。因此它们能证明 Handler 写出了预期头,不能证明跨浏览器 Cookie、预检缓存或嵌入行为都符合预期。

依据与验证边界 ​

  • HTTP:RFC 9421(签名构造与重放风险)、RFC 6585 §4(429)、RFC 6797(HSTS);浏览器跨源协议为 WHATWG Fetch。
  • Go:1.27.2 的 net/http、crypto/hmac、encoding/json/v2、encoding/json/jsontext。正文里的解析行为对应 v2,不可直接套给 v1。
  • 安全指南:OWASP REST Security、Input Validation、Logging、HTTP Headers。限流算法表和处理顺序是本文基于所保护资源提出的工程比较,不是统一配置标准。
  • 实测:2026-10-13,官方 Go 1.27.2、Linux/amd64;go run .、go test -race -count=1 ./...、go vet ./... 通过。覆盖正文篡改、签名字段修改、过期、重复头、并发重放、nonce 污染、JSON 歧义、体积、CORS 头与允许方法。
  • 未运行真实浏览器、反向代理、TLS、生产限流、共享防重放存储或业务数据库。没有测试本文未实现的授权、幂等事务、密钥轮换与日志系统。

完整复现代码 ​

代码不会启动监听端口,也不修改真实数据。固定时间便于重现窗口判断;固定 HMAC 密钥公开展示且明确不可部署。生产系统不能在重启后忘记仍处于接受窗口中的请求,也不能把这个测试协议当作完整认证产品。

go.mod ​

go.mod
module example.com/security05

go 1.27.2

main.go ​

代码块 · 194 行 · 点击收起 / 展开
go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	json "encoding/json/v2"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"
)

// Public test fixture, NOT a deployable secret. The signature format is local
// teaching code, not an implementation of RFC 9421.
var testKey = []byte("PUBLIC-TEST-KEY-DO-NOT-DEPLOY-0001")

const testTime int64 = 1791892800

func canonical(r *http.Request, body []byte) []byte {
	sum := sha256.Sum256(body)
	fields := []string{"demo-v1", r.Method, r.Host, r.URL.EscapedPath(), r.URL.RawQuery, r.Header.Get("Content-Type"), r.Header.Get("X-Key-Id"), r.Header.Get("X-Time"), r.Header.Get("X-Nonce"), fmt.Sprintf("%x", sum)}
	var b strings.Builder
	for _, f := range fields {
		fmt.Fprintf(&b, "%d:%s", len(f), f)
	}
	return []byte(b.String())
}
func signature(r *http.Request, body []byte) string {
	h := hmac.New(sha256.New, testKey)
	h.Write(canonical(r, body))
	return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}

type Verifier struct {
	mu   sync.Mutex
	seen map[string]int64
}

func (v *Verifier) Verify(r *http.Request, body []byte, now int64) error {
	for _, k := range []string{"X-Key-Id", "X-Time", "X-Nonce", "X-Signature", "Content-Type"} {
		if len(r.Header.Values(k)) != 1 {
			return errors.New("ambiguous header")
		}
	}
	if r.Header.Get("X-Key-Id") != "public-test-client" {
		return errors.New("key")
	}
	ts, err := strconv.ParseInt(r.Header.Get("X-Time"), 10, 64)
	if err != nil || ts < now-60 || ts > now+60 {
		return errors.New("time")
	}
	nonce := r.Header.Get("X-Nonce")
	if len(nonce) < 16 || len(nonce) > 64 {
		return errors.New("nonce")
	}
	for _, c := range nonce {
		if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9') {
			return errors.New("nonce")
		}
	}
	got, err := base64.RawURLEncoding.Strict().DecodeString(r.Header.Get("X-Signature"))
	if err != nil {
		return errors.New("signature")
	}
	h := hmac.New(sha256.New, testKey)
	h.Write(canonical(r, body))
	if !hmac.Equal(got, h.Sum(nil)) {
		return errors.New("signature")
	}
	v.mu.Lock()
	defer v.mu.Unlock()
	for key, expiry := range v.seen {
		if expiry < now {
			delete(v.seen, key)
		}
	}
	key := r.Header.Get("X-Key-Id") + ":" + nonce
	if _, ok := v.seen[key]; ok {
		return errors.New("replay")
	}
	if len(v.seen) >= 1024 {
		return errors.New("capacity")
	}
	v.seen[key] = ts + 60
	return nil
}
func (v *Verifier) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("X-Content-Type-Options", "nosniff")
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Add("Vary", "Origin")
	origin := r.Header.Get("Origin")
	if origin != "" && origin != "https://app.example" {
		http.Error(w, "origin", 403)
		return
	}
	if origin != "" {
		w.Header().Set("Access-Control-Allow-Origin", origin)
	}
	if r.Method == http.MethodOptions {
		w.Header().Add("Vary", "Access-Control-Request-Method")
		w.Header().Add("Vary", "Access-Control-Request-Headers")
		if origin == "" || r.Header.Get("Access-Control-Request-Method") != "POST" {
			http.Error(w, "preflight", 403)
			return
		}
		allowed := map[string]bool{"content-type": true, "x-key-id": true, "x-time": true, "x-nonce": true, "x-signature": true}
		for _, h := range strings.Split(r.Header.Get("Access-Control-Request-Headers"), ",") {
			if h = strings.ToLower(strings.TrimSpace(h)); h != "" && !allowed[h] {
				http.Error(w, "header", 403)
				return
			}
		}
		w.Header().Set("Access-Control-Allow-Methods", "POST")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Key-Id, X-Time, X-Nonce, X-Signature")
		w.WriteHeader(204)
		return
	}
	if r.Method != "POST" {
		w.Header().Set("Allow", "POST, OPTIONS")
		http.Error(w, "method", 405)
		return
	}
	if r.URL.Path != "/items" || r.URL.RawQuery != "" {
		http.Error(w, "route", 404)
		return
	}
	if r.Header.Get("Content-Type") != "application/json" {
		http.Error(w, "type", 415)
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, 128)
	defer r.Body.Close()
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "body", 413)
		return
	}
	if err = v.Verify(r, body, testTime); err != nil {
		http.Error(w, "authentication", 401)
		return
	}
	var in struct {
		Name string `json:"name"`
	}
	if err = json.Unmarshal(body, &in, json.RejectUnknownMembers(true)); err != nil || len(in.Name) == 0 || len(in.Name) > 20 {
		http.Error(w, "input", 400)
		return
	}
	// No actual mutation. A production handler must enforce object authorization
	// and combine replay/idempotency with durable business state.
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(201)
	fmt.Fprintln(w, `{"accepted":true}`)
}
func request(body, nonce string) *http.Request {
	r := httptest.NewRequest("POST", "https://api.example/items", strings.NewReader(body))
	r.Header.Set("Content-Type", "application/json")
	r.Header.Set("X-Key-Id", "public-test-client")
	r.Header.Set("X-Time", strconv.FormatInt(testTime, 10))
	r.Header.Set("X-Nonce", nonce)
	r.Header.Set("X-Signature", signature(r, []byte(body)))
	return r
}
func status(v *Verifier, r *http.Request) int {
	w := httptest.NewRecorder()
	v.ServeHTTP(w, r)
	return w.Code
}
func main() {
	v := &Verifier{seen: map[string]int64{}}
	body := `{"name":"ok"}`
	fmt.Println("first:", status(v, request(body, "aaaaaaaaaaaaaaaa")))
	fmt.Println("replay:", status(v, request(body, "aaaaaaaaaaaaaaaa")))
	r := request(body, "bbbbbbbbbbbbbbbb")
	r.Body = io.NopCloser(strings.NewReader(`{"name":"changed"}`))
	fmt.Println("tampered:", status(v, r))
	fmt.Println("duplicate-json:", status(v, request(`{"name":"a","name":"b"}`, "cccccccccccccccc")))
	fmt.Println("large-body:", status(v, request(strings.Repeat("x", 129), "dddddddddddddddd")))
	r = httptest.NewRequest("OPTIONS", "https://api.example/items", nil)
	r.Header.Set("Origin", "https://app.example")
	r.Header.Set("Access-Control-Request-Method", "POST")
	w := httptest.NewRecorder()
	v.ServeHTTP(w, r)
	fmt.Println("preflight:", w.Code, w.Header().Get("Access-Control-Allow-Origin"))
	r = request(body, "eeeeeeeeeeeeeeee")
	r.Header.Set("Origin", "https://app.example")
	r.Header.Set("X-Signature", "invalid")
	fmt.Println("allowed-origin-without-valid-signature:", status(v, r))
}

main_test.go ​

代码块 · 129 行 · 点击收起 / 展开
go
package main

import (
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"
	"testing"
)

func TestInput(t *testing.T) {
	for name, body := range map[string]string{"duplicate": `{"name":"a","name":"b"}`, "unknown": `{"name":"a","role":"admin"}`, "case": `{"NAME":"a"}`, "extra": `{"name":"a"}{}`, "empty": `{"name":""}`} {
		t.Run(name, func(t *testing.T) {
			v := &Verifier{seen: map[string]int64{}}
			if got := status(v, request(body, "aaaaaaaaaaaaaaaa")); got != 400 {
				t.Fatal(got)
			}
		})
	}
}
func TestSignatureFieldsAndTime(t *testing.T) {
	body := []byte(`{"name":"ok"}`)
	for _, field := range []string{"host", "method", "path", "query", "time", "duplicate-header"} {
		v := &Verifier{seen: map[string]int64{}}
		r := request(string(body), "aaaaaaaaaaaaaaaa")
		switch field {
		case "host":
			r.Host = "other.example"
		case "method":
			r.Method = "DELETE"
		case "path":
			r.URL.Path = "/other"
		case "query":
			r.URL.RawQuery = "x=1"
		case "time":
			r.Header.Set("X-Time", strconv.FormatInt(testTime-61, 10))
			r.Header.Set("X-Signature", signature(r, body))
		case "duplicate-header":
			r.Header.Add("X-Time", r.Header.Get("X-Time"))
		}
		if err := v.Verify(r, body, testTime); err == nil {
			t.Fatal("accepted", field)
		}
	}
}
func TestReplayAtomic(t *testing.T) {
	v := &Verifier{seen: map[string]int64{}}
	var wg sync.WaitGroup
	ch := make(chan int, 2)
	for range 2 {
		wg.Go(func() { ch <- status(v, request(`{"name":"ok"}`, "aaaaaaaaaaaaaaaa")) })
	}
	wg.Wait()
	close(ch)
	counts := map[int]int{}
	for c := range ch {
		counts[c]++
	}
	if counts[201] != 1 || counts[401] != 1 {
		t.Fatal(counts)
	}
}
func TestCORS(t *testing.T) {
	v := &Verifier{seen: map[string]int64{}}
	r := httptest.NewRequest("OPTIONS", "https://api.example/items", nil)
	r.Header.Set("Origin", "https://app.example")
	r.Header.Set("Access-Control-Request-Method", "POST")
	r.Header.Set("Access-Control-Request-Headers", "X-Evil")
	if c := status(v, r); c != 403 {
		t.Fatal(c)
	}
	r = request(`{"name":"ok"}`, "aaaaaaaaaaaaaaaa")
	r.Header.Set("Origin", "https://other.example")
	w := httptest.NewRecorder()
	v.ServeHTTP(w, r)
	if w.Code != 403 || w.Header().Get("Access-Control-Allow-Origin") != "" {
		t.Fatal(w.Code, w.Header())
	}
	if !strings.Contains(w.Header().Get("Vary"), "Origin") {
		t.Fatal("vary")
	}
}
func TestBadMACDoesNotConsumeNonce(t *testing.T) {
	v := &Verifier{seen: map[string]int64{}}
	r := request(`{"name":"ok"}`, "aaaaaaaaaaaaaaaa")
	r.Header.Set("X-Signature", "bad")
	if status(v, r) != 401 {
		t.Fatal("bad mac")
	}
	if status(v, request(`{"name":"ok"}`, "aaaaaaaaaaaaaaaa")) != 201 {
		t.Fatal("nonce poisoned")
	}
}

func TestTimeAndCapacityBoundaries(t *testing.T) {
	body := []byte(`{"name":"ok"}`)
	for _, delta := range []int64{-61, -60, 60, 61} {
		v := &Verifier{seen: map[string]int64{}}
		r := request(string(body), "aaaaaaaaaaaaaaaa")
		r.Header.Set("X-Time", strconv.FormatInt(testTime+delta, 10))
		r.Header.Set("X-Signature", signature(r, body))
		err := v.Verify(r, body, testTime)
		if (err == nil) != (delta >= -60 && delta <= 60) {
			t.Fatalf("delta %d: %v", delta, err)
		}
	}
	v := &Verifier{seen: map[string]int64{}}
	r := request(string(body), "aaaaaaaaaaaaaaaa")
	if err := v.Verify(r, body, testTime); err != nil {
		t.Fatal(err)
	}
	if err := v.Verify(r, body, testTime+60); err == nil || err.Error() != "replay" {
		t.Fatal("edge replay", err)
	}
	if err := v.Verify(r, body, testTime+61); err == nil || err.Error() != "time" {
		t.Fatal("expired request", err)
	}
	v = &Verifier{seen: map[string]int64{}}
	for i := range 1024 {
		v.seen[strconv.Itoa(i)] = testTime + 60
	}
	if err := v.Verify(request(string(body), "bbbbbbbbbbbbbbbb"), body, testTime); err == nil || err.Error() != "capacity" || len(v.seen) != 1024 {
		t.Fatal("capacity", err, len(v.seen))
	}
	v.seen["0"] = testTime - 1
	if err := v.Verify(request(string(body), "bbbbbbbbbbbbbbbb"), body, testTime); err != nil || len(v.seen) != 1024 {
		t.Fatal("cleanup", err, len(v.seen))
	}
}

运行命令 ​

sh
go mod download
go run .
go test -race -count=1 ./...
go vet ./...

保留各书来源与许可 · 示例运行条件以正文为准