• 一个HTTPS转HTTP的Bug,他们忍了2年,原谅我无法接受,加班改了


    今天这篇文章给大家讲一个追查Bug的故事和过程。个人一直认为:事出反常必有妖,程序中的Bug也是如此

    希望通过这个Bug的排查故事,大家不仅能够学到一系列的知识点,同时也能学会如何解决问题,如何更加专业的做事。而解决问题的方式及思维比单纯的技术更加重要。

    Let’s go!

    故事的起因

    刚接手新团队新项目没多久,在发布一个系统时,同事友善的提醒:发布xx系统时,在测试环境要注释掉一行代码,上线发布时再放开注释。

    听此友善提醒,一惊:这又是什么黑科技啊?!在我的经验里,还没有什么系统需要这样处理,暗下决心要排查此问题。

    终于抽出时间,周五折腾了多半天,没解决掉,周末还心里惦记着,于是加班也搞定这个问题。

    Bug的存在及操作

    项目是基于JSP的,没有做前后端分离。在JSP页面中引入了一个公共的head.jsp,该文件内有这样一行代码和注释:

    <!-- 解决线上HTTPS浏览器转圈的问题,测试环境要注释掉下面的一句话 -->
    <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
    
    • 1
    • 2

    同事友善提醒的就是注释上的操作,测试环境注释掉(不然无法访问),生产环境需要放开,不然也无法访问(转圈圈啊)。据注释说明,大概知道是用来解决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="策略">
    
    • 1
    • 2

    其中,在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.
    
    • 1

    其中,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";
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    redirect方式的跳转在HTTPS的环境下会重定向到HTTP协议,导致无法访问。

    这也太坑了,难怪上面HTTP转HTTPS的设置都配置完成了,部分页面还不起效。

    而导致这个问题的根本原因是Spring的ViewResolver对HTTP 1.0协议的兼容。

    针对此问题,将其关闭即可解决,具体改造方案有两个。

    方案一,将redirect改为RedirectView类来实现:

    modelAndView.setView(new RedirectView(Global.getAdminPath() + "/list", true, false));
    
    • 1

    其中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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    由于项目中使用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));
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    通过上述代码追踪,可以看到,最终在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);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    示例中,将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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    经过上述的改造,关于HTTPS中的HTTP请求问题已经得到解决了。

    为了防止遗漏,又挨个点了一些页面,又发了问题了!哎,咋那么手欠呢……

    LayUI的坑

    本来以为解决了上面的问题,就彻底解决了,可以吃顿烧烤庆祝一下了。结果,在前端页面中又发现了类似的错误。但此时错误信息来自访问登录页面的路径:

    http://example.com/a/login
    
    • 1

    奇了怪了,已经登录成功了,为什么业务操作页面还会再请求login页面呢?而且跳转过去还是HTTP请求,而不是HTTPS的请求。

    查看了一下login的请求结果:

    303错误

    排查了相关的业务代码,登录完成之后,再也没有请求登录请求了啊,为什么会再次请求一次login呢?难道是访问某些资源受限,导致重定向到登录页面了?

    于是,查看了一下HTML调用的”Initiator“:

    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)
    
    • 1

    对照起来,也就是说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)
    
    • 1
    • 2
    • 3
    • 4
    • 5

    改造的基本思路是:如果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;
    • 其他排查该问题时用到或学到的技术;

    这些技术你学到了吗?解决问题的思路和方式方法你学到了吗?如果本文有那么一点内容启发到你了,我不吝分享,你也不要吝啬,点个赞吧。

    博主简介:《SpringBoot技术内幕》技术图书作者,酷爱钻研技术,写技术干货文章。

    公众号:「程序新视界」,博主的公众号,欢迎关注~

    技术交流:请联系博主微信号:zhuan2quan


    微信公众号:程序新视界

    程序新视界”,一个100%技术干货的公众号

  • 相关阅读:
    SpringMVC详解
    笔试面试相关记录(5)
    NaiveUI中看起来没啥用的组件(文字渐变)实现原来这么简单
    eslint+stylelint+prettier全流程配置
    SpringBoot接口 - 怎么处理Controller异常
    第三课 哈希表、集合、映射
    Java线程安全与不安全理解
    SQL必需掌握的100个重要知识点:使用视图
    【Sword系列】Vulnhub靶机HACKADEMIC: RTB1 writeup
    动态内存操作(2)
  • 原文地址:https://blog.csdn.net/wo541075754/article/details/121507099