在前后端分离的架构中,HTTP请求的安全性往往被简化为HTTPS加密,但仅仅依赖传输层加密并不足以防范所有攻击。重放攻击就是一个典型的例子。攻击者截获一个合法的请求,哪怕看不懂内容,原封不动地重新发送,依然可能触发转账、下单或数据修改。解决这个问题的核心思路之一,是在每个请求上附加一个唯一且时效性强的参数,服务端据此判断请求是否新鲜。用Node.js开发时,axios是最常用的HTTP客户端,在它的请求拦截器里统一注入时间戳和签名,是成本最低、覆盖面最全的防重放方案。

重放攻击的本质与时间戳的防御边界

重放攻击之所以有效,是因为服务端无法区分一个请求是用户主动发起的,还是被复制后重新发送的。时间戳的引入,给请求打上了“出生证明”。服务端拿到时间戳后,与当前服务器时间对比,如果偏差超过设定的窗口(比如60秒),直接拒绝。这个机制简单有效,但有两个关键点必须处理好:一是客户端与服务端的时间同步问题,二是时间戳本身可以被篡改,所以必须配合签名校验,否则攻击者修改时间戳后重放依然可行。

axios拦截器的工作模型

axios的拦截器分为请求拦截器和响应拦截器。请求拦截器在请求发出之前执行,可以修改config对象,这正是注入公共参数的理想位置。通过axios.interceptors.request.use方法,我们可以注册一个函数,每次请求都会经过它。在这个函数里,计算当前时间戳、生成签名,然后把它们拼接到URL参数或请求头中,最后返回处理后的config,整个流程对业务代码完全透明。

基础实现:在请求拦截器中注入时间戳

先从最直接的写法开始。假设我们使用axios.create创建了一个实例,接下来在请求拦截器中获取当前毫秒时间戳,并将其添加到params对象中。这样所有使用该实例发出的GET请求,URL后面都会自动带上timestamp参数。

const axiosInstance = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000
});

axiosInstance.interceptors.request.use(config => {
  config.params = config.params || {};
  config.params.timestamp = Date.now();
  return config;
}, error => {
  return Promise.reject(error);
});

这段代码对于GET请求没问题,但对于POST请求,参数通常放在请求体里,这时候就需要根据请求方法判断注入位置。更稳妥的做法是统一放在请求头中,这样不受请求方法影响,而且语义更清晰,不会污染业务参数。

进阶方案:时间戳加签名,防止参数被篡改

单纯传一个时间戳,攻击者完全可以修改时间戳后重放。所以必须引入签名机制。常见的做法是:将请求参数、时间戳、请求路径以及一个客户端与服务端共享的密钥,按照固定顺序拼接成字符串,然后计算其哈希值(如MD5或SHA256),作为签名一起发送。服务端收到请求后,用同样的方式计算签名,对比是否一致。由于攻击者不知道密钥,无法伪造签名。

在axios拦截器中的实现步骤如下:首先,定义一个签名生成函数,它接收config对象和时间戳,提取method、url、params、data等要素,排序后拼接,再使用crypto模块计算哈希。然后,在请求拦截器中调用该函数,将时间戳和签名都放入请求头。

const crypto = require('crypto');
const SECRET_KEY = 'your-secret-key';

function generateSignature(config, timestamp) {
  const { method, url, params, data } = config;
  const paramStr = params ? JSON.stringify(params) : '';
  const dataStr = data ? JSON.stringify(data) : '';
  const rawStr = `${method}&${url}&${paramStr}&${dataStr}&${timestamp}&${SECRET_KEY}`;
  return crypto.createHash('sha256').update(rawStr).digest('hex');
}

axiosInstance.interceptors.request.use(config => {
  const timestamp = Date.now().toString();
  const signature = generateSignature(config, timestamp);
  config.headers['X-Timestamp'] = timestamp;
  config.headers['X-Signature'] = signature;
  return config;
}, error => {
  return Promise.reject(error);
});

注意,这里对params和data进行了JSON.stringify处理,目的是保证序列化的一致性。如果参数中包含嵌套对象或数组,直接拼接会导致服务端验签失败。另外,密钥SECRET_KEY绝对不能出现在前端代码中,这个方案只适用于Node.js后端之间的通信,或者Node.js作为中间层调用第三方服务时使用。如果是浏览器端,密钥暴露是不可避免的,此时需要换成动态协商的Token机制。

时间窗口的设定与时钟同步问题

服务端验证时间戳时,通常会设置一个允许的偏差范围,比如±60秒。这个窗口太大会降低安全性,太小又会因为网络延迟或时钟不同步导致正常请求被拒绝。实践中,30秒到120秒是常见的选择。如果客户端是移动设备或分布式系统,时钟偏差可能更大,可以考虑在响应头中返回服务器时间,客户端据此校准本地时钟偏移量,并在生成时间戳时进行修正。这个校准逻辑可以放在响应拦截器中统一处理,避免每个业务模块单独处理。

