diff --git a/yudao-dependencies/pom.xml b/yudao-dependencies/pom.xml index 7b056c46b..cc23f245c 100644 --- a/yudao-dependencies/pom.xml +++ b/yudao-dependencies/pom.xml @@ -41,6 +41,7 @@ 6.8.0 + 1.15.3 1.18.24 1.5.3.Final 5.8.11 @@ -394,6 +395,11 @@ + + org.jsoup + jsoup + ${jsoup.version} + cn.iocoder.boot yudao-common diff --git a/yudao-framework/yudao-common/pom.xml b/yudao-framework/yudao-common/pom.xml index 2ad12ad87..a732967f2 100644 --- a/yudao-framework/yudao-common/pom.xml +++ b/yudao-framework/yudao-common/pom.xml @@ -133,6 +133,10 @@ transmittable-thread-local + + org.jsoup + jsoup + diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/config/YudaoWebAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/config/YudaoWebAutoConfiguration.java index d2bffb166..50586d4a6 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/config/YudaoWebAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/config/YudaoWebAutoConfiguration.java @@ -2,15 +2,22 @@ package cn.iocoder.yudao.framework.web.config; import cn.iocoder.yudao.framework.apilog.core.service.ApiErrorLogFrameworkService; import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum; +import cn.iocoder.yudao.framework.web.core.clean.JsoupXssCleaner; +import cn.iocoder.yudao.framework.web.core.clean.XssCleaner; import cn.iocoder.yudao.framework.web.core.filter.CacheRequestBodyFilter; import cn.iocoder.yudao.framework.web.core.filter.DemoFilter; import cn.iocoder.yudao.framework.web.core.filter.XssFilter; import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler; import cn.iocoder.yudao.framework.web.core.handler.GlobalResponseBodyHandler; +import cn.iocoder.yudao.framework.web.core.json.XssStringJsonDeserializer; import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; @@ -48,7 +55,7 @@ public class YudaoWebAutoConfiguration implements WebMvcConfigurer { * 设置 API 前缀,仅仅匹配 controller 包下的 * * @param configurer 配置 - * @param api API 配置 + * @param api API 配置 */ private void configurePathMatch(PathMatchConfigurer configurer, WebProperties.Api api) { AntPathMatcher antPathMatcher = new AntPathMatcher("."); @@ -104,8 +111,9 @@ public class YudaoWebAutoConfiguration implements WebMvcConfigurer { * 创建 XssFilter Bean,解决 Xss 安全问题 */ @Bean - public FilterRegistrationBean xssFilter(XssProperties properties, PathMatcher pathMatcher) { - return createFilterBean(new XssFilter(properties, pathMatcher), WebFilterOrderEnum.XSS_FILTER); + @ConditionalOnBean(XssCleaner.class) + public FilterRegistrationBean xssFilter(XssProperties properties, PathMatcher pathMatcher, XssCleaner xssCleaner) { + return createFilterBean(new XssFilter(properties, pathMatcher, xssCleaner), WebFilterOrderEnum.XSS_FILTER); } /** @@ -117,6 +125,32 @@ public class YudaoWebAutoConfiguration implements WebMvcConfigurer { return createFilterBean(new DemoFilter(), WebFilterOrderEnum.DEMO_FILTER); } + + /** + * Xss 清理者 + * + * @return XssCleaner + */ + @ConditionalOnMissingBean(XssCleaner.class) + @Bean + public XssCleaner xssCleaner() { + return new JsoupXssCleaner(); + } + + /** + * 注册 Jackson 的序列化器,用于处理 json 类型参数的 xss 过滤 + * + * @return Jackson2ObjectMapperBuilderCustomizer + */ + @Bean + @ConditionalOnMissingBean(name = "xssJacksonCustomizer") + @ConditionalOnBean(ObjectMapper.class) + public Jackson2ObjectMapperBuilderCustomizer xssJacksonCustomizer(XssCleaner xssCleaner, XssProperties xssProperties) { + // 在反序列化时进行 xss 过滤,可以替换使用 XssStringJsonSerializer,在序列化时进行处理 + return builder -> builder.deserializerByType(String.class, new XssStringJsonDeserializer(xssCleaner, xssProperties)); + } + + private static FilterRegistrationBean createFilterBean(T filter, Integer order) { FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); bean.setOrder(order); diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/clean/JsoupXssCleaner.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/clean/JsoupXssCleaner.java new file mode 100644 index 000000000..5a72834af --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/clean/JsoupXssCleaner.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.framework.web.core.clean; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.safety.Safelist; + +public class JsoupXssCleaner implements XssCleaner { + + private final Safelist safelist; + + /** + * 用于在 src 属性使用相对路径时,强制转换为绝对路径。 为空时不处理,值应为绝对路径的前缀(包含协议部分) + */ + private final String baseUri; + + /** + * 无参构造,默认使用 {@link JsoupXssCleaner#buildSafelist} 方法构建一个安全列表 + */ + public JsoupXssCleaner() { + this.safelist = buildSafelist(); + this.baseUri = ""; + } + + public JsoupXssCleaner(Safelist safelist) { + this.safelist = safelist; + this.baseUri = ""; + } + + public JsoupXssCleaner(String baseUri) { + this.safelist = buildSafelist(); + this.baseUri = baseUri; + } + + public JsoupXssCleaner(Safelist safelist, String baseUri) { + this.safelist = safelist; + this.baseUri = baseUri; + } + + /** + *

+ * 构建一个 Xss 清理的 Safelist 规则。 + *

+ * + *
    + * 基于 Safelist#relaxed() 的基础上: + *
  • 扩展支持了 style 和 class 属性
  • + *
  • a 标签额外支持了 target 属性
  • + *
  • img 标签额外支持了 data 协议,便于支持 base64
  • + *
+ * @return Safelist + */ + protected Safelist buildSafelist() { + // 使用 jsoup 提供的默认的 + Safelist relaxedSafelist = Safelist.relaxed(); + // 富文本编辑时一些样式是使用 style 来进行实现的 + // 比如红色字体 style="color:red;", 所以需要给所有标签添加 style 属性 + // 注意:style 属性会有注入风险 + relaxedSafelist.addAttributes(":all", "style", "class"); + // 保留 a 标签的 target 属性 + relaxedSafelist.addAttributes("a", "target"); + // 支持img 为base64 + relaxedSafelist.addProtocols("img", "src", "data"); + + // 保留相对路径, 保留相对路径时,必须提供对应的 baseUri 属性,否则依然会被删除 + // WHITELIST.preserveRelativeLinks(false); + + // 移除 a 标签和 img 标签的一些协议限制,这会导致 xss 防注入失效,如 + // 虽然可以重写 WhiteList#isSafeAttribute 来处理,但是有隐患,所以暂时不支持相对路径 + // WHITELIST.removeProtocols("a", "href", "ftp", "http", "https", "mailto"); + // WHITELIST.removeProtocols("img", "src", "http", "https"); + + return relaxedSafelist; + } + + @Override + public String clean(String html) { + return Jsoup.clean(html, baseUri, safelist, new Document.OutputSettings().prettyPrint(false)); + } + +} + diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/clean/XssCleaner.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/clean/XssCleaner.java new file mode 100644 index 000000000..a5ecc1bcc --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/clean/XssCleaner.java @@ -0,0 +1,14 @@ +package cn.iocoder.yudao.framework.web.core.clean; + +/** + * 对 html 文本中的有 Xss 风险的数据进行清理 + */ +public interface XssCleaner { + /** + * 清理有 Xss 风险的文本 + * + * @param html 原 html + * @return 清理后的 html + */ + String clean(String html); +} diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/XssFilter.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/XssFilter.java index 050a86cc1..2da18768d 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/XssFilter.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/XssFilter.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.framework.web.core.filter; import cn.iocoder.yudao.framework.web.config.XssProperties; +import cn.iocoder.yudao.framework.web.core.clean.XssCleaner; import lombok.AllArgsConstructor; import org.springframework.util.PathMatcher; import org.springframework.web.filter.OncePerRequestFilter; @@ -13,7 +14,7 @@ import java.io.IOException; /** * Xss 过滤器 - * + *

* 对 Xss 不了解的胖友,可以看看 http://www.iocoder.cn/Fight/The-new-girl-asked-me-why-AJAX-requests-are-not-secure-I-did-not-answer/ * * @author 芋道源码 @@ -30,10 +31,12 @@ public class XssFilter extends OncePerRequestFilter { */ private final PathMatcher pathMatcher; + private final XssCleaner xssCleaner; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { - filterChain.doFilter(new XssRequestWrapper(request), response); + filterChain.doFilter(new XssRequestWrapper(request, xssCleaner), response); } @Override diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/XssRequestWrapper.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/XssRequestWrapper.java index 25bd20978..1724cb163 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/XssRequestWrapper.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/XssRequestWrapper.java @@ -1,21 +1,10 @@ package cn.iocoder.yudao.framework.web.core.filter; -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.io.IoUtil; -import cn.hutool.core.util.ArrayUtil; -import cn.hutool.core.util.ReflectUtil; -import cn.hutool.core.util.StrUtil; -import cn.hutool.http.HTMLFilter; -import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; +import cn.iocoder.yudao.framework.web.core.clean.XssCleaner; -import javax.servlet.ReadListener; -import javax.servlet.ServletInputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStreamReader; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -24,113 +13,75 @@ import java.util.Map; * @author 芋道源码 */ public class XssRequestWrapper extends HttpServletRequestWrapper { + private final XssCleaner xssCleaner; - /** - * 基于线程级别的 HTMLFilter 对象,因为它线程非安全 - */ - private static final ThreadLocal HTML_FILTER = ThreadLocal.withInitial(() -> { - HTMLFilter htmlFilter = new HTMLFilter(); - // 反射修改 encodeQuotes 属性为 false,避免 " 被转移成 " 字符 - ReflectUtil.setFieldValue(htmlFilter, "encodeQuotes", false); - return htmlFilter; - }); - - public XssRequestWrapper(HttpServletRequest request) { + public XssRequestWrapper(HttpServletRequest request, XssCleaner xssCleaner) { super(request); + this.xssCleaner = xssCleaner; } - private static String filterXss(String content) { - if (StrUtil.isEmpty(content)) { - return content; + @Override + public Map getParameterMap() { + Map map = new LinkedHashMap<>(); + Map parameters = super.getParameterMap(); + for (Map.Entry entry : parameters.entrySet()) { + String[] values = entry.getValue(); + for (int i = 0; i < values.length; i++) { + values[i] = xssCleaner.clean(values[i]); + } + map.put(entry.getKey(), values); } - return HTML_FILTER.get().filter(content); - } - - // ========== IO 流相关 ========== - - @Override - public BufferedReader getReader() throws IOException { - return new BufferedReader(new InputStreamReader(this.getInputStream())); - } - - @Override - public ServletInputStream getInputStream() throws IOException { - // 如果非 json 请求,不进行 Xss 处理 - if (!ServletUtils.isJsonRequest(this)) { - return super.getInputStream(); - } - - // 读取内容,并过滤 - String content = IoUtil.readUtf8(super.getInputStream()); - content = filterXss(content); - final ByteArrayInputStream newInputStream = new ByteArrayInputStream(content.getBytes()); - // 返回 ServletInputStream - return new ServletInputStream() { - - @Override - public int read() { - return newInputStream.read(); - } - - @Override - public boolean isFinished() { - return true; - } - - @Override - public boolean isReady() { - return true; - } - - @Override - public void setReadListener(ReadListener readListener) {} - - }; - } - - // ========== Param 相关 ========== - - @Override - public String getParameter(String name) { - String value = super.getParameter(name); - return filterXss(value); + return map; } @Override public String[] getParameterValues(String name) { String[] values = super.getParameterValues(name); - if (ArrayUtil.isEmpty(values)) { - return values; + if (values == null) { + return null; } - // 过滤处理 - for (int i = 0; i < values.length; i++) { - values[i] = filterXss(values[i]); + int count = values.length; + String[] encodedValues = new String[count]; + for (int i = 0; i < count; i++) { + encodedValues[i] = xssCleaner.clean(values[i]); } - return values; + return encodedValues; } @Override - public Map getParameterMap() { - Map valueMap = super.getParameterMap(); - if (CollUtil.isEmpty(valueMap)) { - return valueMap; + public String getParameter(String name) { + String value = super.getParameter(name); + if (value == null) { + return null; } - // 过滤处理 - for (Map.Entry entry : valueMap.entrySet()) { - String[] values = entry.getValue(); - for (int i = 0; i < values.length; i++) { - values[i] = filterXss(values[i]); - } - } - return valueMap; + return xssCleaner.clean(value); } - // ========== Header 相关 ========== + @Override + public Object getAttribute(String name) { + Object value = super.getAttribute(name); + if (value instanceof String) { + xssCleaner.clean((String) value); + } + return value; + } @Override public String getHeader(String name) { String value = super.getHeader(name); - return filterXss(value); + if (value == null) { + return null; + } + return xssCleaner.clean(value); + } + + @Override + public String getQueryString() { + String value = super.getQueryString(); + if (value == null) { + return null; + } + return xssCleaner.clean(value); } } diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/json/XssStringJsonDeserializer.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/json/XssStringJsonDeserializer.java new file mode 100644 index 000000000..185469be5 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/json/XssStringJsonDeserializer.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.framework.web.core.json; + +import cn.iocoder.yudao.framework.web.config.XssProperties; +import cn.iocoder.yudao.framework.web.core.clean.XssCleaner; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StringDeserializer; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; + +/** + * XSS过滤 jackson 反序列化器 + * + * 参考 ballcat 实现 + */ +@Slf4j +@AllArgsConstructor +public class XssStringJsonDeserializer extends StringDeserializer { + + private final XssCleaner xssCleaner; + private final XssProperties xssProperties; + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + if (p.hasToken(JsonToken.VALUE_STRING)) { + return getCleanText(p.getText()); + } + JsonToken t = p.currentToken(); + // [databind#381] + if (t == JsonToken.START_ARRAY) { + return _deserializeFromArray(p, ctxt); + } + // need to gracefully handle byte[] data, as base64 + if (t == JsonToken.VALUE_EMBEDDED_OBJECT) { + Object ob = p.getEmbeddedObject(); + if (ob == null) { + return null; + } + if (ob instanceof byte[]) { + return ctxt.getBase64Variant().encode((byte[]) ob, false); + } + // otherwise, try conversion using toString()... + return ob.toString(); + } + // 29-Jun-2020, tatu: New! "Scalar from Object" (mostly for XML) + if (t == JsonToken.START_OBJECT) { + return ctxt.extractScalarFromObject(p, this, _valueClass); + } + // allow coercions for other scalar types + // 17-Jan-2018, tatu: Related to [databind#1853] avoid FIELD_NAME by ensuring it's + // "real" scalar + if (t.isScalarValue()) { + String text = p.getValueAsString(); + return getCleanText(text); + } + return (String) ctxt.handleUnexpectedToken(_valueClass, p); + } + + private String getCleanText(String text) { + if (text == null) { + return null; + } + return xssProperties.isEnable() ? xssCleaner.clean(text) : text; + } + +} + diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/json/XssStringJsonSerializer.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/json/XssStringJsonSerializer.java new file mode 100644 index 000000000..261c99ffe --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/json/XssStringJsonSerializer.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.framework.web.core.json; + +import cn.iocoder.yudao.framework.web.config.XssProperties; +import cn.iocoder.yudao.framework.web.core.clean.XssCleaner; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import lombok.AllArgsConstructor; + +import java.io.IOException; + +/** + * XSS过滤 jackson 序列化器 + * + * 参考 ballcat 实现 + */ +@AllArgsConstructor +public class XssStringJsonSerializer extends JsonSerializer { + + private final XssCleaner xssCleaner; + private final XssProperties xssProperties; + + + @Override + public Class handledType() { + return String.class; + } + + @Override + public void serialize(String value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) + throws IOException { + if (value != null) { + // 开启 Xss 才进行处理 + if (xssProperties.isEnable()) { + value = xssCleaner.clean(value); + } + jsonGenerator.writeString(value); + } + } + +} +