You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 05:15:30