let serverTimeOffset = 0;

axiosInstance.interceptors.response.use(response => {
  const serverTime = response.headers['x-server-time'];
  if (serverTime) {
    serverTimeOffset = parseInt(serverTime) - Date.now();
  }
  return response;
}, error => {
  return Promise.reject(error);
});

axiosInstance.interceptors.request.use(config => {
  const correctedTime = Date.now() + serverTimeOffset;
  config.headers['X-Timestamp'] = correctedTime.toString();
  // 签名逻辑...
  return config;
}, error => {
  return Promise.reject(error);
});

通过这种方式,客户端的时间戳会逐步向服务端对齐,即使设备时间不准确,也能保证请求在有效窗口内到达。

防重放的高级策略:Nonce一次性随机数

时间戳方案有一个天生缺陷:在同一个时间窗口内,攻击者截获请求后依然可以立即重放。要彻底堵住这个漏洞,需要引入Nonce(Number used once),即一次性随机数。客户端每次请求生成一个全局唯一的随机字符串,随请求一起发送,服务端记录已使用过的Nonce,在有效期内同一个Nonce只能使用一次。这样,即使时间戳和签名都正确,只要Nonce重复,请求就会被拒绝。

在axios拦截器中,可以使用uuid库生成Nonce,并将其放入请求头。服务端需要用Redis等缓存存储Nonce,并设置过期时间与时间戳窗口一致。

const { v4: uuidv4 } = require('uuid');

axiosInstance.interceptors.request.use(config => {
  const timestamp = Date.now().toString();
  const nonce = uuidv4();
  const signature = generateSignature(config, timestamp, nonce);
  config.headers['X-Timestamp'] = timestamp;
  config.headers['X-Nonce'] = nonce;
  config.headers['X-Signature'] = signature;
  return config;
}, error => {
  return Promise.reject(error);
});

签名生成函数也需要把Nonce加入拼接字符串,确保Nonce本身不被篡改。这样一来,攻击者即使截获了完整的请求,只要服务端已经见过这个Nonce,重放就会失败。Nonce的引入几乎完全消除了时间窗口内的重放风险,代价是服务端需要额外的存储和查询开销,但在绝大多数业务场景下,这点开销完全可以接受。

实际部署中的细节与避坑指南

第一,区分请求类型。不是所有请求都需要防重放,比如查询类接口、静态资源请求,加上时间戳和签名反而浪费资源。可以在axios实例上通过自定义配置项来控制哪些请求需要注入。例如,在config上增加一个needAntiReplay属性,拦截器中判断该属性为true时才注入参数。

axiosInstance.interceptors.request.use(config => {
  if (config.needAntiReplay) {
    // 注入时间戳、Nonce、签名
  }
  return config;
});

第二,处理请求重试。防重放机制下,一个请求如果因为网络问题失败,直接重试会导致Nonce重复而被服务端拒绝。因此,重试时必须重新生成Nonce和时间戳。如果项目中使用了axios-retry等重试库,需要在重试拦截器中重新执行注入逻辑,或者将注入逻辑放在重试拦截器之后执行。

第三,POST请求的body处理。如果请求体是FormData或者文件上传,签名时不能直接JSON.stringify,需要特殊处理。对于文件上传,通常只对非文件字段进行签名,文件内容本身不参与签名计算,否则性能开销太大。

第四,密钥管理。生产环境中,密钥绝对不能硬编码在代码里,应该从环境变量或配置中心读取。同时,建议定期轮换密钥,轮换时可以通过版本号标识当前使用的密钥版本,服务端同时支持新旧两个版本,平滑过渡。

与现有安全体系的配合

时间戳加签名的防重放机制,本质上是应用层的安全加固,它和HTTPS、Token认证、IP限流等措施是互补关系,而非替代关系。HTTPS保证了传输过程中数据不被窃听和篡改,Token认证确认了用户身份,IP限流防止了暴力请求,而时间戳和Nonce则专门解决请求重放问题。在实际生产环境中,这些机制应该同时启用,形成纵深防御。在Node.js中间层或BFF层,使用axios请求下游服务时,统一加上防重放参数,可以显著提升整体系统的安全性,而且对业务代码零侵入,这是该方案最大的工程价值。

总结

利用axios请求拦截器统一添加时间戳和签名,是Node.js应用中实现防重放攻击的高效手段。核心思路是在请求发出前,自动注入时间戳、Nonce和签名,服务端验证时间窗口和签名合法性,并记录已使用的Nonce。实现过程中需要注意时间同步、密钥安全、重试场景处理以及与现有安全机制的配合。这套方案成本低、覆盖面全、对业务透明,适合在各类Node.js项目中推广使用。