Spring Boot XSS 防护:自动清洗参数与 JSON 请求体


/**
 * XSS 防护配置类
 *
 * 该类用于注册一个全局的 XSS 过滤器(XssFilter),对所有进入系统的 HTTP 请求进行 XSS 攻击防护。
 * 通过 Spring 的 FilterRegistrationBean 将 XssFilter 注册为高优先级过滤器(order=1),
 * 并作用于所有路径("/*"),确保在业务逻辑处理前完成输入内容的安全清洗。
 *
 *  
 *  
 */
@Configuration
public class XssConfig {
    @Bean
    public FilterRegistrationBean<XssFilter> xssFilterRegistration() {
        FilterRegistrationBean<XssFilter> bean = new FilterRegistrationBean<>();
        bean.setFilter(new XssFilter());
        bean.addUrlPatterns("/*");
        bean.setOrder(1);
        return bean;
    }
}
 
/**
 * XSS 请求过滤器
 *
 * 继承自 OncePerRequestFilter,确保每个请求只被过滤一次。
 * 对除白名单路径(如 Swagger 文档、静态资源、文件上传等)外的所有请求,
 * 使用 XssHttpServletRequestWrapper 包装原始 HttpServletRequest,
 * 从而在后续获取参数、Header、Body 等内容时自动进行 XSS 清洗。
 *
 * 白名单路径包括:
 *   - /swagger-ui/**
 *   - /v3/api-docs/**
 *   - /doc.html/**
 *   - /webjars/**
 *   - /uploads/**
 *   - /preview/**
 *   - /upload/**
 *
 *  
 *  
 */
public class XssFilter extends OncePerRequestFilter {
    private final AntPathMatcher matcher = new AntPathMatcher();
    private final String[] excludes = {
            "/swagger-ui/**", "/v3/api-docs/**", "/doc.html/**", "/webjars/**",
            "/uploads/**", "/preview/**", "/upload/**"
    };

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        String uri = request.getRequestURI();
        if (shouldSkip(uri)) {
            chain.doFilter(request, response);
            return;
        }
        XssHttpServletRequestWrapper wrapper = new XssHttpServletRequestWrapper(request);
        chain.doFilter(wrapper, response);
    }

    private boolean shouldSkip(String uri) {
        for (String p : excludes) {
            if (matcher.match(p, uri)) return true;
        }
        return false;
    }
}

