HTTP接口在安全性上的设计要点

2024-12-21

在接口设计时,要考虑接口是面向外网还是内网,调用方是外部用户还是内部团队。因为安全域不一样,接口在安全上的设计是不一样的。对外的接口我们使用HTTPS保证传输安全,即前端到流量入口(Nginx)这条链路由HTTPS来保证安全;在流量入口之后就是内部服务,服务间调用使用RPC或者HTTP接口,由于出于性能和效率的考虑,内部调用的HTTP接口没有用SSL来保证传输安全,因为在当前安全级别下,在内网里默认传输链路是值得信赖的。

内部服务由多个微服务组成,每个团队负责其中的一个或多个微服务。在微服务间的互相调用中,我们开放了很多对内调用的HTTP接口,这些HTTP接口可能被这些人所使用着:

  • 内部业务团队微服务在请求访问;
  • 内部安全团队在扫描;
  • 外部黑客侵入内网后在扫描接口。

其中第三点说明了即使是内部环境,一样有被攻击的风险,如果黑客一旦黑进了内网,这些服务无疑就是在裸奔。所以仅内部调用的服务接口,一样需要安全防护,你不能完全相信当前调用你的接口的人就是内部团队。此外,内部团队在调用你的接口时,也是需要留一个心眼,比如调用频率,如果调用方无节制地请求你的接口,而你的接口没有合适的安全策略,最终会导致你的服务崩溃。

因此,对外开放的HTTP接口,无论是面向外网还是内网,都需要设计一系列的的安全策略以保证接口安全。

接口安全的定义

我们的接口可能是部署在内网,也可能部署在外网,数据在网络中传输,就必定带来安全隐患。接口安全问题,我认为可以分为三类:

  1. 接口访问认证问题,主要解决的是谁有权利访问我们的接口;
  2. 数据传输安全问题,主要解决数据在网络传输期间是否被监听和被篡改;
  3. 接口并发保护问题,主要解决异常访问流量的冲击下接口是否正常可用。

只要你的数据在网络上传输,你的数据就有可能被窃取分析、被篡改、被重放、被SQL注入。但是我们是否需要对我们的所有开放接口都必须设置复杂的安全策略以保证绝对安全呢?我觉得还是具体场景具体分析,有些纯内网且数据重要性低的场景的调用,可以把安全级别调低些,以保证开发效率和请求处理效率。在一些重要的场景,比如跟钱相关的外部接口,可以考虑设计得更为周全些。接下来,开始介绍开放HTTP接口的安全设计实践,一个HTTP接口,我们可以为他加上这些安全策略。

身份验证

首先需要解决权限管理问题,即谁能访问我的接口。这个是安全防范的第一道防线,我们可以通过IP白名单去过滤一批不符合期望的机器访问。比如我向我司的推送团队要了一批他们的服务器IP,加入到我们这边的IP白名单中,只有位于IP白名单的机器才有权限访问我们的接口。IP白名单是权限控制的最重要的防线。但IP白名单方式并不够灵活,在服务弹性伸缩的背景下,微服务的IP经常变化(比如新增一个服务节点、下线一个服务节点),此时IP白名单就需要经常变动了。

此外,我们自建了一套access_key + access_secret的业务注册机制,即我的服务的调用方必须先注册获得access_key和access_secret,只有拿到这两个关键数据,才是有资格访问你的接口。access_key代表调用方身份,是唯一ID;access_secret即秘钥,是唯一字符串,秘钥长度一般选用64位随机字符串,64位的私钥通常用于简单的加密或标识符,而不是用于高安全性的加密应用。对于高安全性的应用,建议使用更长的密钥(如256位或更高)。当调用方调用我方接口时,需要检查携带上来的access_key是否已经注册,未注册的access_key表明访问非法。access_secret不直接传输,而是通过签名机制来验证调用方的身份。

数据防篡改

因为请求数据会在网络上传输,因此请求有可能被别人截取下来分析和修改,再重新发放给请求的接口。比如运营团队发奖励的请求被截取了,把得奖者改为自己,再把请求放回网络继续传输。此时数据是被篡改过的,因此我们的接口在处理请求时,需要检查数据是否被篡改过。此时我们可以即借助我们上面的access_key和access_secret来检验数据是否被篡改过。

