“你的用户表查询居然能用单引号闭合?那输入框里填' or 1=1 --试试?”
这是我在一次内部安全自查时对一个某业务系统说的第一句话。五分钟后,后台数据列表被原样拉了出来。项目代号叫“某安全加固模拟项目X”,目标是给一套典型的Java后端做一次系统性的安全加固,核心就是两件事:SQL注入和XSS(跨站脚本攻击)。这两类漏洞在Java生态里算得上“老熟人”了,但实战中真正能防住的团队并不多——不是因为不知道原理,而是因为落地时总在细节上翻车。
先说结论:Java后端防御SQL注入与XSS,不是靠某一个框架、某一个过滤器就能一劳永逸的,它是一整套“从代码书写习惯到框架配置,再到部署环境”的分层防御体系。这篇文章我会按自己做安全加固时的真实顺序来写,先拆解两种漏洞的攻击原理,再给每一层防线具体的落地代码和参数选择逻辑,最后聊几个我在真实项目里踩过的坑。无论你是刚入职的Java开发,还是带着团队做安全改造的技术负责人,这份实践笔记都能直接抄作业。
1. 先拆清楚威胁长什么样:SQL注入与XSS的本质
1.1 SQL注入:别以为只有拼接字符串才会中招
SQL注入的根本原因,是程序把“数据”和“代码”混为了一谈。当用户输入的字符串被直接拼进SQL语句里执行时,数据库解析器就会把这串输入里的特殊字符当作SQL语法的一部分来解读。
一个最经典的例子:登录接口里写了这样一句查询:
String sql = "SELECT * FROM user WHERE name = '" + username + "' AND password = '" + password + "'"; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);如果用户在用户名输入框填admin' --,实际执行的SQL就变成了:
SELECT * FROM user WHERE name = 'admin' -- ' AND password = '...'--在MySQL里是行注释,后面的密码校验直接被注释掉。攻击者不需要知道密码就能登录管理员账号。这还只是最原始的注入,进阶的攻击者可以用UNION SELECT把其他表的数据查出来,用UPDATE改数据,甚至调用数据库的存储过程提权。
很多人有个误区:觉得只要用了MyBatis、Hibernate这类ORM框架就不会被SQL注入。这是完全错误的想法。SQL注入风险不在框架,而在你怎么用框架。MyBatis里同时存在两种占位符:#{}和${},前者会被编译成JDBC的PreparedStatement参数占位符?,数据库先编译SQL模板再填参数,参数就是纯数据;而${}是做字符串拼接后直接编译,跟当年裸拼SQL的危险级别一模一样。
1.2 XSS的本质:输入被渲染成了HTML和JavaScript
XSS攻击的核心,是攻击者提交的恶意脚本被浏览器当作页面的一部分执行了。Java后端在其中扮演的角色,决定了它必须在“输出”这个环节把风险摁住。
XSS按触发场景分三种,我习惯用“攻击代码在哪里躺、在哪里炸”来记:
- 存储型XSS:恶意脚本持久化存在服务器数据库里,比如在评论框里提交
<script>alert(document.cookie)</script>,之后任何用户打开评论页面,脚本都会在浏览器里执行。这是危害最大的一种。 - 反射型XSS:恶意脚本藏在URL参数里,服务端把参数原样写回响应页面。攻击者需要诱导用户点击恶意链接,脚本才在用户浏览器里“反射”执行。
- DOM型XSS:脚本根本不经过服务端,纯前端JavaScript在操作DOM时,把不可信数据拼进了
innerHTML或document.write()。
站在Java后端的视角,存储型和反射型是服务端完全有责任控制的;DOM型虽然主要在前端,但后端如果能输出安全检查过的数据结构,也能间接降低风险。
1.3 两张表看清两类漏洞的攻防差异
| 对比维度 | SQL注入 | XSS |
|---|---|---|
| 攻击入口 | 任意输入参数、请求体 | 任意可回显输入、URL参数、富文本内容 |
| 攻击目标 | 数据库、后端服务、服务器 | 浏览器中的用户、用户会话 |
| 根本原因 | 用户输入被拼入SQL作为代码执行 | 用户输入被拼入HTML/JS作为代码执行 |
| 核心控制点 | 数据库访问层(DAO/Repository) | 视图渲染层(Thymeleaf/JSP/JSON输出) |
| Java后端主防线 | 预编译、参数化查询、权限最小化 | 上下文感知输出编码、CSP、Cookie加固 |
| 最容易被忽视的场景 | 动态排序、动态表名、LIKE模糊查询 | 富文本内容、JSON中嵌入HTML、日志回显 |
这两类漏洞看起来路径完全不同,但根子其实都一样:没有做到“数据与代码分离”。想明白了这句话,后面每一道防线该怎么加、加在哪,你都能自己推出来。
2. SQL注入的实战防线:从牙签到城墙
2.1 第一道防线:预编译与参数化查询
在Java后端里,预编译就是JDBC的PreparedStatement。它的原理是让数据库先编译好带?占位符的SQL模板,再以纯数据的形式把参数传进去。SQL模板已经固定,攻击者输入的' or 1=1 --最多成为一个奇怪的字符串值,绝不会变成语法结构。
// 安全写法 String sql = "SELECT * FROM user WHERE name = ? AND password = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); ResultSet rs = ps.executeQuery(); }如果你用MyBatis,那记住一条铁律:能用#{}就绝不用${}。
<!-- 安全:预编译占位符 --> <select id="findByName" resultType="User"> SELECT * FROM user WHERE name = #{name} </select> <!-- 危险:字符串拼接 --> <select id="findByNameUnsafe" resultType="User"> SELECT * FROM user WHERE name = '${name}' </select>Spring Data JPA里则对应:name这样的命名参数:
@Query("SELECT u FROM User u WHERE u.name = :name") List<User> findByName(@Param("name") String name);注意:预编译是MySQL、PostgreSQL、Oracle等主流关系型数据库都支持的能力。但不是说预编译就万能,比如动态表名、动态列名排序这些场景它管不了,后面第三节专门说。
2.2 第二道防线:输入校验与白名单映射
预编译解决90%的参数注入问题,但剩余10%藏在“无法参数化”的SQL片段里。典型场景有这三个:动态排序字段、动态表名、IN子句的集合参数。
动态排序字段是重灾区。因为SQL语法上,ORDER BY后面只能跟真实的列名或表达式,不能绑定一个?参数。很多开发图省事,直接把前端传来的sortField拼进去:
// 危险写法 String sql = "SELECT * FROM product ORDER BY " + sortField + " " + sortDir;这一行能打穿前面所有预编译防线。攻击者传sortField=1;DROP TABLE product;--,如果数据库账号权限够大,表就没了。
我推荐的做法是白名单映射。服务端维护一张允许参与排序的字段与SQL真实列名的对照表,前端传过来的是键,后端翻译成列名:
private static final Map<String, String> SORT_FIELD_MAP = new HashMap<>(); static { SORT_FIELD_MAP.put("createTime", "create_time"); SORT_FIELD_MAP.put("price", "price"); SORT_FIELD_MAP.put("name", "name"); SORT_FIELD_MAP.put("id", "id"); } public String getSortColumn(String inputField) { String column = SORT_FIELD_MAP.get(inputField); if (column == null) { throw new IllegalArgumentException("非法的排序字段: " + inputField); } return column; }排序方向sortDir同样只接受asc和desc两个字面量,其他一律拒绝。动态表名同理,写一个枚举或常量类,只允许从预定义集合里取。
IN子句注意别用字符串拼接拼IN (?,?,?)或IN ('a','b')。如果你用MyBatis,直接用foreach配合#{},它会为你生成带占位符的参数列表:
<select id="findByIds" resultType="Item"> SELECT * FROM item WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>2.3 第三道防线:数据库权限分层与纵深防御
代码层能挡的挡完了,还要假设“代码一定会有漏网之鱼”,所以数据库权限必须做最小化分配。我在某个项目里见过一枚应用账号直接就是数据库管理员权限,那意味着一旦注入发生,攻击者可以读所有库、删所有表。这是架构层面必须纠正的。
给应用配置数据库账号时,我的底线是:
- 应用用的账号只拥有当前业务库的
SELECT、INSERT、UPDATE、DELETE权限,不授予DDL权限(不能建表、改表、删表)。 - 如果确实需要批量导入等特殊操作,单独配置一个专用账号,走独立审批流程。
- 生产、测试、开发环境使用不同的数据库账号,密码通过配置中心或环境变量注入,不允许硬编码在源码里。
- 数据库连接串里显式指定
allowMultiQueries=false(尤其MySQL),防止攻击者利用分号拼接多条语句。
不少团队觉得“加个WAF就完事了”,但WAF属于外围防御,规则总有绕过姿势,我见过明文select被WAF拦下,但攻击者用/*注释*/拆分关键字或者十六进制编码就绕过去的例子。代码本身不加预编译,等于把安全底线寄托在外部设备上,这不是纵深防御,是赌运气。
2.4 实操心得:LIKE模糊查询的注入陷阱
模糊查询是另一个高频翻车点。很多人知道#{}安全,于是写成:
<select id="search" resultType="Item"> SELECT * FROM item WHERE name LIKE '%#{keyword}%' </select>这段代码会直接报错,因为MyBatis的#{}不能出现在字符串字面量内部。有人一气之下改成${}:
<select id="search" resultType="Item"> SELECT * FROM item WHERE name LIKE '%${keyword}%' </select>这下能跑了,但SQL注入也回来了。
正确的做法是把%拼在参数里传入:
String keyword = "%" + input.trim() + "%"; mapper.search(keyword);<select id="search" resultType="Item"> SELECT * FROM item WHERE name LIKE #{keyword} </select>这样既绕开了#{}不能进字符串字面量的限制,又保留了参数化。MySQL用户还可以用CONCAT拼接,效果一样:
<select id="search" resultType="Item"> SELECT * FROM item WHERE name LIKE CONCAT('%', #{keyword}, '%') </select>这里我多说一句:MySQL的LIKE里,用户输入如果带%或_,会被当成通配符,影响搜索精确度,甚至造成性能问题。我一般会在拼接前把这两个字符转义掉,或者约定用户自己输入通配符不算有效搜索词。
3. XSS防御:后端的责任边界与控制点
3.1 输出编码的黄金法则:不同上下文用不同编码
XSS防御的核心,是在“不可信数据输出到页面”时,让特殊字符失去“代码”意义。但编码不是一套搞定所有场景——同样的字符串,在HTML标签文本里、在HTML属性里、在JavaScript里、在URL里,需要的转义规则完全不同。
举个最直观的例子:
- HTML文本节点里,
<script>应该被转义成<script>。 - HTML属性值里,双引号必须被转义成
",否则属性闭合,后面就能注入事件属性。 - 在JavaScript字符串里,担心的是
</script>直接闭合脚本块,转义方式又不一样,一般要把<转成\x3c。
Java后端根本没有一个“万能编码器”能同时覆盖所有上下文。正确姿势是区分上下文,分别调用对应的编码API。OWASP的Java Encoder项目(org.owasp.encoder)就是干这个的:
import org.owasp.encoder.Encode; // 输出到HTML标签内容 String safeHtml = Encode.forHtml(userInput); // 输出到HTML属性 String safeAttr = Encode.forHtmlAttribute(userInput); // 输出到JavaScript字符串 String safeJs = Encode.forJavaScript(userInput); // 输出到URL参数或路径 String safeUrl = Encode.forUri(userInput);如果你的项目用的是Thymeleaf,它默认就会对th:text做HTML输出编码,所以常见模板引擎本身是安全的,怕的是有人用th:utext(unescaped text)或者手动new String(html)塞HTML。JSP时代的老代码尤其爱犯这个错,改Thymeleaf或者React/Vue时,要把utext用法排查干净。
3.2 后端输出编码的落地方式:存原样,出编码
我见过不少团队为了防XSS,在用户输入进数据库之前做一层转义,存进去的已经是<script>。这种做法后患无穷:数据显示出来倒是安全了,但数据本身被污染了,后续做数据导出、统计分析、二次处理时全是转义符,还得先decode一遍。
行业通行做法是存储层保持原始数据,输出层做编码。输入侧只做合法性校验(长度、格式、是否匹配业务规则),输出侧在渲染边界执行上下文编码。
如果项目是前后端分离、后端返回JSON给前端渲染,那编码责任要前后端一起担。后端能做的有两件事:
第一,在HTTP响应头统一加Content-Type: application/json; charset=utf-8,避免浏览器把JSON响应当HTML解析。第二,对返回到HTML上下文的数据,由后端码掉或由前端框架转义。现在的Vue、React模板默认会转义绑定数据,只要别用v-html/dangerouslySetInnerHTML这种危险出口,默认就是安全的。
如果必须让后端在JSON输出层加一道保险,可以用Jackson的HtmlXssSerializer思路:对标记了@JsonXss的字符串字段,在序列化时做HTML转义。但这只能防HTML上下文,不能防JavaScript上下文,别把这道保险当万能。
注意:前端转义并不能替代后端编码。攻击者可以绕过前端直接构造POST请求,如果后端校验和编码都指望前端,等于把安全交给对手。
3.3 HttpOnly与CSP:压垮XSS的两根稻草
XSS攻击最经典的“战利品”是用户的Cookie,尤其是会话Cookie。拿到Cookie等于拿到用户的登录态。对策之一非常直接——给会话Cookie加上HttpOnly属性,让JavaScript无法读取Cookie。
在Java后端设置方式很简单:
response.addHeader("Set-Cookie", "JSESSIONID=" + sessionId + "; HttpOnly; Secure; SameSite=Strict");Spring Boot的server.servlet.session.cookie.http-only=true、server.servlet.session.cookie.secure=true两个配置项也能达到同样效果。加了HttpOnly之后,即使用户浏览器被注入了恶意脚本,脚本也拿不到会话Cookie,攻击收益直接砍一半。
第二根稻草是CSP(Content-Security-Policy,内容安全策略)。CSP是浏览器层面的“代码白名单”,告诉浏览器页面上可执行的脚本只能来自哪里。配置一个稍紧的策略,很多XSS攻击直接失效:
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");如果项目用Spring Security,配置在HeadersConfigurer里更优雅:
http.headers(headers -> headers .contentSecurityPolicy(csp -> csp .policyDirectives("default-src 'self'; script-src 'self'") ) .httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true)) .frameOptions(frame -> frame.sameOrigin()) );我试过在同一个系统里只加CSP,原本跑通的存储型XSS脚本就再也弹不出对话框了。当然CSP配置要跟着项目实际情况调,比如你用了CDN上的前端资源,就得在script-src里显式加上对应的域名。
3.4 富文本场景:白名单过滤器才是正解
业务系统里总有“公告编辑”“个人简介”这类允许用户填HTML的需求。这时候不能直接整段输出编码——一旦编码,用户写的内容全都变成显式标签文本,功能就废了。怎么办?答案是白名单过滤。
我用得最多的是jsoup的Safelist机制。jsoup本来是个HTML解析库,它的Cleaner能把不安全的标签、属性、协议全部过滤掉,只保留白名单内的内容:
import org.jsoup.Jsoup; import org.jsoup.safety.Safelist; // 移除所有标签和属性,纯文本 String cleanText = Jsoup.clean(userHtml, Safelist.none()); // 允许指定安全标签,如 b, i, u, p, br, a[href] Safelist safelist = Safelist.relaxed() .addTags("p", "br", "strong", "em", "u", "ul", "ol", "li") .addAttributes("a", "href") .addProtocols("a", "href", "http", "https"); String cleanHtml = Jsoup.clean(userHtml, safelist);注意几点:
a标签的href属性要限制协议,否则攻击者可以用javascript:alert(1)注入,这是最经典的协议绕过。style属性默认不在白名单里,包含CSS的富文本需要单独做CSS解析,不能简单放行。- 富文本过滤要在后端执行,前端预览组件的过滤只是用户体验层面的。
4. 框架整合与纵深防御体系设计
4.1 Spring Security的高价值配置
如果你已经在用Spring Security,这层框架自带的安全能力其实是现成的纵深防御资源,关键看你会不会用。我每次加固都会重点核对这几个配置:
第一是CSRF(跨站请求伪造)防护。Spring Security默认开启CsrfFilter,要求所有非GET请求携带CSRF令牌。很多团队觉得它烦人,为了联调方便一关了之。我的建议是:除非你能保证系统只使用自定义HTTP头进行认证且没有跨域请求,否则杀掉CSRF防护等于放弃一道免费防线。
// 不建议的做法 http.csrf(csrf -> csrf.disable());第二是安全响应头。Spring Security默认会写入X-Content-Type-Options: nosniff、X-Frame-Options: DENY等,这些对缓解XSS衍生风险很有帮助。nosniff可以防止浏览器对响应内容做MIME类型嗅探,比如某个接口意外返回了含有HTML的文本,浏览器不会擅自把它当页面渲染,XSS攻击链路就被截断了一环。
http.headers(headers -> headers .contentTypeOptions() .and() .frameOptions().deny() .and() .httpStrictTransportSecurity() );第三是认证与授权不是XSS的防御,但它是SQL注入攻击的止损线。一个只读账号的注入,比一个管理员的注入破坏力小得多。所以业务接口一定要按角色做@PreAuthorize或方法级权限控制。这个虽然和SQL注入/ XSS不是同一层,但纵深防御体系里缺了它,前面防住了攻击入口也算白防——因为攻击者一旦绕过接口,会发现权限面太大。
4.2 企业级防御的分层模型
我会把所有安全控制画成四层来理解:
| 层级 | 防御手段 | 对SQL注入的作用 | 对XSS的作用 |
|---|---|---|---|
| 客户端 | 前端输入校验、内容编码 | 拦截明显恶意输入,减轻后端压力 | 降低直接访问触发概率 |
| 网关/WAF | 恶意特征拦截、限流 | 拦截已知注入特征 | 拦截已知xss特征 |
| 应用层 | 预编译、白名单、编码、CSP | 根治参数注入 | 阻止脚本被浏览器执行 |
| 数据层 | 权限最小化、备份 | 限制注入后的破坏半径 | 不直接相关但降低数据泄露影响 |
每一层都可以被绕过,但如果攻击者要同时绕过四层,成本就高到不值得打你这个目标了。我在安全加固模拟项目X里用的就是这个模型,做完之后基本可以拍着胸脯说:单点失守不等于全盘崩溃。
WAF到底要不要上?我个人的态度是:WAF要上,但永远别把WAF当主防线。它能挡住批量扫描和常见攻击脚本,为应用层修复争取时间,但规则更新永远落后于攻击手法,而且分布式场景绕WAF的姿势很多。代码本身不防御,上多少个WAF都是纸糊的墙。
4.3 安全基线检查清单
每次安全评审,我直接拿下面这张自查清单过一遍,通过率很高:
| 检查项 | 正确配置 | 错误示例 |
|---|---|---|
| JDBC查询 | PreparedStatement参数绑定 | Statement字符串拼接 |
| MyBatis | 一律#{}参数占位 | ${}直接插入变量 |
ORDER BY | 白名单映射列名 | 前端字段直接拼SQL |
LIKE查询 | CONCAT('%', ?, '%')参数化 | '%${keyword}%' |
| 动态表名 | 枚举/常量表白名单 | 字符串直接拼接 |
| JSON输出HTML | 前后端分离 + 前端默认转义 | 后端返回未处理HTML片段 |
| 富文本 | jsoup白名单过滤 | 直接原样输出 |
| 会话Cookie | HttpOnly + Secure + SameSite | 仅裸JSESSIONID |
| 安全响应头 | X-Content-Type-Options: nosniff, CSP | 全缺省 |
| 数据库账号 | 最小权限,无DDL | 应用账号为DBA |
| 日志 | 脱敏打印,不输出完整Cookie/密码 | 打印整个请求体和响应体 |
5. 真实项目里踩过的坑与排查思路
5.1 坑一:MyBatis的动态SQL你以为用了#{}就安全
我接手某后台管理系统时,代码审查发现一个听起来很合理的写法:动态拼接SQL片段,但变量都走了#{}。问题是拼接的片段本身是“代码”不是“数据”,比如这样:
<select id="query" resultType="Order"> SELECT * FROM t_order WHERE 1 = 1 <if test="status != null"> AND status = #{status} </if> <if test="userInput != null"> AND ${conditionSql} </if> </select>这里的conditionSql是从配置中心读取的查询条件模板,虽然业务方说“模板是我们自己控制的,不是用户输入”,但用户如果可以通过某个管理接口修改配置,注入就来了。而且这种“半参数化”的写法,一旦模板里埋了恶意SQL,代码审查很难一眼看出来。
教训是:模板本身也是“代码”,和用户输入一个标准对待。能用参数就参数化,模板SQL如果必须动态,那就把模板放进白名单配置,并有严格的修改审批流程。
5.2 坑二:统一输出编码导致正常功能异常
有一次做XSS加固,我在Controller的返回层做了一个AOP切面,把所有字符串字段做HTML编码。结果上线半天不到,告警就炸了:富文本编辑器保存的文章全部显示成了源码标签,用户反馈“页面全是尖括号”。
问题出在我做了“一刀切”。富文本内容确实需要HTML编码来确保安全,但它本身是允许标签的——编码后它就不是HTML了。我后来改用“按字段标记”的方式:在实体类的富文本字段上加自定义注解@SafeHtml(rule = "richText"),切面里只对没有该注解的字符串做编码;富文本字段单独走jsoup白名单过滤,不做整体编码。
另外教训是,JSON里如果某个字符串字段将来要被前端放进<script>标签的数据里,后端不能只做HTML编码,还得针对JavaScript上下文再处理。我在排查时发现前端源码里有这种用法:
var userInfo = ${userJson}; // 服务端直接渲染用户对象如果userJson里的字符串含</script><script>alert(1)</script>,浏览器在解析HTML时就已经把外层的</script>闭合了。这种情况服务端要用Encode.forJavaScript或把</转义成<\/。没有银弹,只做HTML编码,防不住JavaScript上下文的XSS。
5.3 坑三:日志打印导致的信息泄露
这个坑特别隐蔽。某次演练,我的同事在后台日志平台里看到了一段凭空多出来的隐藏HTML节点,排查了半天才发现是攻击者在ID参数里塞了XSS payload,日志框架把整个请求参数打了出来,日志查看后台又把日志内容当作HTML渲染了——存储型XSS就这样“借道”日志系统复活了。
从那以后我定了一条铁律:
- 日志打印时脱敏,所有用户输入统一用
{}占位符打印,并且对敏感字段(密码、手机号、身份证)做掩码处理。 - 日志查看后台如果支持HTML预览,一定要关掉,至少要对日志内容做HTML编码。日志平台的安全基线检查,纳入上线流程。
- 打印用户输入前,问自己一句:这条日志是否真的需要记录内容?很多时候只记录
userId和errorCode就够了。
5.4 被攻击后的排查路线与复现思路
万一系统还是被打了,怎么快速定位漏洞点?我一般按这个顺序来:
第一步,查应用访问日志,找异常的请求参数。SQL注入的特征是参数里出现单引号、union、select等关键字;XSS的特征是参数里出现<script>、<img onerror=等标签。可以先筛这一批请求。
第二步,看数据库日志。MySQL的通用日志(general_log)能记录所有实际执行的SQL语句。对比一下,应用层那批异常请求,是否真的进入了SQL语句,以及是否被参数化。如果日志里看到SQL模板本身完整(参数位是?),那说明预编译起了作用,注入没打穿;如果SQL语句里直接嵌进去了参数值,那就是最危险的拼接情况。
第三步,复现。构造一个最小可复现的payload,用curl或者Postman直接打接口,观察响应。注意一定在测试环境复现,别拿生产库练手。复现成功后,修复代码再回归,直到原payload无法触发。
我的经验是,确认一个漏洞是否修复,不只要测原始payload,还要测常见绕过变体:大小写混合(SeLeCt)、注释符拆分(sel/**/ect)、URL编码(%3Cscript%3E)、双写关键字(selselectect)等。有些系统买来的所谓“安全拦截组件”只匹配了固定字符串,换个姿势就海阔天空了。
写在最后
安全加固这件事,做完一次验收通过不代表一劳永逸。我在实际操作中的体会是:编码习惯、框架配置、部署环境三者会随着迭代不断漂移——新来的同事写了${},前端同学为了一个弹窗用了v-html,运维为了调试方便关了CSP,任何一个变化都可能让曾经的防线失效。
所以我现在把安全建设融进日常开发流程里:代码提交前做静态扫描(找SQL拼接、${}、危险API),代码评审里加安全Checklist,每次发版前跑一遍注入/XSS的冒烟用例。这个思路在安全加固模拟项目X里实践下来,比任何一次性整改都扛得住时间。
最后分享一个小技巧:给自己维护的系统造一个“专用攻击字典”,把SQL注入和XSS的经典payload保存到一个文本文件里,每次发版后跑一遍全接口回归。两三分钟的成本,能挡住95%以上的低级回归漏洞。安全这东西,枯燥但值得。