1. 为什么我从“补漏洞”转到了“设计安全”1.1 一件触发我改变的小事事情得从一次很普通的发版说起。当时我负责一个后端项目已经稳定运行了大半年。某个周末收到告警说某个涉及用户ID的接口被批量调用响应数据里出现了不属于调用者本人的业务数据。排查之后发现是查询语句拼接参数时漏掉了租户条件一个很简单的越权问题。代码评审没发现单测也没覆盖到。说白了不是因为某个人技术不行而是整个团队在“安全”这件事上一直处于被动补丁的状态等漏洞爆出来再修修完再上线下一次再爆再修。那次之后我开始认真思考一个问题Java项目里的安全问题为什么总是修不完后来我总结出一个答案——多数团队的安全建设停留在“单点修复”阶段没有把安全设计放进架构和流程里。今天这篇内容就是想把这个从代码防护到架构安全的过程完整讲一遍。它适合刚接触安全的Java开发也适合已经写了几年业务代码但还没理清安全体系的朋友。1.2 代码防护和架构安全到底差在哪很多开发一听“安全”就想到 SQL 注入、XSS、越权这些都是代码层面的漏洞。但如果整个系统的信任边界、权限模型、数据链路没有设计清楚你修完一个漏洞很快又会冒出变种。这就是为什么我会强调代码防护解决的是“已知问题”架构安全解决的是“未知问题”。打个比方代码防护就像给房子换门锁架构安全则是想清楚谁该有钥匙、哪个房间放贵重物品、外人进入房屋后能走多远。门锁当然要换但你连房间都没分清楚换再好的锁也拦不住有心人。我见过一些项目框架选型没问题代码规范也执行得不错但在设计阶段没人提过“这个接口的调用方是谁”“用户和角色之间怎么隔离”“内部服务之间的调用需不需要鉴权”这些问题。等上线后再来补代价往往是成倍的。这也是我写这篇文章的核心立场安全不是某个安全工程师的事情而是设计、编码、测试、运维都参与的一条链路。2. 输入直接拼接SQL这是大多数漏洞的第一入口2.1 SQL注入防护不只靠预编译说到Java安全大部分人的第一反应都是SQL注入。这件老生常谈的事我倒想从另一个角度聊聊为什么预编译都用了还是有人踩坑常见的原因是“部分查询没法用预编译”。比如动态排序字段、动态表名、IN 条件过长时的拼接处理。很多框架提供了查询构造器或动态 SQL 能力但一旦你把外部传入的排序字段直接拼进 ORDER BY预编译就形同虚设了。我处理过一个线上问题排序字段是前端传上来的orderBy代码里没做白名单校验直接拼进了原生 SQL。虽然框架已经隔离了查询参数但排序字段没有参数化空间等于重新开了一个口子。后来我们在公共查询组件里加了一条硬性规定所有动态字段必须走白名单映射。也就是说前端传过来的值只是一个索引键真正拼进 SQL 的字段名由服务端映射表决定。我提供一个简化版的逻辑供参考private static final MapString, String SORT_FIELD_MAPPING Map.of( createTime, created_at, updateTime, updated_at, status, status ); public String resolveSortField(String rawField) { String mapped SORT_FIELD_MAPPING.get(rawField); if (mapped null) { throw new IllegalArgumentException(Unsupported sort field: rawField); } return mapped; }另外还要注意LIKE查询。虽然参数化了但如果你把%和_直接拼进参数配合通配符可能造成信息探测。不要以为 SQL 注入只是“拼接字符串”通配符用的不当同样会给恶意用户提供数据特征的猜测空间。2.2 XSS 和输出编码别在存储层做文章XSS 在 Java 项目里经常被误判为“前端问题”。实际上服务端也有大量工作要做。比如一个用户提交个人简介里面带了script标签你直接在 HTML 里渲染浏览器会把它当脚本执行。解决思路是在输出层做编码而不是在存储层做过滤。很多团队喜欢把用户输入里的script、alert(这些关键词直接替换或删除这种做法后患不小。一方面攻击者可以绕行大小写、编码、嵌套写法都能轻松穿过黑名单另一方面误伤正常业务内容比如一篇技术文章里真的想讲script标签被你存储层过滤了业务语义就坏了。正确做法是输入端只做基础的长度、格式校验输出端根据上下文做编码。HTML 上下文做 HTML 实体编码JavaScript 上下文做 JS 编码URL 上下文做 URL 编码。Spring 里默认的 JSON 序列化不会帮你处理所有富文本场景遇到需要渲染 HTML 的字段还是要单独处理。我在项目中通常会在公共响应层统一做一次转义并且针对富文本字段提供标记能力不走统一转义走白名单净化。这个设计给前端提供了明确的接口契约后端也不会把所有内容一刀切。2.3 文件上传和反序列化的边界控制文件上传是个永远不能放松的入口。攻击者上传一个伪装成图片的 JSP 或可执行脚本如果落入可执行目录等于直接拿下服务器。我们项目里的上传组件有几条硬约束第一文件的存储路径不能由客户端指定由服务端根据业务规则生成第二存储目录与 Web 应用的执行目录完全隔离静态资源通过独立网关对外提供第三使用文件类型检测时只信服务端根据文件头识别出的类型不信 Content-Type 和文件后缀。至于 Java 反序列化这些年学界和工业界已经把风险讲得很透了。我的建议很简单如果业务允许尽量不要自定义反序列化接口。如果一定要用需要做到三点——反序列化白名单、禁止来自外部不可信源的反序列化、对序列化对象做完整性校验。// 使用白名单限制反序列化类的示例 ObjectInputFilter filter ObjectInputFilter.Config.createFilter( com.example.whitelist.**;java.base/*;!* ); try (ObjectInputStream ois new ObjectInputStream(inputStream)) { ois.setObjectInputFilter(filter); Object obj ois.readObject(); }这类代码识别度很高团队内部每次代码评审看到新的反序列化入口都会单独拉出来过一遍。3. 用户身份与权限业务逻辑里的安全问题最隐蔽3.1 会话管理让登录态不可伪造、不可复用身份认证这块常见的坑倒不是加密算法选错而是会话的生命周期管理太随意。很多系统上线一两年后里面还残留着大量永不过期的会话记录这就给了会话固定攻击可乘之机。我建议把会话管理当成一套完整流程登录时重新生成会话ID、登出时销毁会话、密码修改后让旧会话失效、异常来源切换时强制重新认证。别觉得这些细节繁琐安全出问题时往往就是某个细节没安排上。以 Session 固定攻击为例攻击者先自己登录拿到一个合法会话ID然后诱导目标用户使用这个会话ID登录。如果服务端在登录后没有更换会话ID攻击者就能用同一个ID冒充目标用户。解决手段是登录成功后立即调用request.changeSessionId()并让会话 ID 的刷新对用户无感。3.2 越权防护光有RBAC远远不够权限越权是Java业务系统里最普遍的高危问题却没有统一解法因为每一套业务规则的“边界”都不同。多数团队会引入 RBAC 模型划分角色、菜单、按钮权限。但 RBAC 解决的是“你能不能访问这个功能”解决不了“你能访问哪些数据”。真实的越权场景经常发生在数据级。比如一个订单系统采购员只应该看到自己负责的订单但查询接口只校验了登录状态没校验订单归属。这时角色再分明对防护也没用。我们项目里有条铁律所有涉及列表查询和详情查询的接口都必须在 SQL 层或查询构造层注入“数据范围条件”而不是在内存中做过滤。// 数据范围注入的示例 public class OrderQueryService { public ListOrder queryOrders(OrderQuery query, LoginUser user) { if (!user.isAdmin()) { query.setDeptId(user.getDeptId()); } return orderMapper.selectOrders(query); } }另外一个容易漏掉的是“批量接口”。单个对象查询做了归属校验但批量导入、批量导出接口经常没人校验。你想想一个批量导出接口如果没加数据范围攻击者把查询条件改成全表几万条数据就直接导走了。所以数据范围校验必须下沉到通用查询层而不是每个接口写一遍。3.3 凭证和密钥永远不要让密钥进入代码仓库密钥管理是一个老生常谈但反复出问题的话题。我见过不止一次数据库密码直接写在application.yml里提交到代码仓库跟着代码一起流转到各套环境。还有的团队把密钥写在前端配置里等于把门锁密码贴在门上。正确的做法是引入密钥管理服务运行时注入环境变量本地开发使用独立密钥生产环境的密钥只存在于加密存储中。代码里不应该出现任何一个真实的数据库密码、第三方密钥、签名证书私钥。还有一个细节日志打点很容易把密钥打出来。某些 HTTP 客户端在开启 debug 模式后会把请求头里的 Authorization 一起打印。加密配置中心下发密钥时也可能被打到底层日志里。这类泄漏比代码仓库里的硬编码更难发现因为它不会第一时间暴露只会潜伏在日志系统里等着被翻出来。4. 数据在链路中的每一站都可能被“看光”4.1 传输层和存储层不该裸奔的数据绝不裸奔Java 应用的数据从浏览器出发经过网关、应用服务器、数据库、缓存、消息队列最后可能还要进数据仓库。链路越长暴露点越多。安全设计上我习惯把数据分成几个等级公开、内部、敏感、高敏感。不同等级使用不同的加密和脱敏策略。传输层方面全站启用 HTTPS 已经是底线内部服务之间的调用也建议启用双向 TLS 或至少使用内部 CA 签发的证书。有些团队认为内部网络是可信的却在云环境里把数据库端口直接暴露在公网这种信任一旦被击穿损失难以估量。存储层方面密码必须使用加盐的慢哈希算法比如 BCrypt、Argon2不要再用 MD5 或 SHA256 直接存密码。手机号、身份证号、银行卡号这类高敏字段我建议在持久化之前做字段级加密。不要等隐私合规找上门再想办法补救那会儿改造成本已经非常高了。// 加盐哈希的正确用法BCrypt import org.mindrot.jbcrypt.BCrypt; public String hashPassword(String rawPassword) { return BCrypt.hashpw(rawPassword, BCrypt.gensalt(12)); } public boolean checkPassword(String rawPassword, String hashed) { return BCrypt.checkpw(rawPassword, hashed); }有一点要额外提醒高敏字段加密后需要保证密文具备同值等长等特性。用 AES-GCM 这类带随机 IV 的加密方案时直接随机 IV 会导致同一明文在不同记录中密文不同一些依赖数据库唯一索引的业务会失效。更稳的做法是生成随机的“字段盐”参与加密把盐和密文一起存储这样既保证多样性又不破坏可比较性。4.2 日志、报错与链路追踪中的隐形泄露日志系统是安全建设中容易被忽略的薄弱点。很多应用在报错日志里直接输出 SQL 语句、查询参数、请求体一些链路追踪系统也会把请求头完整记录。一旦日志中心被拖库或者权限设置不当敏感信息就整体泄露了。我们团队的处理方式是给日志系统配置两级脱敏第一级在应用侧输出前脱敏针对手机号、邮箱、身份证号等字段使用掩码第二级在日志采集侧再做一次字段过滤双保险防止漏网之鱼。此外异常处理也要统一。不要让框架把原始的SQLException、RedisException直接返回给客户端也不要在堆栈信息里包含文件路径、数据库连接串等内容。我给团队定的规则是对外返回统一错误码和可读的业务消息技术细节只进日志不上响应体。5. 第三方依赖Java生态里最容易被忽略的暗雷5.1 依赖漏洞扫描先搭起来再谈覆盖率Java 项目的第三方依赖数量往往多得惊人一个 Spring Boot 工程扫出几十上百个依赖很正常。这些依赖里任何一个存在已知漏洞都会影响整体安全。曾经有一个不算冷门的组件爆出反序列化漏洞时我们排查下来系统内有多个服务都间接引用了它有些甚至是通过传递依赖引入的谁都没有主动消费这个组件但它确实躺在了依赖树里。所以依赖漏洞扫描工具是必须安排的国内常见的比如 becomposer、owasp dependency-check或者直接在 CI 里接入组件扫描任务。我个人的习惯是把扫描结果分成两类第一类是直接依赖中的漏洞必须升级或者换替代第二类是传递依赖中的漏洞能升级父组件就升级不能的话评估是否有实际调用链路没有就记录为风险项并持续跟踪。5.2 传递依赖的坑升级不是拍脑袋依赖漏洞修复时有一个非常容易失控的场景为了修一个传递依赖的漏洞盲目升级父组件结果引发大范围 API 不兼容测试用例炸一大片。这类升级我给一个建议先锁定漏洞组件的直接入口是谁再查它的哪个版本引用了有问题的依赖找到最小可行的升级路径而不是一上来就把整个框架升级到最新版。!-- 示例在 Maven 中强制指定传递依赖版本 -- dependencyManagement dependencies dependency groupIdorg.example/groupId artifactIdvulnerable-library/artifactId version2.3.1/version /dependency /dependencies /dependencyManagement另外不要在 POM 里随便加.RELEASE这种动态版本标识。动态版本会让每次构建的依赖树都不稳定CI 环境里无法复现安全审计也无法追溯某个时间点到底用了哪个版本。锁死版本号哪怕丑一点也赚一个可追溯和可控。6. 架构层面的安全把安全当作非功能需求提前设计6.1 纵深防御别把筹码押在任何一层做过几个完整项目之后我越来越认同一个观点安全不是某一层做了就够的而是每一层都要有独立防御。一个成熟的系统即便应用层被攻破数据库层还有访问控制即便数据库被拖库数据本身有加密即便数据被解密还有操作审计记录。这就是纵深防御。落到 Java 架构里我会这样排布网关层限流、IP 黑白名单、WAF 策略、统一鉴权入口。应用层参数校验、权限模型、敏感数据脱敏、统一异常处理。数据层字段级加密、数据库账号最小权限、连接串集中托管。运维层网络隔离、密钥轮换、日志审计、行为告警。比较有意思的是每一层都会给上层提供“失败关闭”的默认值。所谓失败关闭是指当安全组件判断不了某个请求时默认拒绝还是默认放行。很多团队默认放行因为怕误伤正常业务。但安全设计上我建议身份不明、权限不清的请求默认拒绝宁可少放一点也别多放一点。6.2 微服务架构中的信任边界内部调用不等于可信调用微服务架构给安全带来了新问题以前单体应用只有一个信任边界现在每个服务都可能是一个信任边界。很多团队在内部服务之间不做鉴权理由是“反正内部网络是可信的”。一旦某台服务被入侵攻击者可以在这批内部服务之间横向移动把整个后端当后花园逛。我参与过的微服务项目里安全设计上至少做了这几件事内部服务之间的调用统一走网关或服务网格携带带时效的凭证服务间通过 JWT 或 mTLS 做互相认证每个服务有自己的服务账号数据库连接分别使用对应账号避免一个服务被拿下后能访问所有数据库敏感操作单独记录审计日志并保证日志不可篡改。另外BFFBackend for Frontend层我个人建议独立部署。BFF 面向外部客户端暴露的接口更贴近前端需求它应该比内部服务承担更多的输入校验、鉴权、限流工作。不要让前端直接绕开 BFF 去调用内部服务更不要让内部服务的端口直接暴露到外网负载均衡上去。6.3 可观测性安全事件处理的前提条件安全架构最后要落地离不开可观测性。没有日志、指标、链路追踪支撑安全事件发生后你可能连“攻击者是从哪个入口进来的”都说不清楚。所以真正把事情做扎实的团队都会把可观测性当作安全能力的一部分来建设。我建议每个 Java 项目至少做到所有认证失败的尝试记录到日志并带请求来源 IP、目标用户、失败原因所有权限不足的请求单独打点按接口聚合方便识别扫描行为关键业务的写操作要有操作人和操作内容的审计信息异常响应保留 traceId便于全链路排查。这些能力叠加之后安全团队也能基于数据做分析而不是靠猜。我在项目里看到过一次明显的撞库尝试就是因为给登录失败加了响应时间追踪和来源聚合几个小时内就发现同一个 IP 段在持续发起登录请求随后在网关层直接拦截了该段 IP。7. 安全建设不是“加一个安全工程师”而是改流程7.1 把安全检查嵌进每个研发节点聊了这么多技术细节最后我想讲讲流程。技术方案设计得再好如果团队不执行也是白搭。我见过很多项目的安全评审文档写得天花乱坠然而开发完直接上线、CI 全绿就发版、安全扫描工具只扫不修安全隐患还是稳稳地趴在线上。我自己比较推崇的做法是把安全检查和日常研发流程绑定在一起而不是单独拎出来做。需求评审阶段把“是否涉及敏感数据”“是否有权限边界变化”“是否引入新的第三方组件”这几个问题标准化代码评审阶段要求每次 MR 都标注安全自检结论CI 阶段依赖扫描不通过不能合并测试阶段把越权用例和注入用例放进常规回归测试集。这是在让团队用最低的额外成本去执行安全规则。安全如果只是一年一次的“安全演练”效果真的有限。7.2 我的体会小步快跑比一次性大改造有效最后分享一点个人的体会。以前我也试图做过一次“全面安全改造”把框架、权限、加密方式全部推翻重来。结果不用说阻力巨大业务团队觉得影响进度测试团队觉得回归量大最后项目也没按预期落地。后来换了一种方式每个迭代拆几个安全相关的技术债逐步去还。第一个迭代只做统一鉴权入口第二个迭代把后台管理的权限模型换成 RBAC第三个迭代给所有高敏字段加密第四个迭代再完善日志脱敏。每次改动都有回归验证业务团队也更容易接受。这种做法的另一个好处是安全漏洞和代码修复一样需要持续的反馈循环。你每修复一个隐患都要从漏洞扫描工具、威胁模型、线上监控数据里得到验证才会形成一个正向的闭环。一次性的安全大扫除看起来轰轰烈烈但修复质量常常没人跟进这也是我不太推荐的原因。最终你会发现Java 安全开发这件事真正落地的时候没有那么神秘无非是每一个输入参数都被恰当校验每一个权限判断都覆盖到数据级每一个敏感数据都知道自己的位置每一个依赖都在可控的范围内每一个安全事件发生之后都还有补救和追溯的余地。把这些细节打磨好架构安全自然就立住了。