首先请求方对自己的请求进行加密,秘钥选择我们分配的access_secret,对称加密算法我们可以指定为MD5或者HMAC-SHA256,参数拼接方法就更随意了,可以是MD5(access_key+你的请求参数),也可以是MD5(你的请求参数+access_key),更可以是MD5(access_key+你的请求参数+access_key)。怎么拼不重要,你作为服务提供者做好定义就好,即秘钥+请求参数的组合即可,生成的签名也一并加入到请求参数里。

我们的http post请求的body参数假设是这样的

{
	"hostnum": 89,
	"usernum": 163,
	"access_key": xosujellsxy,
	"cmd": 5,
	"ts": 1624696781,
	"nonce": 858038,
	"data": "{\"time\": 1024, \"usernum\": 1022321, \"name\": \"\\u5251\\u4f1a\\u5929\\u4e0b\", \"uid_list\": [1, 2, 3]}",
	"sign": "d0aacb409ffaaea1387c1640e3ccc8b4"
}

那么我们的sign字段就是我们的数据签名,用于服务提供者的数据检验。而sign的生成方式,一般步骤是:

  1. 请求参数先按参数名首字符排序,如access_key=%s&cmd=%d&data=%s&hostnum=%d&nonce=%d&ts=%d&usernum=%d,若字段为空值则不参与计算。
  2. 参数拼接秘钥,如access_key=%s&cmd=%d&data=%s&hostnum=%d&nonce=%d&ts=%d&usernum=%d&access_secret=%s
  3. 使用MD5或者HmacSHA256生成摘要,如sign=md5("appid=%d&cmd=%d&data=%s&hostnum=%d&nonce=%d&ts=%d&usernum=%d&access_secret=%s" % (body["appid"], body["cmd"], body["data"], body["hostnum"], body["nonce"], body["ts"],body["usernum"], access_key)).hex,最后转16进制。

以go为例,这里实现了利用MD5对参数生成签名

func BuildSign(mReq map[string]string, secret string) (sign string) {
	sortedKeys := make([]string, 0, len(mReq))
	for k := range mReq {
		sortedKeys = append(sortedKeys, k)
	}
	// 第一步,先排序
	sort.Strings(sortedKeys)
	for _, k := range sortedKeys {
		value := mReq[k]
		//参数为空的不参与计算
		if value != "" {
			sign = sign + k + "=" + value + "&"
		}
	}
	// 第二步,拼接规则,采取参数列表+秘钥的方式拼接
	sign = sign + "key=" + secret
	log.Info.Println("sign %s", sign)
	// 第三步,MD5生成哈希串
	md5Hash := md5.New()
	md5Hash.Write([]byte(sign))
	// 第四步,转16进制
	return hex.EncodeToString(md5Hash.Sum(nil))
}

请求方发起调用时需要带上参数签名,我们收到请求后首先取出其access_key,查询access_key对应的access_secret,再使用该access_secret对收到的请求的参数进行一次同样的签名操作,如果发现自己计算得到的sign不等于请求带过来的sign,则数据被篡改过;否则可以认为数据正常,且该用户是已经向我们注册过了,可以通过我们的数据校验和身份验证。

请求防重放

数据被篡改了我们可以校验出来,但是请求被重放貌似仍未解决。比如发奖励的请求被窃取下来了,因为窃取者不知道秘钥所以不敢修改请求的参数,但是他发现奖励的发放者居然是自己的账号,因此他重复发送该请求,我们的接口如果没有处理重放的情形(非幂等),这个奖励就会被多次发放了。

请求是否被重放是可以检测出来的,关键是利用好请求带过来的随机数Nonce和timestamp。

timestamp标记着请求时什么时候发过来的,我们可以首先做个请求过滤,请求timestamp跟我们服务器时间相差过大的,直接拒绝。

if req.Ts <= 0 || now-req.Ts > 60 || req.Ts-now > 60{
	c.JSON(http.StatusOK, NewDataRsp(ErrRequestExpire, "请求已超时", nil))
	return
}

