第三章 小程序网络通信安全:HTTPS配置、证书绑定、请求签名、防重放攻击

说实话,小程序网络通信这块,是我见过最多人踩坑的地方。

很多开发者觉得,只要用了 HTTPS 就万事大吉了。嗯,我以前也这么想。直到有一次,我帮一个金融类小程序做安全审计,发现他们的请求包居然能被完整抓包解析——虽然用了 HTTPS,但证书校验根本没做对。

今天我们就来把这几个关键点彻底讲透。

3.1 HTTPS 配置:不只是打个勾那么简单

小程序要求所有请求必须走 HTTPS,这个大家都知道。但配置 HTTPS 时,有几个细节我建议你特别注意。

核心要点:小程序后台的「合法域名」配置,必须和证书上的域名完全一致。包括 www 前缀。

我在项目中遇到过一个问题:证书配的是 api.example.com,但小程序请求写的是 example.com/api。结果呢?请求直接报错。你想想看,浏览器访问可能还能自动跳转,但小程序不会给你任何容错。

配置步骤其实不复杂:

  1. 在微信公众平台「开发」-「开发设置」中配置服务器域名
  2. 确保 HTTPS 证书是正规 CA 签发的(别用自签名证书)
  3. TLS 版本必须支持 1.2 及以上

警告:千万不要为了省事把「不校验合法域名」勾上。那是开发调试用的,上线前必须关掉。我曾经见过一个线上事故,就是因为这个选项没关,导致用户数据被中间人截获。

3.2 证书绑定:给通信加把锁

HTTPS 本身是安全的,但有个问题——如果用户的设备被植入了恶意根证书,HTTPS 也能被解密。这就是所谓的「中间人攻击」。

怎么防?证书绑定(Certificate Pinning)。

说白了,就是客户端只认你指定的那个证书。别的证书就算看起来合法,也一律拒绝。

在小程序里实现证书绑定,我一般用这个思路:

// 获取服务器证书的公钥哈希值
// 在请求回调中校验
const checkCertificate = (response) => {
  // 这里假设 response 里包含了证书信息
  const serverCertHash = response.certificateHash;
  const pinnedHash = '你的预置哈希值';
  
  if (serverCertHash !== pinnedHash) {
    throw new Error('证书校验失败,可能存在中间人攻击');
  }
};

// 使用时
wx.request({
  url: 'https://api.example.com/data',
  success: (res) => {
    try {
      checkCertificate(res);
      // 正常处理数据
    } catch (e) {
      // 处理异常
    }
  }
});

提示:证书绑定不要只绑一个证书。建议绑定证书的「公钥」而不是整个证书。这样证书续期时,只要公钥不变,就不会影响业务。我习惯绑定 2-3 个备用公钥,防止主证书出问题。

3.3 请求签名:防止数据被篡改

HTTPS 保证了传输过程的安全,但到了服务器端,你怎么确认这个请求真的是你的小程序发出来的?

答案就是请求签名。

我常用的签名方案是这样的:

  1. 客户端把请求参数按字典序排序
  2. 拼接成字符串,加上时间戳和随机数
  3. 用约定的密钥进行 HMAC-SHA256 加密
  4. 把签名放在请求头里传给服务器

来看个具体例子:

// 生成签名
const generateSignature = (params, secretKey) => {
  // 1. 排序参数
  const keys = Object.keys(params).sort();
  let signStr = '';
  
  // 2. 拼接
  for (let key of keys) {
    signStr += `${key}=${params[key]}&`;
  }
  
  // 3. 加上时间戳和随机数
  const timestamp = Date.now();
  const nonce = Math.random().toString(36).substr(2, 10);
  signStr += `timestamp=${timestamp}&nonce=${nonce}`;
  
  // 4. HMAC 加密
  const signature = CryptoJS.HmacSHA256(signStr, secretKey).toString();
  
  return {
    signature,
    timestamp,
    nonce
  };
};

// 使用
const params = {
  userId: '12345',
  amount: 100,
  type: 'pay'
};

const signedData = generateSignature(params, '你的密钥');
wx.request({
  url: 'https://api.example.com/pay',
  header: {
    'X-Signature': signedData.signature,
    'X-Timestamp': signedData.timestamp,
    'X-Nonce': signedData.nonce
  },
  data: params,
  success: (res) => {
    // 处理结果
  }
});

关键点:密钥绝对不能写死在代码里。我建议通过登录接口动态获取,或者使用小程序的云函数来管理密钥。曾经有个项目,密钥写死在 JS 里,结果被反编译直接拿到了——那叫一个惨。

3.4 防重放攻击:让过期请求失效

有了签名,攻击者就不能篡改数据了。但他可以「重放」——把同一个请求再发一次。

比如一个转账请求,如果被重放,用户可能被扣两次钱。这就是重放攻击。

防重放的核心思路就两个:

  • 时间戳校验:服务器检查请求时间戳,超过 5 分钟的直接拒绝
  • 随机数去重:每个请求带一个唯一随机数,服务器记录已用过的随机数

我一般这样实现:

// 服务器端伪代码
const checkReplayAttack = (timestamp, nonce, signature) => {
  // 1. 检查时间戳
  const now = Date.now();
  if (now - timestamp > 5 * 60 * 1000) {
    return false; // 请求过期
  }
  
  // 2. 检查随机数是否已使用
  if (redis.exists(`nonce:${nonce}`)) {
    return false; // 重复请求
  }
  
  // 3. 记录随机数(设置过期时间,防止内存泄漏)
  redis.setex(`nonce:${nonce}`, 300, '1');
  
  // 4. 验证签名(略)
  return true;
};

避坑指南:我曾经犯过一个错——随机数只用时间戳。结果同一毫秒内的请求全被当成重放。后来改成「时间戳 + 随机字符串」的组合,才彻底解决。记住,随机数必须保证全局唯一。

3.5 把这些组合起来:一个完整的请求流程

说了这么多,我们来串一下完整的流程:

步骤 客户端 服务器
1 生成请求参数 等待请求
2 计算签名(含时间戳+随机数) -
3 通过 HTTPS 发送请求 接收请求
4 - 校验证书绑定
5 - 检查时间戳是否过期
6 - 检查随机数是否重复
7 - 验证签名
8 收到响应 返回数据

你看,每一步都在解决一个特定的安全问题。少了任何一环,整个安全体系就会出现漏洞。

最后提醒:安全不是一劳永逸的事。我建议每季度做一次安全审计,检查证书是否过期、签名算法是否需要升级、随机数存储是否正常。别等到出了事故才想起来——那代价可就大了。

好了,网络通信安全这块就讲到这里。下一章我们会聊聊小程序本地存储的安全问题,包括数据加密、Key 管理、以及如何防止数据被篡改。到时候见。