ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SnakeYAML 2.0升级避坑指南:从Constructor到依赖冲突的完整实践

SnakeYAML 2.0升级避坑指南:从Constructor到依赖冲突的完整实践 1. 升级之前先搞清楚2.0这次“破坏兼容”的底层原因SnakeYAML 2.0之前的版本Yaml类的默认构造器是Constructor它允许YAML文档里出现自定义tag时直接在classpath里反射创建任意Java对象。这是一个很方便的特性但也是安全隐患——当YAML内容来自外部输入时攻击者构造一个带有恶意tag的文档就能让应用实例化任意类配合某些利用链就能造成远程代码执行。这就是CVE-2022-1471的核心。2.0的修复方式简单粗暴把默认构造器替换成SafeConstructor。SafeConstructor在发现无法识别的tag时不再尝试实例化只处理Java基础类型和集合类型等于把“一切皆可实例化”这个后门给焊死了。这个改变对安全团队是好事但对业务代码来说所有依赖“默认可实例化自定义类”的用法都被迫要改造。我当时在负责一个Spring Boot 3.0项目的YAML配置解析模块业务里用yaml.loadAs()直接映射自定义Bean升级后第一轮就爆出两个类转换异常一个运行期报错一个编译期直接过不去。后面围绕这两个问题又排查出依赖冲突、默认大小限制、自定义tag注册三连坑整个过程虽然耗时间但把2.0的很多API变化都过了一遍。这篇文章我按“坑”来写每个坑都先说现象、再说原理、最后给解决办法。已经升级到2.0但被各种异常卡住的人可以直接跳到对应小节去对照不用从头翻。2. 第一个坑自定义Java类加载失败不再是“默认就能用”的了2.1 现象ClassCastException和Cannot create property升级前很多项目的代码长这样Yaml yaml new Yaml(); MyConfig config yaml.loadAs(yamlString, MyConfig.class);这段代码在SnakeYAML 1.x下运行正常但在2.0下要么直接抛异常要么返回的对象无法转换。最常见的是这种Exception in thread main java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class com.example.MyConfig还有另一类报错是在解析带内嵌对象的YAML时出现的Exception in thread main org.yaml.snakeyaml.error.YAMLException: Cannot create propertyname看到这两个报错基本可以确认问题出在Yaml类的默认构造器上。但“基本确认”和“彻底解决”之间还隔着一个对SafeConstructor的理解。2.2 原理默认构造器从Constructor变成了SafeConstructorSnakeYAML 2.0的new Yaml()等价于new Yaml(new SafeConstructor(new LoaderOptions()))。SafeConstructor的白名单里只有Java核心库里那些安全的类型比如String、Integer、List、Map等遇到自定义Bean它不会去反射实例化而是直接把YAML结构当成Map返回。所以你调用yaml.loadAs(yamlString, MyConfig.class)的时候内部实际解析出来的是一个LinkedHashMap再做类型转换自然就炸了。这里的loadAs其实掩盖了一个细节SnakeYAML在解析阶段并不关心你传入的MyConfig.class它只关心构造器能不能把YAML节点转换为目标类型。SafeConstructor不认识MyConfig于是直接放弃返回它自己认识的Map结构。这里顺带说一句如果你的代码只做yaml.load()然后强转成Map或List没有自定义tag升级到2.0之后通常是能正常工作的因为Map和List本来就在SafeConstructor的白名单里。真正受影响的是那些“解析出来直接变成业务对象”的用法。2.3 解决办法显式指定一个允许加载目标类型的Constructor正确写法是这样LoaderOptions loaderOptions new LoaderOptions(); Constructor constructor new Constructor(MyConfig.class, loaderOptions); Yaml yaml new Yaml(constructor); MyConfig config yaml.loadAs(yamlString, MyConfig.class);如果你要加载各种类型的Bean建议在Constructor里通过addTypeDescription注册多个类型或者给一个比较宽泛的根类。注意Constructor类在2.0里有带LoaderOptions的构造方法我强烈建议所有场景都传LoaderOptions别用不传参的旧写法。因为不带LoaderOptions你就没法调整长文档限制、别名数量限制这些参数后面遇到第3个坑还得再改一遍代码。另外还有一个隐含要求被加载的Bean必须有公共的无参构造函数。SnakeYAML不是靠构造器参数去做依赖注入的而是先无参构造出一个对象再通过setter或字段反射赋值。如果你的Bean只有带参构造器就算你显式传了Constructor也会报错。2.4 想完全恢复旧行为可以但我不推荐如果你只想让升级后行为和1.x一模一样可以这样写LoaderOptions options new LoaderOptions(); Yaml yaml new Yaml(new Constructor(options));Constructor的默认行为仍然允许通过YAML里的tag实例化任意类。但我要提醒一句CVE-2022-1471针对的就是这种用法安全团队扫描到还是会拦。除非你的YAML全部来自可信的受控来源否则不建议这样做。我当时跟安全组确认过他们给的意见很直接业务代码里不要出现new Constructor(options)这种“全量放开”的写法只能针对具体已知的Bean类型做限定。3. 第二个坑配置稍微大点就报codePointLimit超标3.1 现象The code point limit of YAML input exceeded第一个坑解决完之后跑了没几天我这边有几个跑了好几年的配置导入任务开始报错堆栈指向org.yaml.snakeyaml.error.YAMLException: The code point limit of YAML input exceeded第一反应是先怀疑是不是文件被截断了后来仔细一看不是文件问题是SnakeYAML 2.0引入了一个默认输入长度限制CodePointLimit默认值是300万个代码点。原来1.x版本不限制加载的字符数量现在2.0为了防护“超长YAML拖垮内存”这类攻击把所有文档都按代码点数做了上限。我的配置文件里有很多重复规则一个文件轻轻松松超过3MB于是直接被拦在门外。这里有个容易混淆的点CodePointLimit的单位不是字节不是字符而是Unicode代码点。对英文、数字为主的配置文件代码点数量和字符数基本一致如果文件里有大量中文一个汉字通常算一个代码点但字符串的length()返回的是UTF-16编码单元数两者可能出现差异。尤其是包含emoji或其他非BMP字符时一个字符可能占两个UTF-16单元但代码点还是算一个。所以估算时别按文件字节数去算按文本的字符数去估更稳妥。3.2 解决办法调整LoaderOptions的CodePointLimitLoaderOptions loaderOptions new LoaderOptions(); loaderOptions.setCodePointLimit(10 * 1024 * 1024); // 调大到1000万 Yaml yaml new Yaml(new Constructor(MyConfig.class, loaderOptions));我建议直接设成业务能接受的合理上限比如正常配置文件最大可能到8MB那就设成10MB或者1200万代码点留点余量。别设成Integer.MAX_VALUE那样等于把这个安全限制关了以后真有攻击者丢一个上GB的YAML进来应用内存直接被打满。3.3 其他几个默认限制先看一眼2.0除了CodePointLimit还有几个默认限制也可能在你升级后突然冒出来我整理了一个表格限制项默认值说明CodePointLimit3000000单文档最大代码点数超过直接抛异常NestingDepthLimit50集合/映射最大嵌套层数MaxAliasesForCollections50单个集合的别名数量上限AllowDuplicateKeystrue允许重复key建议按业务收紧AllowRecursiveKeysfalse禁止递归key开启需谨慎这些限制里最容易被忽略的是NestingDepthLimit。我后来还遇到过一次深度限制报错因为配置生成工具输出了一长串嵌套的列表层级超过50层升级前根本跑得好好的升级后直接报错。解决办法是在LoaderOptions里调大loaderOptions.setNestingDepthLimit(100);但个人建议别一上来就无脑调大先看看到底是什么业务导致的。如果正常配置结构本来就浅突然报嵌套过深说不定是YAML本身有异常循环引用调大限制反而把问题掩盖了。4. 第三个坑依赖冲突和NoSuchMethodError1.x和2.0同时在classpath里4.1 现象编译没报错运行期NoSuchMethodError把snakeyaml升级到2.0之后我遇到的最烦的一个坑是pom里明明已经指定了2.0启动时却报java.lang.NoSuchMethodError: org.yaml.snakeyaml.constructor.SafeConstructor.init()V还有变体java.lang.NoSuchMethodError: org.yaml.snakeyaml.Yaml.init(Lorg/yaml/snakeyaml/constructor/BaseConstructor;)V这种错误最迷惑人的地方在于本地IDE编译的时候不报错因为编译器用的是你pom里声明的2.0版本一运行就报NoSuchMethodError因为classpath里实际加载的类来自旧版本。4.2 原因其他库通过传递依赖把1.x带进了classpathSnakeYAML 2.0里SafeConstructor的构造方法签名变了。1.x里有无参构造器2.0里需要传LoaderOptions。如果你的项目里还有其他中间件被snakeyaml:1.x传递依赖而这个旧版本在classpath里被先生命那么运行时加载的就是旧类。旧类里没有新签名的方法自然就NoSuchMethodError了。还有一个更隐蔽的情况即使你用了maven-enforcer-plugin强制所有模块都用2.0但如果某个依赖在编译期直接引用了1.x独有的方法签名运行期还是会在那个库内部触发NoSuchMethodError。这种问题在旧版JRuby、旧版Drools、旧版MongoDB驱动里都出现过。4.3 排查命令和排除方案用Maven项目举例子先看全貌mvn dependency:tree -Dincludesorg.yaml:snakeyaml输出里找到版本为1.x的条目再看它的引入路径。比如[INFO] - org.apache.shiro:shiro-core:jar:1.9.0:compile [INFO] \- org.yaml:snakeyaml:jar:1.30:compile找到引入方之后在依赖声明里排除dependency groupIdorg.apache.shiro/groupId artifactIdshiro-core/artifactId version1.9.0/version exclusions exclusion groupIdorg.yaml/groupId artifactIdsnakeyaml/artifactId /exclusion /exclusions /dependencyGradle项目这样写implementation(org.apache.shiro:shiro-core:1.9.0) { exclude group: org.yaml, module: snakeyaml }排除完成后再次执行mvn dependency:tree -Dincludesorg.yaml:snakeyaml确认只剩一个2.0版本。4.4 一个容易遗漏的细节排除依赖不一定能解决所有问题有时候你确实已经把classpath里的snakeyaml强制到2.0了但还是报NoSuchMethodError。这种时候我一般会检查是不是某个中间件对SnakeYAML 1.x的API做了编译期绑定。这类库即使classpath上放2.0运行时还是会在内部调用被删掉的公开方法然后挂掉。这种问题没什么优雅解法只能给依赖库升级版本或者干脆别在你自己的代码里直接依赖SnakeYAML的底层API改成让框架去处理应用层只做字符串操作。我当时是写了一个中间层把所有YAML解析入口封装起来依赖冲突只影响这个中间层排查范围一下子缩小了很多。5. 第四个坑自定义Tag与TypeDescription的规则变了5.1 曾经常用的自定义Tag写法在2.0里可能失效SnakeYAML支持为Java类型和YAML tag建立映射。1.x时代常见的写法是TypeDescription desc new TypeDescription(MyClass.class); desc.putMapProperty(properties, MyProperty.class); Constructor constructor new Constructor(MyClass.class); constructor.addTypeDescription(desc);这段代码在2.0里仍然能编译大部分场景也能用但有一个细节变了Constructor和SafeConstructor对全局tag的解析策略不同。如果你在YAML文档里直接写!!com.example.MyClass name: xxx2.0里如果只是用new Yaml()也就是SafeConstructor就算你注册了TypeDescription也没用因为SafeConstructor根本不会走TypeDescription那套解析逻辑遇到未知tag直接按基础类型处理。结果就是报错类似Exception in thread main org.yaml.snakeyaml.constructor.ConstructorException: could not determine a constructor for the tag !!com.example.MyClass5.2 现在的标准做法第一Yaml实例一定要用自定义的Constructor来创建不要图省事用new Yaml()。第二注册tag时建议用addTypeDescription显式声明而不是只依赖类名推断TypeDescription typeDescription new TypeDescription(MyClass.class, !!com.example.MyClass); typeDescription.addPropertyParameters(items, Item.class); Constructor constructor new Constructor(MyClass.class, new LoaderOptions()); constructor.addTypeDescription(typeDescription); Yaml yaml new Yaml(constructor);第三如果你的YAML tag对应的是一个抽象接口比如!!com.example.Handler type: http哪怕你注册了Handler接口和它的实现类2.0里也不能再靠Constructor自动选了。需要自己实现Construct然后用TypeDescription把tag和实现类绑定。业务里遇到这种抽象列表我建议改配置格式别用tag区分实现类直接用普通字符串属性加路由逻辑维护成本低得多。5.3 tag字符串的大小写和包名一个字符都不能错还有一个很隐蔽的问题tag匹配是精确匹配。如果你在TypeDescription里写了!!com.example.MyClass但YAML文档里拼成了!!com.example.mypackage.MyClass大小写不一致或者包名少了一段都会匹配不上。以前我在排查一个自定义tag问题时把Constructor、TypeDescription、LoaderOptions全看了一遍都没发现问题最后是逐字节对比YAML文件里的tag和Java类全限定名才发现的。当时就是某个开发在配置文件里手敲了一个!!com.example.config.MyConfig而类实际包名是com.example.configs多了个s。这个错误在1.x里不一定触发因为老版本对tag解析更宽松但2.0里就是精确匹配找不到就直接报错。6. 与Spring Boot联动时的二次坑版本和Bean属性处理6.1 Spring Boot 3.x自带SnakeYAML 2.x但你的工具类不一定适配Spring Boot 3.0的spring-boot-starter里已经包含了SnakeYAML 2.x如果你是从Boot 2.x升到3.x这个是顺带升上来的不需要手动指定版本。但要注意Boot本身的ConfigurationProperties不直接调用SnakeYAML的公开API它内部用的是SnakeYamlPropertySourceLoader所以大多数Boot应用升级后不会出现“配置文件加载不了”的问题。真正容易踩的坑是项目里有公共工具类直接调用new Yaml().load()而且没有传自定义Constructor。这种工具类在Boot 2.x时代把任意YAML转成Map用升级到Boot 3.0之后遇到自定义tag就会直接报错。建议把这些工具类统一改成传LoaderOptions的写法。如果只需要转成Map用SafeConstructor就行如果要转成Bean必须走Constructor。6.2 依赖版本被框架“锁死”的尴尬升级Boot 3.x后我一度发现项目里某个子模块的snakeyaml版本被Spring Boot的BOM锁定成2.0但另一个中间件却在强制要求1.30。这个子模块只要引入中间件dependency:tree里就会同时出现两个版本。这种问题只能靠前面第4节的排除逻辑处理要么听框架的让中间件也升级到兼容Boot 3.x的版本要么在引入中间件时排除掉它的旧依赖。千万不要在两个版本的jar都在classpath里的情况下尝试混用NoSuchMethodError会告诉你什么叫自找麻烦。6.3 Bean属性名以is开头的历史遗留问题2.0里Constructor对Java Bean的属性处理从java.beans.Introspector改成了一套内部实现原来依赖属性名setter/getter推断的代码升级后有时会碰到“属性明明存在但赋值不上”的问题。常见触发点是某些字段名以is开头比如布尔字段叫isActive对应的setter习惯性写成setActive而不是setIsActive。在1.x里这套getName加setter的推断相对宽松2.0开始更严格直接找不到对应的setter就不赋值你拿到手的Bean里这个字段就是null。遇到这种问题建议把字段名统一改掉比如把isActive改成active。如果你不能改字段名得确认setter方法的命名和JavaBeans规范一致。这个问题排查起来比较费劲因为它不会报错只是字段静默丢失建议升级的时候重点检查一下布尔类型字段。7. 还有问题从classpath、线程栈、类加载器三个维度定位7.1 先确认运行期到底加载的是哪个jar如果你不确定当前进程用的SnakeYAML是哪个版本最直接的验证方式是用-verbose:class启动java -verbose:class -jar your-app.jar | grep snakeyaml输出里会列出每个类是从哪个jar加载出来的比如[Loaded org.yaml.snakeyaml.Yaml from file:/path/to/repo/org/yaml/snakeyaml/2.0/snakeyaml-2.0.jar]如果路径指向的jar不是你预期仓库里的2.0说明classpath里混入了旧版本。在IDE里也可以用更简单的方式加一行临时代码打印类的物理位置System.out.println(Yaml.class.getProtectionDomain().getCodeSource().getLocation());这一步能快速区分“代码写错”和“版本串了”。7.2 用Arthas直接看类的来源如果上面两个方法都不方便我用Arthas的频率也很高java -jar arthas-boot.jar进入之后执行sc -d org.yaml.snakeyaml.YamlArthas会显示这个类由哪个类加载器加载codeSource是哪个jar。如果是Spring Boot的LaunchedURLClassLoader加载的它会定位到BOOT-INF/lib下的具体jar包。这个命令在排查容器化部署问题时特别好用因为本地classpath和镜像里classpath经常不一致。7.3 如果异常被包装用jstack看原始堆栈还有一次异常被框架包装成了RuntimeException原始堆栈信息丢失我用jstack抓线程栈从调用链上找到了真正触发NoSuchMethodError的位置。这个技巧比较笨但很有用jstack pid /tmp/thread_dump.txt然后搜关键字snakeyaml看是哪一行调用触发的。配合前面-verbose:class输出能排除掉很多问题。8. SnakeYAML 2.0升级检查清单照着走能省一半时间最后我把自己整理的一份升级检查清单发出来每一条都对应一个真实踩过的坑全局搜一下代码里的new Yaml()凡是没传Constructor的根据业务改成new Yaml(new SafeConstructor(new LoaderOptions()))或自定义Constructor。所有拿loadAs解析自定义Bean的地方先确认Yaml实例是new Yaml(new Constructor(SomeClass.class, loaderOptions))创建的。全局搜索new SafeConstructor()和new Constructor()这两个类在2.0里基本都要补上LoaderOptions参数。检查大型YAML文件有没有超过300万代码点的有就配置setCodePointLimit。检查嵌套结构有没有超过50层的有就在LoaderOptions里调setNestingDepthLimit。跑一遍mvn dependency:tree -Dincludesorg.yaml:snakeyaml确保没有残留1.x。检查是否用了!!自定义tag是的话必须配合TypeDescription和自定义Constructor。如果升级到Spring Boot 3.x把老的YAML工具类测试用例跑一遍别只看启动是否正常。重点检查布尔属性名以is开头的Bean升级后可能出现静默不赋值的情况。我自己的经验是只要按照这个清单走SnakeYAML 2.0的升级基本能把意外控制在很小的范围内。最怕的是只改版本号不跑测试出问题之后再现场排查那才是真的浪费时间。准备升级的同行可以收藏一下后面踩到坑回来对照应该能帮你省下不少排查时间。
返回列表