调用方发起请求时,参数必须带上随机数Nonce,比如nonce=random(0,1000000),这是为了防重发。我们的服务收到请求后,先把这个随机数nonce拼接access_key作为key存入redis,并设置超时时间(比如60秒)。如果请求被重放了,我们收到的请求的随机数一定是一样的。我们收到请求后也是拿nonce拼接access_key去查redis这个key是否存在,如果存在则这个可能是一个重放的请求,需要拒绝响应本次请求。

接口防异常流量

剩下的一个安全问题是接口并发保护问题。这其实是一个接口限流的问题,限流的策略很多了,我们可以自己定制,比如我的接口的限流策略可以这么设计

"xosujellsxy":{
    "secret":"dT2oX6sS6tB7iI7iR9jJ6uX4bQ8nY9kN",
    "name":"业务A(内部测试)",
    "start_time":"2021-06-25 23:15:00",
    "end_time":"2021-12-01 12:00:00",
    "minute_limit":10,
    "hour_limit":100,
}

appid为3001的团队,可以访问我的接口的有效时间是"2021-06-25 23:15:00"至"2021-12-01 12:00:00",超出该时间范围后一律拒绝服务;一分钟内的访问次数最多是10次;一小时内访问的次数最多是100次;通过这类业务层面的限流策略,可以比较好地挡住异常流量请求,主要是防业务方突然异常请求流量过多导致我方服务不可用,所以得给他们设置好限额访问。

这个分布式限时计数器可以利用redis的lua脚本简单实现下:

var checkCounter = redis.NewScript(`
	local now = ARGV[1]

	local r = redis.call('HGet', KEYS[1], KEYS[2]); 
	if (r == 0) then
		return 0
	end

	if (r ~= now) then
		redis.call('HSet', KEYS[1], KEYS[3], 0); 
		redis.call('HSet', KEYS[1], KEYS[2], now); 
	end

	return redis.call('HGet', KEYS[1], KEYS[3]); 
`)

var addCounter = redis.NewScript(`
    local now = ARGV[1]
    local cnt = ARGV[2]

    local r = redis.call('HIncrBy', KEYS[1], KEYS[3], cnt); 
    redis.call('HSet', KEYS[1], KEYS[2], now); 

    return r
`)

// 小时计数器
func AddHourCounter(key string, val int) (err error) {
	timeNow := time.Now()
	now := fmt.Sprintf("%d-%02d-%02d %02d", timeNow.Year(), timeNow.Month(), timeNow.Day(), timeNow.Hour())
	err = addCounter.Run(MicroRedisClient, []string{key, "hourcounter_time", "hourcounter"}, now, val).Err()
	return
}

func GetHourCounter(key string) (val int, err error) {
	timeNow := time.Now()
	now := fmt.Sprintf("%d-%02d-%02d %02d", timeNow.Year(), timeNow.Month(), timeNow.Day(), timeNow.Hour())
	result, err := checkCounter.Run(MicroRedisClient, []string{key, "hourcounter_time", "hourcounter"}, now).Int()
	val = result
	return
}

总结

通过上面的分析,一个符合安全策略的请求至少需要携带以下信息:

  1. access_key,标记请求方;
  2. timestamp,请求发起的时间,校验请求的时效性;
  3. nonce,随机数,用于防重放攻击;
  4. sign,数据签名,用于数据防篡改和身份验证;

总结下,我们在设计我们的HTTP接口时,可以考虑采用以下安全策略,以加强我们的接口安全性:

  • IP白名单,指定的IP才可访问;
  • 每个业务调用方需进行access_key的注册,只有注册了且获得秘钥的请求才可能请求成功;
  • 数据使用了指定秘钥对请求参数做了签名,防数据篡改+身份验证;
  • 校验请求发起的时间;
  • 接口限流,该调用方一分钟/一小时/一天限制多少次访问;
  • 随机数防重放;
  • 服务时间检查,只有处于指定的时间内才可访问成功。

阅读至此,你可能发现了,我们的安全策略没有包含“防监听”,即请求数据在传输链路上可能会被抓包看到里面的数据。因为要防监听需要用到非对称加密体系,这里的复杂度将大大提升,请求性能也也会大大降低,在多方考虑下,所以在现有的安全级别上,并不适合上这套安全策略,因此内网上被恶意监控数据传输的行为我们是默认接受的。