/**
* 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;
* - 最终将 < 和 > 转义为 < 和 >,防止 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("<", "<").replace(">", ">");
return v;
}
}