|
|
@@ -0,0 +1,266 @@
|
|
|
+package com.zsElectric.boot.core.filter;
|
|
|
+
|
|
|
+import cn.hutool.core.util.StrUtil;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.zsElectric.boot.common.util.IPUtils;
|
|
|
+import com.zsElectric.boot.config.property.SecurityProperties;
|
|
|
+import com.zsElectric.boot.core.security.SecurityThreatDetector;
|
|
|
+import com.zsElectric.boot.core.web.Result;
|
|
|
+import com.zsElectric.boot.core.web.ResultCode;
|
|
|
+import com.zsElectric.boot.security.util.SecurityUtils;
|
|
|
+import com.zsElectric.boot.system.model.entity.SecurityEventLog;
|
|
|
+import com.zsElectric.boot.system.service.SecurityEventLogService;
|
|
|
+import jakarta.servlet.FilterChain;
|
|
|
+import jakarta.servlet.ServletException;
|
|
|
+import jakarta.servlet.http.HttpServletRequest;
|
|
|
+import jakarta.servlet.http.HttpServletResponse;
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.slf4j.MDC;
|
|
|
+import org.springframework.core.Ordered;
|
|
|
+import org.springframework.core.annotation.Order;
|
|
|
+import org.springframework.http.HttpHeaders;
|
|
|
+import org.springframework.http.HttpStatus;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+import org.springframework.web.filter.OncePerRequestFilter;
|
|
|
+
|
|
|
+import java.io.IOException;
|
|
|
+import java.net.URLDecoder;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.security.MessageDigest;
|
|
|
+import java.security.NoSuchAlgorithmException;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Blocks high-confidence malicious requests before they reach controllers.
|
|
|
+ *
|
|
|
+ * <p>The filter intentionally checks only URI, query string and decoded query/form parameters. It does
|
|
|
+ * not read JSON bodies, so normal request body consumption is not affected.</p>
|
|
|
+ *
|
|
|
+ * @author zsElectric
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Component
|
|
|
+@RequiredArgsConstructor
|
|
|
+@Order(Ordered.HIGHEST_PRECEDENCE + 20)
|
|
|
+public class MaliciousRequestBlockFilter extends OncePerRequestFilter {
|
|
|
+
|
|
|
+ private static final int SHORT_TEXT_LIMIT = 128;
|
|
|
+ private static final int MIDDLE_TEXT_LIMIT = 512;
|
|
|
+ private static final int LONG_TEXT_LIMIT = 2048;
|
|
|
+
|
|
|
+ private final SecurityEventLogService securityEventLogService;
|
|
|
+ private final ObjectMapper objectMapper;
|
|
|
+ private final SecurityProperties securityProperties;
|
|
|
+
|
|
|
+ @Override
|
|
|
+ protected boolean shouldNotFilter(HttpServletRequest request) {
|
|
|
+ if (!isEnabled()) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ String uri = request.getRequestURI();
|
|
|
+ return StrUtil.startWith(uri, "/api/v1/security-event-logs");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
|
|
+ throws ServletException, IOException {
|
|
|
+ RiskMatch riskMatch = findRiskMatch(request);
|
|
|
+ if (riskMatch == null) {
|
|
|
+ filterChain.doFilter(request, response);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ recordBlockedRequest(request, riskMatch);
|
|
|
+ log.warn("非法请求已拦截: ip={}, uri={}, matchedField={}, eventType={}",
|
|
|
+ IPUtils.getIpAddr(request), request.getRequestURI(), riskMatch.matchedField(), riskMatch.eventType());
|
|
|
+ writeBlockedResponse(response);
|
|
|
+ }
|
|
|
+
|
|
|
+ private RiskMatch findRiskMatch(HttpServletRequest request) {
|
|
|
+ RiskMatch uriRisk = detect("requestUri", request.getRequestURI());
|
|
|
+ if (uriRisk != null) {
|
|
|
+ return uriRisk;
|
|
|
+ }
|
|
|
+
|
|
|
+ String queryString = request.getQueryString();
|
|
|
+ RiskMatch queryRisk = detect("queryString", firstNotBlank(decode(queryString), queryString));
|
|
|
+ if (queryRisk != null) {
|
|
|
+ return queryRisk;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!shouldInspectParameterMap(request)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
|
|
+ RiskMatch nameRisk = detect(entry.getKey(), entry.getKey());
|
|
|
+ if (nameRisk != null) {
|
|
|
+ return nameRisk;
|
|
|
+ }
|
|
|
+
|
|
|
+ String[] values = entry.getValue();
|
|
|
+ if (values == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (String value : values) {
|
|
|
+ RiskMatch valueRisk = detect(entry.getKey(), value);
|
|
|
+ if (valueRisk != null) {
|
|
|
+ return valueRisk;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean shouldInspectParameterMap(HttpServletRequest request) {
|
|
|
+ String method = request.getMethod();
|
|
|
+ if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ String contentType = request.getContentType();
|
|
|
+ return StrUtil.isNotBlank(contentType)
|
|
|
+ && contentType.toLowerCase().startsWith(MediaType.APPLICATION_FORM_URLENCODED_VALUE);
|
|
|
+ }
|
|
|
+
|
|
|
+ private RiskMatch detect(String matchedField, String content) {
|
|
|
+ if (StrUtil.isBlank(content) || !SecurityThreatDetector.containsMaliciousFeature(content)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return new RiskMatch(
|
|
|
+ matchedField,
|
|
|
+ content,
|
|
|
+ SecurityThreatDetector.resolveEventType("INVALID_PARAMETER", content),
|
|
|
+ SecurityThreatDetector.resolveRiskLevel(content)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ private void recordBlockedRequest(HttpServletRequest request, RiskMatch riskMatch) {
|
|
|
+ try {
|
|
|
+ SecurityEventLog eventLog = new SecurityEventLog();
|
|
|
+ eventLog.setEventType(riskMatch.eventType());
|
|
|
+ eventLog.setRiskLevel(riskMatch.riskLevel());
|
|
|
+ eventLog.setDetector("APP_RULE");
|
|
|
+ eventLog.setRuleId("malicious-request-block-filter");
|
|
|
+ eventLog.setEventDesc("非法请求已被应用过滤器拦截");
|
|
|
+ eventLog.setRequestUri(truncate(request.getRequestURI(), MIDDLE_TEXT_LIMIT));
|
|
|
+ eventLog.setRequestMethod(truncate(request.getMethod(), 16));
|
|
|
+ eventLog.setQueryString(truncate(request.getQueryString(), LONG_TEXT_LIMIT));
|
|
|
+ eventLog.setClientIp(truncate(IPUtils.getIpAddr(request), 45));
|
|
|
+ eventLog.setXForwardedFor(truncate(request.getHeader("X-Forwarded-For"), MIDDLE_TEXT_LIMIT));
|
|
|
+ eventLog.setUserAgent(truncate(request.getHeader(HttpHeaders.USER_AGENT), MIDDLE_TEXT_LIMIT));
|
|
|
+ eventLog.setReferer(truncate(request.getHeader(HttpHeaders.REFERER), MIDDLE_TEXT_LIMIT));
|
|
|
+ eventLog.setUserId(getCurrentUserId());
|
|
|
+ eventLog.setOperatorId(truncate(resolveOperatorId(request), 64));
|
|
|
+ eventLog.setRequestId(truncate(resolveRequestId(request), SHORT_TEXT_LIMIT));
|
|
|
+ eventLog.setMatchedField(truncate(riskMatch.matchedField(), SHORT_TEXT_LIMIT));
|
|
|
+ eventLog.setPayloadExcerpt(truncate(riskMatch.payloadExcerpt(), LONG_TEXT_LIMIT));
|
|
|
+ eventLog.setPayloadHash(sha256(riskMatch.payloadExcerpt()));
|
|
|
+ eventLog.setAction("BLOCK");
|
|
|
+ eventLog.setHttpStatus(HttpStatus.BAD_REQUEST.value());
|
|
|
+ eventLog.setHandleResult("blocked_by_app_filter");
|
|
|
+ eventLog.setIsDeleted(0);
|
|
|
+ securityEventLogService.record(eventLog);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("非法请求拦截日志写入失败: {}", e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void writeBlockedResponse(HttpServletResponse response) throws IOException {
|
|
|
+ response.setStatus(HttpStatus.BAD_REQUEST.value());
|
|
|
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
|
|
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
|
|
+ response.getWriter().write(objectMapper.writeValueAsString(
|
|
|
+ Result.failed(ResultCode.USER_INPUT_CONTENT_ILLEGAL, ResultCode.USER_INPUT_CONTENT_ILLEGAL.getMsg())
|
|
|
+ ));
|
|
|
+ }
|
|
|
+
|
|
|
+ private Long getCurrentUserId() {
|
|
|
+ try {
|
|
|
+ return SecurityUtils.getUserId();
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String resolveRequestId(HttpServletRequest request) {
|
|
|
+ return firstNotBlank(
|
|
|
+ request.getHeader("X-Request-Id"),
|
|
|
+ request.getHeader("X-Request-ID"),
|
|
|
+ request.getHeader("Request-ID"),
|
|
|
+ request.getHeader("Trace-Id"),
|
|
|
+ request.getHeader("traceId"),
|
|
|
+ MDC.get("traceId"),
|
|
|
+ MDC.get("requestId")
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ private String resolveOperatorId(HttpServletRequest request) {
|
|
|
+ return firstNotBlank(
|
|
|
+ request.getHeader("OperatorID"),
|
|
|
+ request.getHeader("OperatorId"),
|
|
|
+ request.getHeader("operatorID"),
|
|
|
+ request.getHeader("operatorId"),
|
|
|
+ request.getHeader("Operator-Id")
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ private String firstNotBlank(String... values) {
|
|
|
+ if (values == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ for (String value : values) {
|
|
|
+ if (StrUtil.isNotBlank(value)) {
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String decode(String value) {
|
|
|
+ if (StrUtil.isBlank(value)) {
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return URLDecoder.decode(value, StandardCharsets.UTF_8);
|
|
|
+ } catch (Exception e) {
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String truncate(String value, int maxLength) {
|
|
|
+ if (value == null || value.length() <= maxLength) {
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+ return value.substring(0, maxLength);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String sha256(String value) {
|
|
|
+ if (StrUtil.isBlank(value)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
|
+ byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
|
|
|
+ StringBuilder builder = new StringBuilder(bytes.length * 2);
|
|
|
+ for (byte b : bytes) {
|
|
|
+ builder.append(String.format("%02x", b));
|
|
|
+ }
|
|
|
+ return builder.toString();
|
|
|
+ } catch (NoSuchAlgorithmException e) {
|
|
|
+ log.warn("SHA-256算法不可用: {}", e.getMessage());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isEnabled() {
|
|
|
+ return securityProperties.getMaliciousRequestBlock() == null
|
|
|
+ || !Boolean.FALSE.equals(securityProperties.getMaliciousRequestBlock().getEnabled());
|
|
|
+ }
|
|
|
+
|
|
|
+ private record RiskMatch(String matchedField, String payloadExcerpt, String eventType, String riskLevel) {
|
|
|
+ }
|
|
|
+}
|