文章目录

SmartOA 要过三级等保,安全审计拆出 P0~P3 四级问题。P0 是必须立刻修的:SQL 注入、路径遍历、IP 伪造、默认密码、明文 token。这一批修复横跨后端(拦截器/加密/权限)与前端(加密方案),每一条都有对应的攻击场景。这篇记录 P0 批次的完整加固过程。

一、P0 问题清单与攻击场景

问题攻击场景风险
SQL 注入拼接查询条件注入 OR 1=1数据泄露/删库
路径遍历../../etc/passwd 读任意文件服务器沦陷
IP 伪造X-Forwarded-For 伪造来源 IP 绕过限流刷接口/绕封禁
默认密码admin/admin123 未改直接登录账户接管
明文 tokenURL/Cookie 明文携带 token 被日志/中间人截获会话劫持

二、SQL 注入:白名单校验 + 参数化

审计发现部分查询接口存在拼接 SQL 的风险。修复分两层:

// 排序字段白名单:动态列名无法参数化,只能白名单
private static final Set<String> SORT_WHITELIST =
    Set.of("create_time", "update_time", "status", "amount");

public void applySort(QueryWrapper<?> qw, String column, String dir) {
    if (!SORT_WHITELIST.contains(column)) {
        throw new IllegalArgumentException("非法排序字段: " + column);
    }
    qw.orderBy(true, "asc".equalsIgnoreCase(dir), column);
}
✅ 配套测试:SQL 注入白名单校验测试 + 边界修复(commit: test(security)),把「排序字段注入」和「like 通配符注入」都覆盖了。

三、路径遍历:文件下载统一校验

文件下载/预览接口如果直接拼接用户传入的文件名,就能 ../../ 穿越目录。修复方案:

// 文件名规范化 + 目录约束
Path base = Paths.get(uploadRoot).toAbsolutePath().normalize();
Path target = base.resolve(fileName).normalize();

// 关键:解析后必须仍在 base 目录内,否则拒绝
if (!target.startsWith(base)) {
    throw new AccessDeniedException("非法文件路径");
}

核心就是 normalize() 后再 startsWith(base) 校验——先规范化(去掉 .. ),再判断是否逃出根目录。

四、IP 伪造:统一取真实客户端 IP

限流/封禁都依赖客户端 IP,但如果直接读 X-Forwarded-For 头,攻击者可以自己伪造。审计发现项目里有 16 行手写的 XFF 解析逻辑,且与框架的 JakartaServletUtil.getClientIP() 不一致。修复:

🚨 教训:「手写解析」和「框架统一工具」并存 = 一定有地方取到的是可伪造值。安全相关逻辑必须收敛到单一实现。

五、默认密码与密码策略

默认管理员密码是历史遗留的安全隐患,修复:

六、明文 token:Cookie httpOnly + 密钥交换

这是最大的一块。原来 token 存在 localStorage,且部分场景出现在 URL 里;前端加密密钥硬编码在 JS 里。三层修复:

1. token 迁移到 httpOnly Cookie

localStorage 的 token 能被任何 XSS 脚本读走。改为 httpOnly Cookie 后 JS 读不到,XSS 拿不到会话:

// Sa-Token 启用 Cookie 读写 + HttpOnly + SameSite
sa-token:
  token-name: Authorization
  is-read-cookie: true   // 从 Cookie 读 token
  is-read-header: true   // 同时兼容 Header(API 调用方)
  cookie:
    http-only: true      // JS 不可读
    same-site: lax       // 防 CSRF

迁移坑:Cookie 值带 Bearer 前缀,而 Sa-Token 读 Cookie 时不自动 strip——前端登录后写 Cookie 要去掉前缀,否则每次校验都失败(commit: fix Cookie值去掉Bearer前缀)。

2. 前端加密密钥:RSA 密钥交换替代硬编码

原来前端加密密钥(AES/SM4)写死在 JS 里,等于加密白做。改为方案 A:前端随机生成 AES 会话密钥,用后端 RSA 公钥加密传输,后端解密后用于本次会话(AES 密钥每次会话随机,兼容旧 v1 格式向后兼容):

// 前端:随机 AES 密钥 + RSA 加密传输
const aesKey = crypto.getRandomValues(new Uint8Array(16));  // 随机 128-bit
const encryptedKey = JSEncrypt('-----BEGIN PUBLIC KEY-----...')
    .encrypt(aesKey);

// 后端:RSA 私钥解密出 AES 密钥,再解业务数据
byte[] aesKeyBytes = rsaDecrypt(encryptedKey);   // PKCS1Padding 对齐前端

联调坑:前端 JSEncrypt 默认 PKCS1Padding,后端 Java 若用 OAEP 会解密失败——两边 padding 必须对齐(commit: KeyExchangeService RSA padding OAEP→PKCS1Padding)。

3. 钉钉登录 token:code 交换替代明文传递

钉钉登录回调 URL 里直接带 token(可被日志/历史记录截获),改为临时 code + 后端交换:回调只传一次性 code,后端用 code 换真实 token;临时 token 加 Redis 一次性消费(GETDEL 原子操作防重放,A1 修复)。

七、顺带:XSS 与 CVE 补丁

八、P0 批次修复清单(可直接复用)

  1. SQL 注入:参数化 + 动态列名白名单,写注入测试
  2. 路径遍历:normalize() + startsWith(base) 双重校验
  3. IP 伪造:统一框架取 IP 工具,删手写解析,nginx 只信第一跳
  4. 默认密码:部署提醒 + 密码策略 + Argon2 历史密码(哈希比较要验证真的生效)
  5. token:httpOnly Cookie + 前端随机密钥 RSA 交换 + 钉钉 code 交换防重放
  6. XSS:v-html 全量 DOMPurify,安全相关库及时升 CVE 补丁
✅ 最终成果:P0 批次全部修复并回归,token 明文暴露点清零,前端加密密钥不再硬编码,SQL 注入/路径遍历/IP 伪造三类高危入口全部封死。

评 论