今天这篇文章给大家讲一个追查Bug的故事和过程。个人一直认为:事出反常必有妖,程序中的Bug也是如此。
希望通过这个Bug的排查故事,大家不仅能够学到一系列的知识点,同时也能学会如何解决问题,如何更加专业的做事。而解决问题的方式及思维比单纯的技术更加重要。
Let's go!
故事的起因
刚接手新团队新项目没多久,在发布一个系统时,同事友善的提醒:发布xx系统时,在测试环境要注释掉一行代码,上线发布时再放开注释。
听此友善提醒,一惊:这又是什么黑科技啊?!在我的经验里,还没有什么系统需要这样处理,暗下决心要排查此问题。
终于抽出时间,周五折腾了多半天,没解决掉,周末还心里惦记着,于是加班也搞定这个问题。
Bug的存在及操作
项目是基于JSP的,没有做前后端分离。在JSP页面中引入了一个公共的head.jsp,该文件内有这样一行代码和注释:
<!-- 解决线上HTTPS浏览器转圈的问题,测试环境要注释掉下面的一句话 --> <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
同事友善提醒的就是注释上的操作,测试环境注释掉(不然无法访问),生产环境需要放开,不然也无法访问(转圈圈啊)。据注释说明,大概知道是用来解决HTTPS相关的问题。
那么,是什么原因导致了要这样操作?有没有更简单的操作?大家只是在这么做,没人寻找问题的根源,也没人能出答案,只能自己去寻找了。
HTTPS中的HTTP请求
先来看看配置META元素是干什么用的。
其中http-equiv指定的“Content-Security-Policy”就"网页安全政策",缩写CSP,常用来防止XSS攻击。
通常的使用方法就是在HTML中通过meta标签来进行定义:
<meta http-equiv="content-security-policy" content="策略"> <meta http-equiv="content-security-policy-report-only" content="策略">
其中,在content中可以指定涉及安全的各类限制策略。
项目中使用的upgrade-insecure-requests
便是限制策略之一,作用是:自动将网页上所有加载外部资源的HTTP链接换成HTTPS协议。
此刻稍微明白了一点,原来最初写这行代码是想将HTTP请求强制转换成HTTPS请求啊。
但正常情况来说,只要在Nginx或SLB中配置了HTTP转HTTPS便不会出现这类问题,而系统中是有对应的配置的。
于是,在线上另起一个服务实验了一下,注释掉这段代码,部分功能还真的在转圈圈,诚不欺我!
为什么HTTPS中不允许HTTP请求
查看浏览器中的请求,发现转圈圈原来是如下错误引起的:
Mixed Content: The page at 'https://example.com' was loaded over HTTPS, but requested an insecure stylesheet 'http://example.com/xxx'. This request has been blocked; the content must be served over HTTPS.
其中,Mixed Content即混合内容。所谓的混合内容通常出现在以下情况:初始的HTML的内容是通过HTTPS加载的,但其他资源(比如,css样式、js、图片等)则通过不安全的HTTP请求加载。此时,同一个页面,同时使用了HTTP和HTTPS的内容,而HTTP协议会降低整个页面的安全性。
因此,现代浏览器会针对HTTPS中的HTTP请求进行警告,阻断请求,并抛出上述异常信息。
现在,问题的原因基本明确了:HTTPS请求中出现了HTTP请求。
那么,解决方案有几种:
- 方案一:在HTML中添加meta标签,强制将HTTP请求转换成HTTPS请求。这也是上面的使用方式,但这种方式的弊端也很明显,在没有使用HTTPS的测试环境,需要手动的注释掉。否则,也无法正常访问。
- 方案二:通过Nginx或SLB的配置,将HTTP请求转换成HTTPS请求。
- 方案三:最笨的方法,找到项目中存在HTTP请求的问题,逐个修复。
初步改造,略显成效
目前使用的第一种方案很显然不符合要求,而第二种方案已经配置了,但部分页面依旧不起效。那么,还有其他方案吗?
经过大量排查,发现导致不起效的原因是:项目中大量使用了redirect方式的跳转。
@RequestMapping(value = "delete") public String delete(RedirectAttributes redirectAttributes) { //.. do something addMessage(redirectAttributes, "删除xxx成功"); return "redirect:" + Global.getAdminPath() + "/list"; }
redirect
方式的跳转在HTTPS的环境下会重定向到HTTP协议,导致无法访问。
这也太坑了,难怪上面HTTP转HTTPS的设置都配置完成了,部分页面还不起效。
而导致这个问题的根本原因是Spring的ViewResolver对HTTP 1.0协议的兼容。
针对此问题,将其关闭即可解决,具体改造方案有两个。
方案一,将redirect
改为RedirectView
类来实现:
modelAndView.setView(new RedirectView(Global.getAdminPath() + "/list", true, false));
其中RedirectView
的最后一个参数设置为false,就是将http10Compatible
的开关关闭,不对HTTP 1.0协议进行兼容。
方案二:配置Spring的ViewResolver的redirectHttp10Compatible属性。通过这种方案,可以实现全局关闭。
<bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="viewClass" value="org.springframework.web.servlet.view.JstlView" /> <property name="prefix" value="/" /> <property name="suffix" value=".jsp" /> <property name="redirectHttp10Compatible" value="false" /> </bean>
由于项目中使用redirect
较多,于是就采用了第二种方案。修改之后,发现大部分问题都解决了。
为了防止遗漏,就多点了一些页面,竟然还有漏网之鱼!
Shiro拦截器又作祟
解决了重定向导致的问题,以为万事大吉了,结果涉及到Shiro重定向的页面又出现了类似的问题。原因很简单:某些页面的权限验证需要经过Shiro,但Shiro将HTTPS请求拦截之后,重定向时转换成了HTTP请求。
那么,为什么视图层将redirectHttp10Compatible设置为false不起效呢?
追踪了Shiro拦截器中的代码,发现Shiro在拦截器中默认将redirectHttp10Compatible设置为true,又是一坑~
查看源码可以发现,Shiro的登录过滤器FormAuthenticationFilter的方法中调用了saveRequestAndRedirectToLogin方法:
protected void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException { saveRequest(request); redirectToLogin(request, response); } // 进而调用redirectToLogin方法 protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException { String loginUrl = getLoginUrl(); WebUtils.issueRedirect(request, response, loginUrl); } // 通过WebUtils.issueRedirect进行设置 public static void issueRedirect(ServletRequest request, ServletResponse response, String url) throws IOException { issueRedirect(request, response, url, (Map)null, true, true); } // 通过WebUtils.issueRedirect重载方法 public static void issueRedirect(ServletRequest request, ServletResponse response, String url, Map queryParams, boolean contextRelative, boolean http10Compatible) throws IOException { RedirectView view = new RedirectView(url, contextRelative, http10Compatible); view.renderMergedOutputModel(queryParams, toHttp(request), toHttp(response)); }
通过上述代码追踪,可以看到,最终在WebUtils的issueRedirect方法中调用了两次issueRedirect,而http10Compatible参数值默认为true。
找到问题的根源,解决起来就简单了,重写FormAuthenticationFilter拦截器:
public class CustomFormAuthenticationFilter extends FormAuthenticationFilter { @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { if (isLoginRequest(request, response)) { if (isLoginSubmission(request, response)) { return executeLogin(request, response); } else { return true; } } else { saveRequestAndRedirectToLogin(request, response); return false; } } protected void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException { saveRequest(request); redirectToLogin(request, response); } protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException { String loginUrl = getLoginUrl(); WebUtils.issueRedirect(request, response, loginUrl, null, true, false); } }
示例中,将onAccessDenied中需要原本调用WebUtils.issueRedirect方法的http10Compatible参数改为false即可。
上面只是示例,实际上不仅包括成功页面,还包括失败页面等,都需要重新实现一下对应的方法。最后,在shiroFilter中配置自定义的拦截器。
<!-- 自定义的登录过滤器--> <bean id="customFilter" class="com.senzhuang.shiro.CustomFormAuthenticationFilter" /> <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean"> <property name="securityManager" ref="securityManager" /> <property name="loginUrl" value="/login.html"></property> <property name="unauthorizedUrl" value="/refuse.html"></property> <property name="filters"> <map> <entry key="authc" value-ref="customFilter"/> </map> </property> </bean>
经过上述的改造,关于HTTPS中的HTTP请求问题已经得到解决了。
为了防止遗漏,又挨个点了一些页面,又发了问题了!哎,咋那么手欠呢……
LayUI的坑
本来以为解决了上面的问题,就彻底解决了,可以吃顿烧烤庆祝一下了。结果,在前端页面中又发现了类似的错误。但此时错误信息来自访问登录页面的路径:
http://example.com/a/login
奇了怪了,已经登录成功了,为什么业务操作页面还会再请求login页面呢?而且跳转过去还是HTTP请求,而不是HTTPS的请求。
查看了一下login的请求结果:排查了相关的业务代码,登录完成之后,再也没有请求登录请求了啊,为什么会再次请求一次login呢?难道是访问某些资源受限,导致重定向到登录页面了?
于是,查看了一下HTML调用的”Initiator“:原来是LayUI请求对应的layer.css资源时,触发了login的登录操作。
首先想到的是Shiro中没有放开静态资源的拦截,于是在Shiro中放开了layui的拦截权限,但问题已经存在。
再次排查,发现页面中没有主动引入layer.css文件,于是主动引入了layer.css文件,但问题还是存在。
没办法,只好查看layui.js,看看为什么要发起这个请求。此时,还留意到请求路径中有一个"undefinedcss"的词。
用过js的朋友都知道,undefined是js中变量未初始化的默认值,类似Java中的null。
在layui.js中搜索”css/“,还真找到这样一段代码:
return layui.link(o.dir + "css/" + e, t, n)
对照起来,也就是说o.dir的值为"undefined",与后面的css连接起来就变成了"undefinedcss",而这个路径并不存在,也没在Shiro中进行权限配置,默认会走到登录界面去。而这里是内部的一个异步的redirect请求,不会在页面呈现,要查看浏览器的错误信息才能发现。
找到问题原因了,改造起来就简单了,将layui的link方法参数进行修改:
// 注释掉 // return layui.link(o.dir + "css/" + e, t, n) // 改为 return layui.link((o.dir ? o.dir:"/static/sc_layui/") +"css/"+e, t, n)
改造的基本思路是:如果o.dir有值(js中有值即为true)则使用o.dir的值;如果o.dir为undefined则采用指定的默认值。
其中"/static/sc_layui/"为项目中存放layui组件的路径。由于layui.js可能是压缩后的js,可通过搜索”css/“或”layui.link“找到对应的代码。
重启项目,清除浏览器缓存,再次访问页面,问题得到彻底解决。
可以安心吃烤串了
周末又花了半天时间,终于把这个问题彻底解决了,现在可以安心去吃顿烤串庆祝一下了。
最后,回顾一下这个过程,看看你能从中收获到什么:
- 出现问题:不同环境(HTTP和HTTPS)需要手动改代码;
- 寻找问题:为了安全,HTTPS内不允许发起HTTP请求;
- 解决问题:两种方式关闭
http10Compatible
; - Shiro问题:Shiro中默认为关闭
http10Compatible
,重写Filter,实现关闭操作; - LayUI Bug修复:LayUI代码bug,导致发起http(登录)请求。修复此Bug;
在这个过程中,如果你只是安于现状,”遵守规则“,每次上线时修改一下文件,不仅费时费力,而且不知为什么要这么做。
但如果像笔者一样,刨根问底的追踪一下,你将会学到一系列的知识:
- HTTP请求的CSP,upgrade-insecure-requests配置;
- HTTPS中为什么不能发起HTTP请求;
- Spring视图解析器中配置
http10Compatible
; - redirect方式视图返回的弊端;
- Nginx中如何将HTTP请求转为HTTPS请求;
- HTTP请求的混合内容(Mixed Content)概念及错误;
- HTTP 1.0、HTTP 1.1、HTTP2.0协议的区别;
- Shiro拦截器自定义Filter;
- Shiro拦截器过滤指定URL访问;
- Shiro拦截器的配置及部分源码实现;
- LayUI的一个bug;
- 其他排查该问题时用到或学到的技术;
这些技术你学到了吗?解决问题的思路和方式方法你学到了吗?如果本文有那么一点内容启发到你了,我不吝分享,你也不要吝啬,点个赞吧。