/**
 * XSS 安全增强的 HttpServletRequest 包装类
 *
 * 重写了 HttpServletRequest 中所有可能携带用户输入的方法(如 getParameter、getHeader、getQueryString、getInputStream 等),
 * 对其中的字符串内容进行 XSS 攻击特征的清洗与转义。
 *
 * 特别支持对 application/json 和 text/* 类型的请求体进行深度解析与递归清洗:
 *   - 若为 JSON,会解析为 JsonNode 树结构,并对所有文本节点执行 sanitize;
 *   - 若为普通文本,则直接清洗整个 body。
 *
 * 清洗规则包括:
 *   - 移除 <script> 标签及其内容;
 *   - 移除 on* 事件属性(如 onclick、onload);
 *   - 阻断 javascript:、vbscript:、data: 等危险协议的 href/src;
 *   - 最终将 < 和 > 转义为 &lt; 和 &gt;,防止 HTML 注入。
 *
 * 注意:multipart/form-data 类型(如文件上传)因涉及二进制流,不进行 body 解析,仅处理参数和 Header。
 *
 *  
 *  
 */
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {
    private byte[] body;
    private static final Pattern SCRIPT_TAG = Pattern.compile("(?i)<script.*?>[\\s\\S]*?</script>");
    private static final Pattern ONEVENT_ATTR = Pattern.compile("(?i)on[a-z]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
    private static final Pattern ATTR_JS_OR_VBS = Pattern.compile("(?i)\\b(href|src|xlink:href)\\s*=\\s*([\"']?)(\\s*(javascript|vbscript):[^\"'\\s>]*)\\2");
    private static final Pattern ATTR_DATA_NOT_IMAGE = Pattern.compile("(?i)\\b(href|src|xlink:href)\\s*=\\s*([\"']?)(\\s*data:(?!image\\/(png|jpe?g|gif|webp);base64,)[^\"'\\s>]*)\\2");

    public XssHttpServletRequestWrapper(HttpServletRequest request) {
        super(request);
        String ct = request.getContentType();
        boolean isJson = ct != null && ct.toLowerCase().contains("application/json");
        boolean isText = ct != null && ct.toLowerCase().startsWith("text/");
        boolean isMultipart = ct != null && ct.toLowerCase().startsWith("multipart/");
        if (!isMultipart && (isJson || isText)) {
            try {
                String raw = readBody(super.getInputStream(), getCharset());
                if (raw != null) {
                    String sanitized = sanitizeBody(raw, isJson);
                    body = sanitized.getBytes(getCharset());
                }
            } catch (Exception ignore) {}
        }
    }

    @Override
    public String getParameter(String name) {
        String value = super.getParameter(name);
        return sanitize(value);
    }

    @Override
    public String[] getParameterValues(String name) {
        String[] values = super.getParameterValues(name);
        if (values == null) return null;
        String[] sanitized = new String[values.length];
        for (int i = 0; i < values.length; i++) sanitized[i] = sanitize(values[i]);
        return sanitized;
    }

    @Override
    public Map<String, String[]> getParameterMap() {
        Map<String, String[]> map = super.getParameterMap();
        for (Map.Entry<String, String[]> e : map.entrySet()) {
            String[] arr = e.getValue();
            if (arr == null) continue;
            for (int i = 0; i < arr.length; i++) arr[i] = sanitize(arr[i]);
        }
        return map;
    }

    @Override
    public String getHeader(String name) {
        String value = super.getHeader(name);
        return sanitize(value);
    }

    @Override
    public java.util.Enumeration<String> getHeaders(String name) {
        java.util.Enumeration<String> values = super.getHeaders(name);
        java.util.ArrayList<String> list = new java.util.ArrayList<>();
        while (values != null && values.hasMoreElements()) {
            list.add(sanitize(values.nextElement()));
        }
        return java.util.Collections.enumeration(list);
    }

    @Override
    public String getQueryString() {
        String value = super.getQueryString();
        return sanitize(value);
    }

    @Override
    public ServletInputStream getInputStream() {
        if (body == null) {
            try {
                return super.getInputStream();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }

        ByteArrayInputStream bais = new ByteArrayInputStream(body);
        return new ServletInputStream() {
            @Override
            public boolean isFinished() { return bais.available() == 0; }
            @Override
            public boolean isReady() { return true; }
            @Override
            public void setReadListener(ReadListener readListener) {}
            @Override
            public int read() { return bais.read(); }
        };
    }

    @Override
    public BufferedReader getReader() {
        Charset cs = getCharset();
        return new BufferedReader(new InputStreamReader(getInputStream(), cs));
    }

    private Charset getCharset() {
        String enc = super.getCharacterEncoding();
        return enc != null ? Charset.forName(enc) : StandardCharsets.UTF_8;
    }

    private String readBody(ServletInputStream in, Charset cs) throws Exception {
        if (in == null) return null;
        BufferedReader br = new BufferedReader(new InputStreamReader(in, cs));
        StringBuilder sb = new StringBuilder();
        String line;
        boolean first = true;
        while ((line = br.readLine()) != null) {
            if (!first) sb.append('\n');
            sb.append(line);
            first = false;
        }
        return sb.length() == 0 ? null : sb.toString();
    }

    private String sanitizeBody(String raw, boolean isJson) {
        if (!isJson) return sanitize(raw);
        try {
            JsonNode node = JSONUtil.toJsonNode(raw);
            JsonNode cleaned = deepSanitize(node);
            return JSONUtil.toJson(cleaned);
        } catch (Exception e) {
            return sanitize(raw);
        }
    }

    private JsonNode deepSanitize(JsonNode node) {
        if (node == null) return null;
        if (node.isTextual()) {
            return JSONUtil.MAPPER.getNodeFactory().textNode(sanitize(node.asText()));
        }
        if (node.isObject()) {
            ObjectNode obj = (ObjectNode) node;
            Iterator<String> it = obj.fieldNames();
            while (it.hasNext()) {
                String k = it.next();
                obj.set(k, deepSanitize(obj.get(k)));
            }
            return obj;
        }
        if (node.isArray()) {
           ArrayNode arr = JSONUtil.MAPPER.createArrayNode();
            for (int i = 0; i < node.size(); i++) {
                JsonNode child = deepSanitize(node.get(i));
                arr.add(child == null ? JSONUtil.MAPPER.nullNode() : child);
            }
            return arr;
        }
        return node;
    }

    public static String sanitize(String s) {
        if (s == null) return null;
        String v = s;
        v = SCRIPT_TAG.matcher(v).replaceAll("");
        v = ONEVENT_ATTR.matcher(v).replaceAll("");
        v = ATTR_JS_OR_VBS.matcher(v).replaceAll("");
        v = ATTR_DATA_NOT_IMAGE.matcher(v).replaceAll("");
        if (v.trim().matches("(?i)^(javascript|vbscript)\\s*:.*")) {
            v = "";
        }
        v = v.replace("<", "&lt;").replace(">", "&gt;");
        return v;
    }


}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yzhSWJ

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值