SpringBoot全局XSS防护实战:基于Hutool构建请求过滤与富文本净化方案

发布时间:2026/7/28 12:13:11
SpringBoot全局XSS防护实战:基于Hutool构建请求过滤与富文本净化方案 1. 项目概述与XSS防护的核心价值最近在重构一个老项目的用户反馈模块后台管理员在查看用户提交的富文本内容时页面上时不时会弹出一些奇怪的弹窗或者样式直接崩掉。一排查果然是存储型XSS跨站脚本攻击在作祟。用户在前端编辑器里输入的内容未经任何处理就直接存库然后在管理员后台渲染时脚本就被执行了。这问题说大不大但安全隐患是实实在在的。传统的防护思路要么是在前端提交时用JS过滤但防不住直接调用接口要么是在后端每个接收参数的地方手动写工具类处理繁琐且容易遗漏。这次我决定用Hutool这个国产工具包里的HtmlUtil来一劳永逸地解决这个问题。选择Hutool不是跟风而是因为它提供的escape和unescape方法底层是对org.jsoup这个专业HTML解析库的优雅封装既保证了过滤的有效性又避免了重复造轮子。这个实战项目的目标很明确在SpringBoot项目中构建一个全局、高效、可配置的XSS防护体系涵盖请求参数过滤、响应内容过滤以及富文本内容的“白名单”式过滤并附上能直接复制粘贴到项目里用的完整工具类和配置代码。无论你是刚接触Web安全的新手还是想优化现有项目防护的老鸟这套方案都能给你一个清晰的落地路径。2. 整体防护方案设计与核心思路拆解2.1 为何选择Hutool而非手动正则或其它库面对XSS防护开发者通常有几个选择自己写正则表达式、使用org.owasp.encoder、或者用Jsoup。自己写正则维护成本高容易有漏网之鱼OWASP Encoder更偏向于针对不同上下文HTML、JS、CSS进行编码功能纯粹但需要自己组合Jsoup能力最强但API稍显复杂。Hutool的HtmlUtil本质上是对Jsoup的二次封装。它最大的优势是“中庸”既提供了HtmlUtil.escape(String text)这样一行代码完成HTML转义将、、、等转换为实体字符如lt;、gt;的快捷方法也提供了HtmlUtil.filter(String html, Whitelist whitelist)这种基于白名单的强力过滤功能。这意味着我们可以用同一套工具应对两种场景普通文本的转义和富文本的净化。这种一致性降低了团队的学习和使用成本。2.2 全局防护的架构设计我们的目标不是在每个Controller的方法里手动调用过滤工具那样太容易出错和遗漏。理想的方案是入口拦截请求参数过滤在请求数据进入业务逻辑之前对所有字符串类型的参数进行HTML转义。这可以通过实现Spring的HandlerMethodArgumentResolver针对RequestParam、PathVariable和RequestBodyAdvice针对RequestBody来完成。出口过滤响应内容过滤在返回数据给前端之前对响应体中的字符串字段进行转义如果需要。这可以通过实现ResponseBodyAdvice来全局处理。但需注意对于富文本内容如文章详情我们不应转义而应使用白名单过滤。富文本特殊处理对于明确需要保留HTML格式的内容如博客内容、商品详情不能使用简单的转义否则格式全无。必须使用白名单过滤只允许安全的标签和属性通过。双重保障与性能考量入口转义是核心防线。出口过滤可作为第二道保险但需谨慎避免对性能和非文本数据如图片URL的误伤。因此我们的设计重心放在请求入口的全局参数过滤和富文本的针对性白名单过滤上。3. 核心工具类与过滤逻辑详解3.1 构建XSS过滤工具类首先我们创建一个核心工具类XssFilterUtil它整合了Hutool的能力并提供清晰的API。import cn.hutool.core.util.StrUtil; import cn.hutool.http.HtmlUtil; import org.jsoup.Jsoup; import org.jsoup.safety.Safelist; /** * XSS过滤工具类 * 1. 普通文本转义防止HTML/JS注入。 * 2. 富文本净化基于白名单保留安全格式。 */ public class XssFilterUtil { /** * 对普通文本进行HTML转义默认使用Hutool的escape * 适用于用户名、搜索关键词、普通评论等纯文本场景。 * param content 原始内容 * return 转义后的安全内容如果输入为null或空则原样返回。 */ public static String escape(String content) { if (StrUtil.isBlank(content)) { return content; } // Hutool的escape方法会将 , , , , 等字符转为HTML实体 return HtmlUtil.escape(content); } /** * 对富文本内容进行基于白名单的过滤 * 适用于文章详情、商品描述、带格式的评论等需要保留HTML的场景。 * param html 原始HTML内容 * return 净化后的安全HTML内容 */ public static String cleanRichText(String html) { if (StrUtil.isBlank(html)) { return html; } // 使用Jsoup提供的Safelist原Whitelist定义白名单 // 这里采用Safelist.relaxed()并移除不安全的标签和属性作为基础 Safelist whitelist Safelist.relaxed() // 添加一些额外允许的标签例如iframe但需要严格限制 // .addTags(iframe) // 允许标签上的class属性 .addAttributes(:all, class, style) // 允许a标签的target属性 .addAttributes(a, target, rel) // 移除不安全的协议例如javascript: .addProtocols(a, href, http, https, mailto) .addProtocols(img, src, http, https) // 显式移除可能危险的标签如script, style .removeTags(script, style, iframe, frame, frameset, object, embed, applet); // 使用Jsoup进行过滤 return Jsoup.clean(html, whitelist); } /** * 反转义将HTML实体转回普通字符谨慎使用 * 仅在你确认内容安全且需要还原显示时使用例如从数据库取出已转义的内容显示在非HTML环境。 * param escapedContent 已转义的内容 * return 反转义后的原始内容 */ public static String unescape(String escapedContent) { if (StrUtil.isBlank(escapedContent)) { return escapedContent; } return HtmlUtil.unescape(escapedContent); } }工具类设计要点解析方法分离明确区分escape和cleanRichText。前者用于纯文本简单粗暴有效后者用于富文本精细控制。空值处理使用Hutool的StrUtil.isBlank进行判断避免NPE。白名单策略Safelist.relaxed()提供了一个比较宽松的起点包含了大部分安全标签p, div, span, a, img等。我们在此基础上进行微调addAttributes(“:all”, “class”, “style”)允许所有标签使用class和style属性这是保留样式的基础。addProtocols限制a和img标签的href/src属性只能使用http、https、mailto协议彻底杜绝javascript:等危险协议。removeTags显式移除那些即便在relaxed列表中也存在风险的标签如script、style。谨慎使用反转义unescape方法必须谨慎调用只有在确保内容安全且上下文需要例如将内容输出到文本文件或非HTML渲染的移动端原生视图时才使用。在Web页面渲染中对于已转义的内容浏览器会自动显示为普通文本无需手动反转义。3.2 实现全局请求参数解析器这是实现“入口拦截”的关键。我们将创建一个自定义的String类型参数解析器。import org.springframework.core.MethodParameter; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; /** * 处理 RequestParam 和 PathVariable 注解的String类型参数解析器 * 自动对传入的字符串值进行XSS过滤。 */ public class XssStringArgumentResolver implements HandlerMethodArgumentResolver { /** * 判断是否支持该参数解析 * 这里只处理类型为String的参数 */ Override public boolean supportsParameter(MethodParameter parameter) { // 只处理String类型的参数 return parameter.getParameterType().equals(String.class); } /** * 解析参数并进行XSS过滤 */ Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { String paramName parameter.getParameterName(); if (paramName null) { return null; } // 从请求中获取参数值 String value webRequest.getParameter(paramName); // 使用工具类进行转义过滤 return XssFilterUtil.escape(value); } }接着需要将其注册到Spring MVC的配置中import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addArgumentResolvers(ListHandlerMethodArgumentResolver resolvers) { // 将自定义的XSS参数解析器添加到解析器链中优先级可以调整 resolvers.add(new XssStringArgumentResolver()); } }注意这个解析器主要对RequestParam和PathVariable生效。对于RequestBody接收的JSON对象它无能为力因为那是通过HttpMessageConverter处理的。接下来我们就解决这个问题。3.3 处理JSON请求体RequestBody对于通过RequestBody接收的复杂对象我们需要一个更全面的解决方案。这里采用实现RequestBodyAdvice接口的方式在请求体被反序列化成对象之后控制器方法执行之前对对象中的字符串字段进行过滤。import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.MethodParameter; import org.springframework.http.HttpInputMessage; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice; import java.io.IOException; import java.lang.reflect.Type; import java.util.*; Slf4j ControllerAdvice(basePackages com.yourpackage.controller) // 指定扫描的控制器包避免影响其他组件 public class XssRequestBodyAdvice implements RequestBodyAdvice { Autowired private ObjectMapper objectMapper; /** * 判断是否支持该请求 */ Override public boolean supports(MethodParameter methodParameter, Type targetType, Class? extends HttpMessageConverter? converterType) { // 这里可以根据需要细化支持的范围例如只处理某些注解或包下的请求 return true; } Override public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class? extends HttpMessageConverter? converterType) throws IOException { // 在读取body之前调用通常不需要处理 return inputMessage; } Override public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class? extends HttpMessageConverter? converterType) { // 在body被反序列化成对象后调用在这里进行XSS过滤 return filterObject(body); } Override public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class? extends HttpMessageConverter? converterType) { // 请求体为空时调用 return body; } /** * 递归过滤对象中的所有String类型字段 */ private Object filterObject(Object object) { if (object null) { return null; } // 如果是String直接过滤 if (object instanceof String) { return XssFilterUtil.escape((String) object); } // 如果是集合或数组遍历元素 if (object instanceof Collection) { Collection? collection (Collection?) object; ListObject filteredList new ArrayList(collection.size()); for (Object item : collection) { filteredList.add(filterObject(item)); } // 尝试返回原集合类型这里简化处理返回ArrayList。对于Set等需要更复杂处理 return filteredList; } if (object instanceof Map) { Map?, ? map (Map?, ?) object; MapObject, Object filteredMap new HashMap(map.size()); for (Map.Entry?, ? entry : map.entrySet()) { filteredMap.put(entry.getKey(), filterObject(entry.getValue())); } return filteredMap; } if (object.getClass().isArray() object.getClass().getComponentType().equals(String.class)) { String[] array (String[]) object; for (int i 0; i array.length; i) { array[i] XssFilterUtil.escape(array[i]); } return array; } // 如果是普通Java对象反射遍历其字段 if (isCustomObject(object)) { // 使用反射遍历对象的所有String字段并过滤 // 注意这里简化了反射逻辑实际应用中可以使用BeanUtils、反射工具类或递归调用Jackson的ObjectMapper // 一种更稳妥的方式是将对象再次序列化为Map过滤Map再反序列化回来。 // 这里为了清晰使用一个简化版的反射示例生产环境建议用更健壮的方式 return filterPojoFields(object); } // 其他类型Number, Boolean, Date等原样返回 return object; } private boolean isCustomObject(Object obj) { // 简单的判断排除JDK内置类型和常见第三方类型 String packageName obj.getClass().getPackage().getName(); return !packageName.startsWith(java.) !packageName.startsWith(javax.); } private Object filterPojoFields(Object obj) { // 使用Jackson将对象转为Map过滤Map再转回对象 // 这种方式避免了直接使用反射的复杂性且能处理嵌套对象 try { MapString, Object map objectMapper.convertValue(obj, Map.class); MapString, Object filteredMap (MapString, Object) filterObject(map); return objectMapper.convertValue(filteredMap, obj.getClass()); } catch (IllegalArgumentException e) { log.warn(XSS过滤时对象转换失败: {}, obj.getClass(), e); return obj; // 转换失败返回原对象安全考虑也可以抛出异常 } } }这个RequestBodyAdvice的实现是整套方案中最复杂但也最核心的部分有几点必须注意递归过滤它需要能处理嵌套的对象、集合List、Set、数组和Map。filterObject方法实现了这个递归逻辑。性能考量递归反射和对象转换objectMapper.convertValue有一定性能开销。如果系统对性能极其敏感可以考虑缩小ControllerAdvice的basePackages范围只作用于需要过滤的控制器。在实体类的Setter方法中手动调用过滤工具但这失去了全局性。使用注解标记哪些字段需要过滤但实现更复杂。类型安全objectMapper.convertValue方法要求对象有无参构造器且字段有标准的Getter/Setter或配置了相应的序列化/反序列化器。对于复杂的第三方库对象如MyBatis的Page对象可能会失败。代码中做了异常捕获失败时返回原对象避免影响正常请求。在生产环境中这里需要更精细的日志记录和降级策略。3.4 富文本内容的专用处理对于富文本我们不能在全局拦截器里用escape方法那会破坏格式。正确的做法是在业务逻辑层针对特定的字段如Article.content进行白名单过滤。在Service层或工具类中显式调用Service public class ArticleService { public void saveArticle(ArticleDTO articleDTO) { Article article new Article(); article.setTitle(articleDTO.getTitle()); // 标题会被全局的XssStringArgumentResolver或RequestBodyAdvice过滤转义 // 内容字段需要保留HTML使用白名单过滤 String safeContent XssFilterUtil.cleanRichText(articleDTO.getContent()); article.setContent(safeContent); // 存入数据库的是净化后的安全HTML // ... 其他保存逻辑 } public ArticleVO getArticleById(Long id) { Article article articleMapper.selectById(id); ArticleVO vo new ArticleVO(); vo.setTitle(article.getTitle()); // 转义后的标题浏览器正常显示 // 注意从数据库取出的content是安全的HTML直接返回给前端渲染即可无需再转义 vo.setContent(article.getContent()); return vo; } }这里的关键区别title这类纯文本字段经过全局过滤后存入和取出的都是转义后的字符串如lt;scriptgt;。前端渲染时浏览器会将其显示为普通文本“