# 29 | 数据和代码:数据就是数据,代码就是代码 你好,我是朱晔。今天,我来和你聊聊数据和代码的问题。 正如这一讲标题“数据就是数据,代码就是代码”所说,Web安全方面的很多漏洞,都是源自把数据当成了代码来执行,也就是注入类问题,比如: * 客户端提供给服务端的查询值,是一个数据,会成为SQL查询的一部分。黑客通过修改这个值注入一些SQL,来达到在服务端运行SQL的目的,相当于把查询条件的数据变为了查询代码。这种攻击方式,叫做SQL注入。 * 对于规则引擎,我们可能会用动态语言做一些计算,和SQL注入一样外部传入的数据只能当做数据使用,如果被黑客利用传入了代码,那么代码可能就会被动态执行。这种攻击方式,叫做代码注入。 * 对于用户注册、留言评论等功能,服务端会从客户端收集一些信息,本来用户名、邮箱这类信息是纯文本信息,但是黑客把信息替换为了JavaScript代码。那么,这些信息在页面呈现时,可能就相当于执行了JavaScript代码。甚至是,服务端可能把这样的代码,当作普通信息保存到了数据库。黑客通过构建JavaScript代码来实现修改页面呈现、盗取信息,甚至蠕虫攻击的方式,叫做XSS(跨站脚本)攻击。 今天,我们就通过案例来看一下这三个问题,并了解下应对方式。 ## SQL注入能干的事情比你想象的更多 我们应该都听说过SQL注入,也可能知道最经典的SQL注入的例子,是通过构造’or’1’='1作为密码实现登录。这种简单的攻击方式,在十几年前可以突破很多后台的登录,但现在很难奏效了。 最近几年,我们的安全意识增强了,都知道使用参数化查询来避免SQL注入问题。其中的原理是,使用参数化查询的话,参数只能作为普通数据,不可能作为SQL的一部分,以此有效避免SQL注入问题。 虽然我们已经开始关注SQL注入的问题,但还是有一些认知上的误区,主要表现在以下三个方面: 第一,**认为SQL注入问题只可能发生于Http Get请求,也就是通过URL传入的参数才可能产生注入点**。这是很危险的想法。从注入的难易度上来说,修改URL上的QueryString和修改Post请求体中的数据,没有任何区别,因为黑客是通过工具来注入的,而不是通过修改浏览器上的URL来注入的。甚至Cookie都可以用来SQL注入,任何提供数据的地方都可能成为注入点。 第二,**认为不返回数据的接口,不可能存在注入问题**。其实,黑客完全可以利用SQL语句构造出一些不正确的SQL,导致执行出错。如果服务端直接显示了错误信息,那黑客需要的数据就有可能被带出来,从而达到查询数据的目的。甚至是,即使没有详细的出错信息,黑客也可以通过所谓盲注的方式进行攻击。我后面再具体解释。 第三,**认为SQL注入的影响范围,只是通过短路实现突破登录,只需要登录操作加强防范即可**。首先,SQL注入完全可以实现拖库,也就是下载整个数据库的内容(之后我们会演示),SQL注入的危害不仅仅是突破后台登录。其次,根据木桶原理,整个站点的安全性受限于安全级别最低的那块短板。因此,对于安全问题,站点的所有模块必须一视同仁,并不是只加强防范所谓的重点模块。 在日常开发中,虽然我们是使用框架来进行数据访问的,但还可能会因为疏漏而导致注入问题。接下来,我就用一个实际的例子配合专业的SQL注入工具[sqlmap](https://github.com/sqlmapproject/sqlmap),来测试下SQL注入。 首先,在程序启动的时候使用JdbcTemplate创建一个userdata表(表中只有ID、用户名、密码三列),并初始化两条用户信息。然后,创建一个不返回任何数据的Http Post接口。在实现上,我们通过SQL拼接的方式,把传入的用户名入参拼接到LIKE子句中实现模糊查询。 ``` //程序启动时进行表结构和数据初始化 @PostConstruct public void init() { //删除表 jdbcTemplate.execute("drop table IF EXISTS `userdata`;"); //创建表,不包含自增ID、用户名、密码三列 jdbcTemplate.execute("create TABLE `userdata` (\n" + " `id` bigint(20) NOT NULL AUTO_INCREMENT,\n" + " `name` varchar(255) NOT NULL,\n" + " `password` varchar(255) NOT NULL,\n" + " PRIMARY KEY (`id`)\n" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); //插入两条测试数据 jdbcTemplate.execute("INSERT INTO `userdata` (name,password) VALUES ('test1','haha1'),('test2','haha2')"); } @Autowired private JdbcTemplate jdbcTemplate; //用户模糊搜索接口 @PostMapping("jdbcwrong") public void jdbcwrong(@RequestParam("name") String name) { //采用拼接SQL的方式把姓名参数拼到LIKE子句中 log.info("{}", jdbcTemplate.queryForList("SELECT id,name FROM userdata WHERE name LIKE '%" + name + "%'")); } ``` 使用sqlmap来探索这个接口: ``` python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test ``` 一段时间后,sqlmap给出了如下结果: ![](https://static001.geekbang.org/resource/image/2f/59/2f8e8530dd0f76778c45333adfad5259.png) 可以看到,这个接口的name参数有两种可能的注入方式:一种是报错注入,一种是基于时间的盲注。 接下来,**仅需简单的三步,就可以直接导出整个用户表的内容了**。 第一步,查询当前数据库: ``` python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test --current-db ``` 可以得到当前数据库是common\_mistakes: ``` current database: 'common_mistakes' ``` 第二步,查询数据库下的表: ``` python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test --tables -D "common_mistakes" ``` 可以看到其中有一个敏感表userdata: ``` Database: common_mistakes [7 tables] +--------------------+ | user | | common_store | | hibernate_sequence | | m | | news | | r | | userdata | +--------------------+ ``` 第三步,查询userdata的数据: ``` python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test -D "common_mistakes" -T "userdata" --dump ``` 你看,**用户密码信息一览无遗。当然,你也可以继续查看其他表的数据**: ``` Database: common_mistakes Table: userdata [2 entries] +----+-------+----------+ | id | name | password | +----+-------+----------+ | 1 | test1 | haha1 | | 2 | test2 | haha2 | +----+-------+----------+ ``` 在日志中可以看到,sqlmap实现拖库的方式是,让SQL执行后的出错信息包含字段内容。注意看下错误日志的第二行,错误信息中包含ID为2的用户的密码字段的值“haha2”。这,就是报错注入的基本原理: ``` [13:22:27.375] [http-nio-45678-exec-10] [ERROR] [o.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.DuplicateKeyException: StatementCallback; SQL [SELECT id,name FROM userdata WHERE name LIKE '%test'||(SELECT 0x694a6e64 WHERE 3941=3941 AND (SELECT 9927 FROM(SELECT COUNT(*),CONCAT(0x71626a7a71,(SELECT MID((IFNULL(CAST(password AS NCHAR),0x20)),1,54) FROM common_mistakes.userdata ORDER BY id LIMIT 1,1),0x7170706271,FLOOR(RAND(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a))||'%']; Duplicate entry 'qbjzqhaha2qppbq1' for key ''; nested exception is java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'qbjzqhaha2qppbq1' for key ''] with root cause java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'qbjzqhaha2qppbq1' for key '' ``` 既然是这样,我们就实现一个ExceptionHandler来屏蔽异常,看看能否解决注入问题: ``` @ExceptionHandler public void handle(HttpServletRequest req, HandlerMethod method, Exception ex) { log.warn(String.format("访问 %s -> %s 出现异常!", req.getRequestURI(), method.toString()), ex); } ``` 重启程序后重新运行刚才的sqlmap命令,可以看到报错注入是没戏了,但使用时间盲注还是可以查询整个表的数据: ![](https://static001.geekbang.org/resource/image/76/c4/76ec4c2217cc5ac190b578e7236dc9c4.png) 所谓盲注,指的是注入后并不能从服务器得到任何执行结果(甚至是错误信息),只能寄希望服务器对于SQL中的真假条件表现出不同的状态。比如,对于布尔盲注来说,可能是“真”可以得到200状态码,“假”可以得到500错误状态码;或者,“真”可以得到内容输出,“假”得不到任何输出。总之,对于不同的SQL注入可以得到不同的输出即可。 在这个案例中,因为接口没有输出,也彻底屏蔽了错误,布尔盲注这招儿行不通了。那么退而求其次的方式,就是时间盲注。也就是说,通过在真假条件中加入SLEEP,来实现通过判断接口的响应时间,知道条件的结果是真还是假。 不管是什么盲注,都是通过真假两种状态来完成的。你可能会好奇,通过真假两种状态如何实现数据导出? 其实你可以想一下,我们虽然不能直接查询出password字段的值,但可以按字符逐一来查,判断第一个字符是否是a、是否是b……,查询到h时发现响应变慢了,自然知道这就是真的,得出第一位就是h。以此类推,可以查询出整个值。 所以,sqlmap在返回数据的时候,也是一个字符一个字符跳出结果的,并且时间盲注的整个过程会比报错注入慢许多。 你可以引入[p6spy](https://github.com/p6spy/p6spy)工具打印出所有执行的SQL,观察sqlmap构造的一些SQL,来分析其中原理: ``` com.github.gavlyukovskiy p6spy-spring-boot-starter 1.6.1 ``` ![](https://static001.geekbang.org/resource/image/5d/0d/5d9a582025bb06adf863ae21ccb9280d.png) 所以说,即使屏蔽错误信息错误码,也不能彻底防止SQL注入。真正的解决方式,还是使用参数化查询,让任何外部输入值只可能作为数据来处理。 比如,对于之前那个接口,**在SQL语句中使用“?”作为参数占位符,然后提供参数值。**这样修改后,sqlmap也就无能为力了: ``` @PostMapping("jdbcright") public void jdbcright(@RequestParam("name") String name) { log.info("{}", jdbcTemplate.queryForList("SELECT id,name FROM userdata WHERE name LIKE ?", "%" + name + "%")); } ``` **对于MyBatis来说,同样需要使用参数化的方式来写SQL语句。在MyBatis中,“#{}”是参数化的方式,“${}”只是占位符替换。** 比如LIKE语句。因为使用“#{}”会为参数带上单引号,导致LIKE语法错误,所以一些同学会退而求其次,选择“${}”的方式,比如: ``` @Select("SELECT id,name FROM `userdata` WHERE name LIKE '%${name}%'") List findByNameWrong(@Param("name") String name); ``` 你可以尝试一下,使用sqlmap同样可以实现注入。正确的做法是,使用“#{}”来参数化name参数,对于LIKE操作可以使用CONCAT函数来拼接%符号: ``` @Select("SELECT id,name FROM `userdata` WHERE name LIKE CONCAT('%',#{name},'%')") List findByNameRight(@Param("name") String name); ``` 又比如IN子句。因为涉及多个元素的拼接,一些同学不知道如何处理,也可能会选择使用“${}”。因为使用“#{}”会把输入当做一个字符串来对待: ``` ``` 但是,这样直接把外部传入的内容替换到IN内部,同样会有注入漏洞: ``` @PostMapping("mybatiswrong2") public List mybatiswrong2(@RequestParam("names") String names) { return userDataMapper.findByNamesWrong(names); } ``` 你可以使用下面这条命令测试下: ``` python sqlmap.py -u http://localhost:45678/sqlinject/mybatiswrong2 --data names="'test1','test2'" ``` 最后可以发现,有4种可行的注入方式,分别是布尔盲注、报错注入、时间盲注和联合查询注入: ![](https://static001.geekbang.org/resource/image/bd/d3/bdc7a7bcb34b59396f4a99d62425d6d3.png) 修改方式是,给MyBatis传入一个List,然后使用其foreach标签来拼接出IN中的内容,并确保IN中的每一项都是使用“#{}”来注入参数: ``` @PostMapping("mybatisright2") public List mybatisright2(@RequestParam("names") List names) { return userDataMapper.findByNamesRight(names); } ``` 修改后这个接口就不会被注入了,你可以自行测试一下。 ## 小心动态执行代码时代码注入漏洞 总结下,我们刚刚看到的SQL注入漏洞的原因是,黑客把SQL攻击代码通过传参混入SQL语句中执行。同样,对于任何解释执行的其他语言代码,也可以产生类似的注入漏洞。我们看一个动态执行JavaScript代码导致注入漏洞的案例。 现在,我们要对用户名实现动态的规则判断:通过ScriptEngineManager获得一个JavaScript脚本引擎,使用Java代码来动态执行JavaScript代码,实现当外部传入的用户名为admin的时候返回1,否则返回0: ``` private ScriptEngineManager scriptEngineManager = new ScriptEngineManager(); //获得JavaScript脚本引擎 private ScriptEngine jsEngine = scriptEngineManager.getEngineByName("js"); @GetMapping("wrong") public Object wrong(@RequestParam("name") String name) { try { //通过eval动态执行JavaScript脚本,这里name参数通过字符串拼接方式混入JavaScript代码 return jsEngine.eval(String.format("var name='%s'; name=='admin'?1:0;", name)); } catch (ScriptException e) { e.printStackTrace(); } return null; } ``` 这个功能本身没什么问题: ![](https://static001.geekbang.org/resource/image/a5/08/a5c253d78b6b40f6e2aa8283732f0408.png) 但是,如果我们把传入的用户名修改为这样: ``` haha';java.lang.System.exit(0);' ``` 就可以达到关闭整个程序的目的。原因是,我们直接把代码和数据拼接在了一起。外部如果构造了一个特殊的用户名先闭合字符串的单引号,再执行一条System.exit命令的话,就可以满足脚本不出错,命令被执行。 解决这个问题有两种方式。 第一种方式和解决SQL注入一样,需要**把外部传入的条件数据仅仅当做数据来对待。我们可以通过SimpleBindings来绑定参数初始化name变量**,而不是直接拼接代码: ``` @GetMapping("right") public Object right(@RequestParam("name") String name) { try { //外部传入的参数 Map parm = new HashMap<>(); parm.put("name", name); //name参数作为绑定传给eval方法,而不是拼接JavaScript代码 return jsEngine.eval("name=='admin'?1:0;", new SimpleBindings(parm)); } catch (ScriptException e) { e.printStackTrace(); } return null; } ``` 这样就避免了注入问题: ![](https://static001.geekbang.org/resource/image/a0/49/a032842a5e551db18bd45dacf7794a49.png) 第二种解决方法是,使用SecurityManager配合AccessControlContext,来构建一个脚本运行的沙箱环境。脚本能执行的所有操作权限,是通过setPermissions方法精细化设置的: ``` @Slf4j public class ScriptingSandbox { private ScriptEngine scriptEngine; private AccessControlContext accessControlContext; private SecurityManager securityManager; private static ThreadLocal needCheck = ThreadLocal.withInitial(() -> false); public ScriptingSandbox(ScriptEngine scriptEngine) throws InstantiationException { this.scriptEngine = scriptEngine; securityManager = new SecurityManager(){ //仅在需要的时候检查权限 @Override public void checkPermission(Permission perm) { if (needCheck.get() && accessControlContext != null) { super.checkPermission(perm, accessControlContext); } } }; //设置执行脚本需要的权限 setPermissions(Arrays.asList( new RuntimePermission("getProtectionDomain"), new PropertyPermission("jdk.internal.lambda.dumpProxyClasses","read"), new FilePermission(Shell.class.getProtectionDomain().getPermissions().elements().nextElement().getName(),"read"), new RuntimePermission("createClassLoader"), new RuntimePermission("accessClassInPackage.jdk.internal.org.objectweb.*"), new RuntimePermission("accessClassInPackage.jdk.nashorn.internal.*"), new RuntimePermission("accessDeclaredMembers"), new ReflectPermission("suppressAccessChecks") )); } //设置执行上下文的权限 public void setPermissions(List permissionCollection) { Permissions perms = new Permissions(); if (permissionCollection != null) { for (Permission p : permissionCollection) { perms.add(p); } } ProtectionDomain domain = new ProtectionDomain(new CodeSource(null, (CodeSigner[]) null), perms); accessControlContext = new AccessControlContext(new ProtectionDomain[]{domain}); } public Object eval(final String code) { SecurityManager oldSecurityManager = System.getSecurityManager(); System.setSecurityManager(securityManager); needCheck.set(true); try { //在AccessController的保护下执行脚本 return AccessController.doPrivileged((PrivilegedAction) () -> { try { return scriptEngine.eval(code); } catch (ScriptException e) { e.printStackTrace(); } return null; }, accessControlContext); } catch (Exception ex) { log.error("抱歉,无法执行脚本 {}", code, ex); } finally { needCheck.set(false); System.setSecurityManager(oldSecurityManager); } return null; } ``` 写一段测试代码,使用刚才定义的ScriptingSandbox沙箱工具类来执行脚本: ``` @GetMapping("right2") public Object right2(@RequestParam("name") String name) throws InstantiationException { //使用沙箱执行脚本 ScriptingSandbox scriptingSandbox = new ScriptingSandbox(jsEngine); return scriptingSandbox.eval(String.format("var name='%s'; name=='admin'?1:0;", name)); } ``` 这次,我们再使用之前的注入脚本调用这个接口: ``` http://localhost:45678/codeinject/right2?name=haha%27;java.lang.System.exit(0);%27 ``` 可以看到,结果中抛出了AccessControlException异常,注入攻击失效了: ``` [13:09:36.080] [http-nio-45678-exec-1] [ERROR] [o.g.t.c.c.codeinject.ScriptingSandbox:77 ] - 抱歉,无法执行脚本 var name='haha';java.lang.System.exit(0);''; name=='admin'?1:0; java.security.AccessControlException: access denied ("java.lang.RuntimePermission" "exitVM.0") at java.security.AccessControlContext.checkPermission(AccessControlContext.java:472) at java.lang.SecurityManager.checkPermission(SecurityManager.java:585) at org.geekbang.time.commonmistakes.codeanddata.codeinject.ScriptingSandbox$1.checkPermission(ScriptingSandbox.java:30) at java.lang.SecurityManager.checkExit(SecurityManager.java:761) at java.lang.Runtime.exit(Runtime.java:107) ``` 在实际应用中,我们可以考虑同时使用这两种方法,确保代码执行的安全性。 ## XSS必须全方位严防死堵 对于业务开发来说,XSS的问题同样要引起关注。 XSS问题的根源在于,原本是让用户传入或输入正常数据的地方,被黑客替换为了JavaScript脚本,页面没有经过转义直接显示了这个数据,然后脚本就被执行了。更严重的是,脚本没有经过转义就保存到了数据库中,随后页面加载数据的时候,数据中混入的脚本又当做代码执行了。黑客可以利用这个漏洞来盗取敏感数据,诱骗用户访问钓鱼网站等。 我们写一段代码测试下。首先,服务端定义两个接口,其中index接口查询用户名信息返回给xss页面,save接口使用@RequestParam注解接收用户名,并创建用户保存到数据库;然后,重定向浏览器到index接口: ``` @RequestMapping("xss") @Slf4j @Controller public class XssController { @Autowired private UserRepository userRepository; //显示xss页面 @GetMapping public String index(ModelMap modelMap) { //查数据库 User user = userRepository.findById(1L).orElse(new User()); //给View提供Model modelMap.addAttribute("username", user.getName()); return "xss"; } //保存用户信息 @PostMapping public String save(@RequestParam("username") String username, HttpServletRequest request) { User user = new User(); user.setId(1L); user.setName(username); userRepository.save(user); //保存完成后重定向到首页 return "redirect:/xss/"; } } //用户类,同时作为DTO和Entity @Entity @Data public class User { @Id private Long id; private String name; } ``` 我们使用Thymeleaf模板引擎来渲染页面。模板代码比较简单,页面加载的时候会在标签显示用户名,用户输入用户名提交后调用save接口创建用户: ```
``` 打开xss页面后,在文本框中输入点击Register按钮提交,页面会弹出alert对话框: ![](https://static001.geekbang.org/resource/image/cc/7f/cc50a56d83b3687859a396081346a47f.png) ![](https://static001.geekbang.org/resource/image/c4/71/c4633bc6edc93c98e1d27969f6518571.png) 并且,脚本被保存到了数据库: ![](https://static001.geekbang.org/resource/image/7e/bc/7ed8a0a92059149ed32bae43458307bc.png) 你可能想到了,解决方式就是HTML转码。既然是通过@RequestParam来获取请求参数,那我们定义一个@InitBinder实现数据绑定的时候,对字符串进行转码即可: ``` @ControllerAdvice public class SecurityAdvice { @InitBinder protected void initBinder(WebDataBinder binder) { //注册自定义的绑定器 binder.registerCustomEditor(String.class, new PropertyEditorSupport() { @Override public String getAsText() { Object value = getValue(); return value != null ? value.toString() : ""; } @Override public void setAsText(String text) { //赋值时进行HTML转义 setValue(text == null ? null : HtmlUtils.htmlEscape(text)); } }); } } ``` 的确,针对这个场景,这种做法是可行的。数据库中保存了转义后的数据,因此数据会被当做HTML显示在页面上,而不是当做脚本执行: ![](https://static001.geekbang.org/resource/image/5f/ca/5ff4c92a1571da41ccb804c4232171ca.png) ![](https://static001.geekbang.org/resource/image/88/01/88cedbd1557690157e52010280386801.png) 但是,这种处理方式犯了一个严重的错误,那就是没有从根儿上来处理安全问题。因为@InitBinder是Spring Web层面的处理逻辑,如果有代码不通过@RequestParam来获取数据,而是直接从HTTP请求获取数据的话,这种方式就不会奏效。比如这样: ``` user.setName(request.getParameter("username")); ``` 更合理的解决方式是,定义一个servlet Filter,通过HttpServletRequestWrapper实现servlet层面的统一参数替换: ``` //自定义过滤器 @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { chain.doFilter(new XssRequestWrapper((HttpServletRequest) request), response); } } public class XssRequestWrapper extends HttpServletRequestWrapper { public XssRequestWrapper(HttpServletRequest request) { super(request); } @Override public String[] getParameterValues(String parameter) { //获取多个参数值的时候对所有参数值应用clean方法逐一清洁 return Arrays.stream(super.getParameterValues(parameter)).map(this::clean).toArray(String[]::new); } @Override public String getHeader(String name) { //同样清洁请求头 return clean(super.getHeader(name)); } @Override public String getParameter(String parameter) { //获取参数单一值也要处理 return clean(super.getParameter(parameter)); } //clean方法就是对值进行HTML转义 private String clean(String value) { return StringUtils.isEmpty(value)? "" : HtmlUtils.htmlEscape(value); } } ``` 这样,我们就可以实现所有请求参数的HTML转义了。不过,这种方式还是不够彻底,原因是无法处理通过@RequestBody注解提交的JSON数据。比如,有这样一个PUT接口,直接保存了客户端传入的JSON User对象: ``` @PutMapping public void put(@RequestBody User user) { userRepository.save(user); } ``` 通过Postman请求这个接口,保存到数据库中的数据还是没有转义: ![](https://static001.geekbang.org/resource/image/6d/4f/6d8e2b3b68e8a623d039d9d73999a64f.png) 我们需要自定义一个Jackson反列化器,来实现反序列化时的字符串的HTML转义: ``` //注册自定义的Jackson反序列器 @Bean public Module xssModule() { SimpleModule module = new SimpleModule(); module.module.addDeserializer(String.class, new XssJsonDeserializer()); return module; } public class XssJsonDeserializer extends JsonDeserializer { @Override public String deserialize(JsonParser jsonParser, DeserializationContext ctxt) throws IOException, JsonProcessingException { String value = jsonParser.getValueAsString(); if (value != null) { //对于值进行HTML转义 return HtmlUtils.htmlEscape(value); } return value; } @Override public Class handledType() { return String.class; } } ``` 这样就实现了既能转义Get/Post通过请求参数提交的数据,又能转义请求体中直接提交的JSON数据。 你可能觉得做到这里,我们的防范已经很全面了,但其实不是。这种只能堵新漏,确保新数据进入数据库之前转义。如果因为之前的漏洞,数据库中已经保存了一些JavaScript代码,那么读取的时候同样可能出问题。因此,我们还要实现数据读取的时候也转义。 接下来,我们看一下具体的实现方式。 首先,之前我们处理了JSON反序列化问题,那么就需要同样处理序列化,实现数据从数据库中读取的时候转义,否则读出来的JSON可能包含JavaScript代码。 比如,我们定义这样一个GET接口以JSON来返回用户信息: ``` @GetMapping("user") @ResponseBody public User query() { return userRepository.findById(1L).orElse(new User()); } ``` ![](https://static001.geekbang.org/resource/image/b2/f8/b2f919307e42e79ce78622b305d455f8.png) 修改之前的SimpleModule加入自定义序列化器,并且实现序列化时处理字符串转义: ``` //注册自定义的Jackson序列器 @Bean public Module xssModule() { SimpleModule module = new SimpleModule(); module.addDeserializer(String.class, new XssJsonDeserializer()); module.addSerializer(String.class, new XssJsonSerializer()); return module; } public class XssJsonSerializer extends JsonSerializer { @Override public Class handledType() { return String.class; } @Override public void serialize(String value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { if (value != null) { //对字符串进行HTML转义 jsonGenerator.writeString(HtmlUtils.htmlEscape(value)); } } } ``` 可以看到,这次读到的JSON也转义了: ![](https://static001.geekbang.org/resource/image/31/fc/315f67193d1f9efe4b09db85361c53fc.png) 其次,我们还需要处理HTML模板。对于Thymeleaf模板引擎,需要注意的是,使用th:utext来显示数据是不会进行转义的,需要使用th:text: ```