基于Spring Boot实现Zendesk Webhook签名真实性校验
Spring Boot 集成 Zendesk Webhook 验签实现方案
核心验签规则(基于Zendesk官方规范整理)
Zendesk Webhook的签名生成逻辑是公开固定的,核心规则如下:
- 签名算法使用
HMAC-SHA256 - 签名原文拼接规则:
X-Zendesk-Webhook-Signature-Timestamp头的值 + 原始HTTP请求体字符串,所有字符串编码统一使用UTF-8 - 最终生成的签名是HMAC计算结果经过Base64编码后的字符串
- 必须校验时间戳有效性,防止重放攻击,官方建议允许的时间偏差不超过5分钟
- 签名比对必须使用恒定时间比较算法,避免时序攻击
具体实现步骤
1. 配置签名密钥
将你在Zendesk Webhook后台配置的签名预共享密钥写入Spring Boot配置,不要硬编码在业务代码中:
# application.yml zendesk: webhook: # Zendesk后台配置的Webhook签名密钥 signing-secret: your_zendesk_webhook_signing_secret # 允许的请求时间戳最大偏差,单位秒,默认300秒即5分钟 allowed-timestamp-drift: 300
对应配置绑定类:
@ConfigurationProperties(prefix = "zendesk.webhook") @Data public class ZendeskWebhookProperties { private String signingSecret; private Integer allowedTimestampDrift = 300; }
在启动类或者配置类上加上@ConfigurationPropertiesScan让配置生效。
2. 解决请求体可重复读取问题
Servlet原生的请求输入流只能读取一次,验签逻辑读取原始请求体后,Controller层就无法再解析@RequestBody参数,所以先配置全局Filter,将请求包装为可缓存输入流的ContentCachingRequestWrapper:
@Configuration public class WebConfig implements WebMvcConfigurer { @Bean public FilterRegistrationBean<ContentCachingRequestFilter> contentCachingFilter() { FilterRegistrationBean<ContentCachingRequestFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new ContentCachingRequestFilter()); registrationBean.addUrlPatterns("/webhook/zendesk/*"); // 只拦截你的Webhook路径,减少性能损耗 registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); return registrationBean; } }
3. 实现验签拦截器
不要把验签逻辑写在Controller内部,通过Spring MVC拦截器统一做切面校验,逻辑和Controller解耦:
@Component @RequiredArgsConstructor public class ZendeskWebhookVerifyInterceptor implements HandlerInterceptor { private final ZendeskWebhookProperties webhookProperties; private static final String SIGNATURE_HEADER = "X-Zendesk-Webhook-Signature"; private static final String TIMESTAMP_HEADER = "X-Zendesk-Webhook-Signature-Timestamp"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 只拦截Controller方法请求 if (!(handler instanceof HandlerMethod)) { return true; } // 1. 取出两个必填请求头,缺失直接返回401 String signature = request.getHeader(SIGNATURE_HEADER); String timestampStr = request.getHeader(TIMESTAMP_HEADER); if (StringUtils.isBlank(signature) || StringUtils.isBlank(timestampStr)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } // 2. 校验时间戳合法性,防止重放攻击,注意确认Zendesk推送的时间戳是秒/毫秒级,对应调整单位即可 long requestTimestamp; try { requestTimestamp = Long.parseLong(timestampStr); } catch (NumberFormatException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } long currentTimestamp = Instant.now().getEpochSecond(); if (Math.abs(currentTimestamp - requestTimestamp) > webhookProperties.getAllowedTimestampDrift()) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } // 3. 读取原始请求体,注意必须用原始字节转UTF-8字符串,不能用反序列化后的对象转字符串 ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) request; // 必须先调用一次getInputStream()触发缓存,否则contentAsByteArray是空的 requestWrapper.getInputStream(); byte[] requestBodyBytes = requestWrapper.getContentAsByteArray(); String requestBody = new String(requestBodyBytes, StandardCharsets.UTF_8); // 4. 计算期望签名 String signPayload = timestampStr + requestBody; Mac hmacSha256 = Mac.getInstance("HmacSHA256"); SecretKeySpec secretKeySpec = new SecretKeySpec( webhookProperties.getSigningSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256" ); hmacSha256.init(secretKeySpec); byte[] expectedSignatureBytes = hmacSha256.doFinal(signPayload.getBytes(StandardCharsets.UTF_8)); String expectedSignature = Base64.getEncoder().encodeToString(expectedSignatureBytes); // 5. 恒定时间比对签名,禁止使用String.equals()避免时序攻击 boolean signMatch = MessageDigest.isEqual( expectedSignature.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8) ); if (!signMatch) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } // 验签通过放行 return true; } }
然后把拦截器注册到拦截器链中,在之前的WebConfig类里重写addInterceptors方法:
@Autowired private ZendeskWebhookVerifyInterceptor zendeskWebhookVerifyInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(zendeskWebhookVerifyInterceptor) .addPathPatterns("/webhook/zendesk/*"); // 对应你的Webhook接口路径 }
常见踩坑点
- 不要用
@RequestBody解析后的实体类转JSON字符串来计算签名:JSON序列化框架可能会调整字段顺序、忽略空字段、改变空格格式,会直接导致签名计算不匹配,必须用最原始的请求字节流 - 不要用
String.equals()比对签名:该方法遇到不匹配的字符会立即返回,攻击者可以通过响应时间差逐位爆破合法签名,必须使用MessageDigest.isEqual()这类恒定时间比较方法 - 不要跳过时间戳校验:否则攻击者截获到一次合法请求后可以无限重放该请求
- 验签失败时不要返回具体的失败原因(比如是时间戳不对还是签名不对),直接返回401状态码即可,减少攻击者可利用的信息
内容的提问来源于stack exchange,提问作者Jae
相关产品推荐
相关产品推荐

