Browse Source

fix: xss 启用后编辑器上传图片错误

gaibu 2 years ago
parent
commit
d7bec143fd

+ 6 - 0
yudao-dependencies/pom.xml

@@ -41,6 +41,7 @@
         <!-- Bpm 工作流相关 -->
         <flowable.version>6.8.0</flowable.version>
         <!-- 工具类相关 -->
+        <jsoup.version>1.15.3</jsoup.version>
         <lombok.version>1.18.24</lombok.version>
         <mapstruct.version>1.5.3.Final</mapstruct.version>
         <hutool.version>5.8.11</hutool.version>
@@ -394,6 +395,11 @@
             <!-- 工作流相关结束 -->
 
             <!-- 工具类相关 -->
+            <dependency>
+                <groupId>org.jsoup</groupId>
+                <artifactId>jsoup</artifactId>
+                <version>${jsoup.version}</version>
+            </dependency>
             <dependency>
                 <groupId>cn.iocoder.boot</groupId>
                 <artifactId>yudao-common</artifactId>

+ 4 - 0
yudao-framework/yudao-common/pom.xml

@@ -133,6 +133,10 @@
             <artifactId>transmittable-thread-local</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>org.jsoup</groupId>
+            <artifactId>jsoup</artifactId>
+        </dependency>
     </dependencies>
 
 </project>

+ 37 - 3
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> xssFilter(XssProperties properties, PathMatcher pathMatcher) {
-        return createFilterBean(new XssFilter(properties, pathMatcher), WebFilterOrderEnum.XSS_FILTER);
+    @ConditionalOnBean(XssCleaner.class)
+    public FilterRegistrationBean<XssFilter> 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 <T extends Filter> FilterRegistrationBean<T> createFilterBean(T filter, Integer order) {
         FilterRegistrationBean<T> bean = new FilterRegistrationBean<>(filter);
         bean.setOrder(order);

+ 81 - 0
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;
+    }
+
+    /**
+     * <p>
+     * 构建一个 Xss 清理的 Safelist 规则。
+     * </p>
+     *
+     * <ul>
+     * 基于 Safelist#relaxed() 的基础上:
+     * <li>扩展支持了 style 和 class 属性</li>
+     * <li>a 标签额外支持了 target 属性</li>
+     * <li>img 标签额外支持了 data 协议,便于支持 base64</li>
+     * </ul>
+     * @return Safelist
+     */
+    protected Safelist buildSafelist() {
+        // 使用 jsoup 提供的默认的
+        Safelist relaxedSafelist = Safelist.relaxed();
+        // 富文本编辑时一些样式是使用 style 来进行实现的
+        // 比如红色字体 style="color:red;", 所以需要给所有标签添加 style 属性
+        // 注意:style 属性会有注入风险 <img STYLE="background-image:url(javascript:alert('XSS'))">
+        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 防注入失效,如 <img src=javascript:alert("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));
+    }
+
+}
+

+ 14 - 0
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);
+}

+ 5 - 2
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 过滤器
- *
+ * <p>
  * 对 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

+ 46 - 95
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<HTMLFilter> HTML_FILTER = ThreadLocal.withInitial(() -> {
-        HTMLFilter htmlFilter = new HTMLFilter();
-        // 反射修改 encodeQuotes 属性为 false,避免 " 被转移成 &quot; 字符
-        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;
-        }
-        return HTML_FILTER.get().filter(content);
-    }
-
-    // ========== IO 流相关 ==========
-
     @Override
-    public BufferedReader getReader() throws IOException {
-        return new BufferedReader(new InputStreamReader(this.getInputStream()));
+    public Map<String, String[]> getParameterMap() {
+        Map<String, String[]> map = new LinkedHashMap<>();
+        Map<String, String[]> parameters = super.getParameterMap();
+        for (Map.Entry<String, String[]> 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 map;
     }
 
     @Override
-    public ServletInputStream getInputStream() throws IOException {
-        // 如果非 json 请求,不进行 Xss 处理
-        if (!ServletUtils.isJsonRequest(this)) {
-            return super.getInputStream();
+    public String[] getParameterValues(String name) {
+        String[] values = super.getParameterValues(name);
+        if (values == null) {
+            return null;
         }
-
-        // 读取内容,并过滤
-        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) {}
-
-        };
+        int count = values.length;
+        String[] encodedValues = new String[count];
+        for (int i = 0; i < count; i++) {
+            encodedValues[i] = xssCleaner.clean(values[i]);
+        }
+        return encodedValues;
     }
 
-    // ========== Param 相关 ==========
-
     @Override
     public String getParameter(String name) {
         String value = super.getParameter(name);
-        return filterXss(value);
+        if (value == null) {
+            return null;
+        }
+        return xssCleaner.clean(value);
     }
 
     @Override
-    public String[] getParameterValues(String name) {
-        String[] values = super.getParameterValues(name);
-        if (ArrayUtil.isEmpty(values)) {
-            return values;
-        }
-        // 过滤处理
-        for (int i = 0; i < values.length; i++) {
-            values[i] = filterXss(values[i]);
+    public Object getAttribute(String name) {
+        Object value = super.getAttribute(name);
+        if (value instanceof String) {
+            xssCleaner.clean((String) value);
         }
-        return values;
+        return value;
     }
 
     @Override
-    public Map<String, String[]> getParameterMap() {
-        Map<String, String[]> valueMap = super.getParameterMap();
-        if (CollUtil.isEmpty(valueMap)) {
-            return valueMap;
-        }
-        // 过滤处理
-        for (Map.Entry<String, String[]> entry : valueMap.entrySet()) {
-            String[] values = entry.getValue();
-            for (int i = 0; i < values.length; i++) {
-                values[i] = filterXss(values[i]);
-            }
+    public String getHeader(String name) {
+        String value = super.getHeader(name);
+        if (value == null) {
+            return null;
         }
-        return valueMap;
+        return xssCleaner.clean(value);
     }
 
-    // ========== Header 相关 ==========
-
     @Override
-    public String getHeader(String name) {
-        String value = super.getHeader(name);
-        return filterXss(value);
+    public String getQueryString() {
+        String value = super.getQueryString();
+        if (value == null) {
+            return null;
+        }
+        return xssCleaner.clean(value);
     }
 
 }

+ 70 - 0
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;
+    }
+
+}
+

+ 42 - 0
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<String> {
+
+    private final XssCleaner xssCleaner;
+    private final XssProperties xssProperties;
+
+
+    @Override
+    public Class<String> 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);
+        }
+    }
+
+}
+