Jackson序列化让验签失败?破解JSON转义陷阱

最近在开发过程中遇到了一个的挺有意思的验签问题。我们的项目采用了前后端签名验证机制来保证数据安全:

  • 前端:请求时对 body 进行签名
  • 后端:接收后对 body 进行验签

正常情况下都没啥问题,但这次比较奇怪:

  1. 后端接口 A 返回了一个对象,其中某个字段是 JSON 字符串(String 类型)
  2. 前端拿到这个对象后,会把这个json字符串原模原样的作为参数传给后端接口 B
  3. 然后就。。。验签失败了

我们就陷入了互相甩锅的局面:

  • 后端认为:前端验签时做了特殊处理
  • 前端很无辜:用的是公共库,从没改过
  • 折中共识:可能是 HTTP 传输过程中对 JSON 做了转义

经过一番调试发现:

  • 前端验签时,JSON 字符串字段有一次转义字符 (如 ")
  • 后端收到的 body 中,JSON 字符串字段有二次转义字符 (如 \")

双方验签的数据不一致,自然验证失败。

临时方案:对这个字段做 Base64 编码,绕过转义问题。

但事实真的是 HTTP 传输导致的转义吗?当然不是

HTTP 请求本身不会对数据进行转义。那转义字符是从哪里来的?

问题根源

整个流程是这样的:

第一步:后端接口 A 返回数据(第一次序列化)

kotlin 复制代码
// 后端返回的对象
public class Response {
    private String data;  // 这里存储的是 JSON 字符串
}

// 接口 A 返回数据
return new Response("{"name":"张三","age":25}");

Spring Boot 在响应时会自动进行 JSON 序列化,由于 data 字段本身是字符串,会对其中的特殊字符进行转义:

json 复制代码
{
  "data": "{"name":"张三","age":25}"
}

第二步:前端验签(基于一次转义的数据)

前端拿到响应后,对整个对象进行验签:

javascript 复制代码
// 前端收到的数据
const response = {
  data: "{"name":"张三","age":25}"  // 带有一次转义
};

// 对这个对象做验签
const signature = sign(JSON.stringify(response));

此时验签的原始数据包含一次转义字符

第三步:前端请求接口 B(第二次序列化)

前端将这个对象作为 body 请求接口 B:

arduino 复制代码
// 将对象发送给接口 B
axios.post('/api/b', response);

浏览器或 HTTP 库会再次对 body 进行 JSON 序列化,JSON 字符串又会被转义一次:

swift 复制代码
{
  "data": "{\"name\":\"张三\",\"age\":25}"
}

注意:" 变成了 \"(二次转义)

第四步:后端接口 B 验签(基于二次转义的数据)

后端收到请求后,拿到的是经过二次转义的数据进行验签:

swift 复制代码
// 后端收到的 body(二次转义)
String body = "{\"name\":\"张三\",\"age\":25}";

// 验签失败!因为转义字符数量不一致

为什么验签会失败?

  • 前端验签 :基于一次转义的数据 {"name":...}
  • 后端验签 :基于二次转义的数据 {\"name\":...}

两边计算签名的原始数据不一致,自然就验签失败了

最快的方案

不要在对象中嵌套 JSON 字符串,直接返回 JSON 对象

kotlin 复制代码
// 错误做法
public class Response {
    private String data;  // JSON 字符串
}

// 正确做法
public class Response {
    private UserInfo data;  // JSON 对象
}

public class UserInfo {
    private String name;
    private Integer age;
}

这样 Spring Boot 只会进行一次序列化,不会产生转义字符,前后端处理的数据格式完全一致。

知己知彼

既然找到了问题根源,正好就深入源码看看 Jackson 是如何对字符串进行序列化的。Spring Boot 默认使用 Jackson 作为 JSON 序列化框架。

序列化调用链路

1. BeanSerializerBase - 对象序列化入口

序列化一般会调用 BeanSerializer,其核心实现在 BeanSerializerBase 中:

ini 复制代码
protected void serializeFields(Object bean, JsonGenerator gen, SerializerProvider provider)
        throws IOException {
    final BeanPropertyWriter[] props;  // 对象的所有字段属性
    if (_filteredProps != null && provider.getActiveView() != null) {
        props = _filteredProps;
    } else {
        props = _props;
    }

    int i = 0;
    try {
        // 遍历所有字段进行序列化
        for (final int len = props.length; i < len; ++i) {
            BeanPropertyWriter prop = props[i];
            if (prop != null) {
                prop.serializeAsField(bean, gen, provider);  // 关键调用
            }
        }
        // ... 省略其他代码
    }
}

2. BeanPropertyWriter - 字段序列化处理

每个字段会调用 serializeAsField 方法,根据字段类型选择对应的序列化器:

ini 复制代码
public void serializeAsField(Object bean, JsonGenerator gen, SerializerProvider prov)
        throws Exception {
    // 获取字段值
    final Object value = (_accessorMethod == null)
        ? _field.get(bean)
        : _accessorMethod.invoke(bean, (Object[]) null);

    // 处理 null 值
    if (value == null) {
        if (_nullSerializer != null) {
            gen.writeFieldName(_name);
            _nullSerializer.serialize(null, gen, prov);
        }
        return;
    }

    // 获取序列化器(关键:根据字段类型选择不同的序列化器)
    JsonSerializer<Object> ser = _serializer;
    if (ser == null) {
        Class<?> cls = value.getClass();
        PropertySerializerMap m = _dynamicSerializers;
        ser = m.serializerFor(cls);  // String 类型会返回 StringSerializer
        if (ser == null) {
            ser = _findAndAddDynamic(m, cls, prov);
        }
    }

    // 写入字段名和值
    gen.writeFieldName(_name);
    ser.serialize(value, gen, prov);  // 调用具体的序列化器
}

核心要点

  • 如果字段是对象,会递归调用 BeanSerializer
  • 如果字段是字符串,会调用 StringSerializer

3. StringSerializer

当字段类型是 String 时,使用 StringSerializer 进行序列化:

java 复制代码
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider provider)
        throws IOException {
    gen.writeString((String) value);  // 调用 JsonGenerator 写入字符串
}

4. UTF8JsonGenerator

由于默认使用 UTF-8 编码,最终会调用 UTF8JsonGenerator.writeString 方法:

kotlin 复制代码
public void writeString(String text) throws IOException {
    this._verifyValueWrite("write a string");
    if (text == null) {
        this._writeNull();
    } else {
        int len = text.length();
        if (len > this._outputMaxContiguous) {
            this._writeStringSegments(text, true);
        } else {
            if (this._outputTail + len >= this._outputEnd) {
                this._flushBuffer();
            }

            // 添加开始引号
            this._outputBuffer[this._outputTail++] = this._quoteChar;

            // 写入字符串内容(这里会对特殊字符进行转义!)
            this._writeStringSegment((String)text, 0, len);

            if (this._outputTail >= this._outputEnd) {
                this._flushBuffer();
            }

            // 添加结束引号
            this._outputBuffer[this._outputTail++] = this._quoteChar;
        }
    }
}

关键点_writeStringSegment 方法会对字符串中的特殊字符(如 "、``、换行符等)进行转义处理。

转义示例对比

序列化之后就多了一层转义符号

主要的调用链路

scss 复制代码
对象序列化
  ↓
BeanSerializerBase.serializeFields()  // 遍历对象字段
  ↓
BeanPropertyWriter.serializeAsField()  // 处理单个字段
  ↓
StringSerializer.serialize()  // String 类型走这里
  ↓
UTF8JsonGenerator.writeString()  // 执行转义
  ↓
_writeStringSegment()  // 转义特殊字符(" → ")

理解了这个调用链路,就能明白为什么 JSON 字符串在序列化时会产生转义字符,以及为什么多次序列化会导致多层转义。

总结

  1. 尽量避免在对象中嵌套 JSON 字符串,应该使用强类型对象,避免多次序列化带来的转义问题
  2. HTTP 传输不会篡改数据,问题往往出在序列化/反序列化环节
  3. 理解序列化的本质:每序列化一次,JSON 字符串就会多一层转义
  4. 遇到验签问题,优先检查双方处理数据时经历了几次序列化

验签虽然好,但有时候也挺麻烦的。所以理解数据在前后端之间的流转过程和序列化时机,可以更好避免这样的验签问题。

相关推荐
Evan芙2 小时前
使用inotify + rsync和sersync实现文件的同步,并且总结两种方式的优缺点
java·服务器·网络
爱笑的眼睛112 小时前
PyTorch自动微分:超越基础,深入动态计算图与工程实践
java·人工智能·python·ai
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ2 小时前
java实现登录:多点登录互踢,30分钟无操作超时
java·前端
LambHappiness2 小时前
Grafana LDAP配置故障排查:从3小时到10分钟的AI辅助解决方案
后端
Three K2 小时前
Redisson限流器特点
java·开发语言
Halo_tjn2 小时前
Java 多线程机制
java·开发语言·windows·计算机
Jeff-Nolan2 小时前
C++运算符重载
java·开发语言·c++
她说..2 小时前
Spring AOP场景3——接口防抖(附带源码)
java·后端·spring·java-ee·springboot