工作踩坑系列——https 訪問遇到 “已阻止載入混合活動內容”

王子昊發表於2019-02-19

原文地址

https域名伺服器請求http域名伺服器下的介面,報錯問題。
最近在主導公司網站進行全站Https改造工作,本文記錄在改造過程中遇到的一個由於後端302跳轉導致前端瀏覽器阻止訪問的問題,感覺這樣的問題有一定通用性,所以編輯成文,希望能給遇到類似問題的人們有所幫助。

經過一段時間的調研工作,終於將公司的環境改造成支援https訪問模式,信心滿滿的開啟公司測試環境主頁,https://test.xxx.com。一切正常,就在我以為改造工作就要完成的時候,問題就出現了

進入主頁正常,輸入使用者名稱和密碼登入,頁面就不動了。調出Firefox的控制檯檢視,發現這麼一行報錯。

clipboard.png
file
(圖一)

開啟網路皮膚檢視得到如下內容

clipboard.png
file
(圖二)

前端發起了一個https的Ajax請求,後端返回狀態碼為302,location為http://開頭網址,這樣就造成了混合訪問。本應該有Ajax自動處理的302跳轉就這樣被瀏覽器禁止了。

  1. 什麼是混合內容
    當使用者訪問使用HTTPS的頁面時,他們與web伺服器之間的連線是使用SSL加密的,從而保護連線不受嗅探器和中間人攻擊。

如果HTTPS頁面包括由普通明文HTTP連線加密的內容,那麼連線只是被部分加密:非加密的內容可以被嗅探者入侵,並且可以被中間人攻擊者修改,因此連線不再受到保護。當一個網頁出現這種情況時,它被稱為混合內容頁面。

詳情可見https://developer.mozilla.org...

  1. 為什麼經過後端跳轉後Location由https變為了http。
    我們後端採用Java開發,部署與Tomcat,對於Servlet來說一般採用HttpServletResponse.sendRedirect(String url) 方法實現頁面跳轉(302跳轉)。那麼問題是不是出在這個方法呢?答案是否定的。
    sendRedirect(String url)方法中url引數可以傳入絕對地址和相對地址。我們使用的時候一般傳入相對地址,這樣由方法內部自動轉換為絕對地址也就是返回給瀏覽器中Location引數中的地址,sendRedirect()方法內部會根據當前訪問的scheme來決定拼接後絕對地址的scheme,也就是說如果訪問地址是https開頭那麼跳轉連結的絕對地址也會是https的,http同理。在本次例項中我們傳入的就是相對地址,跳轉連結的絕對路徑地址開頭是由請求地址決定的,也就是後端程式收到的HttpServletRequest請求協議一定是http開頭的。

我們看到(圖二)中地址請求地址是由https開頭的,為什麼到了後端程式後就成為了http請求呢?我們接著往下說。

clipboard.png
file
(圖三)

為了方便說明我畫了一張https配置的架構圖,我們使用Nginx作為反向代理伺服器,上游伺服器使用Tomcat,我們在Nginx層進行Https配置,由Nginx負責處理Https請求。但是Nginx自身處理方式規定向上游伺服器傳送請求的時候是以http的方式請求的。這也就說明了為什麼我們後端程式碼收到的請求是http協議,真想終於大白了。

問題終於明瞭了,接下來就是解決的時候。

1.解決方案1.0

既然經過Nginx代理後Tomcat伺服器執行的程式碼都變成了http請求,然後sendRedirect方法傳入相對地址就會隨著請求地址也變成http。那麼我們不再使用相對地址而使用絕對地址。這樣跳轉地址就全部由我們做主,想跳轉到哪裡就跳轉的哪裡,媽媽再也不用擔心我們跳轉了。

先期改造:

    /**
     * 重新實現sendRedirect。
     * @param request
     * @param response
     * @param url
     * @throws IOException
     */
    public static void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException{
        if(url.startsWith("http://")||url.startsWith("https://")){
            //絕對路徑,直接跳轉。
            response.sendRedirect(url);
            return;
        }
        // 收集請求資訊,為拼接絕對地址做準備。
        String serverName = request.getServerName();
        int port = request.getServerPort();
        String contextPath = request.getContextPath();
        String servletPath = request.getServletPath();
        String queryString = request.getQueryString();

        // 拼接絕對地址
        StringBuilder absoluteUrl = new StringBuilder();
        // 強制使用https
        absoluteUrl.append("https").append("://").append(serverName);
        //80和443位http和https預設介面,無需拼接。
        if (port != 80 && port != 443) {
            absoluteUrl.append(":").append(port);
        }
        if (contextPath != null) {
            absoluteUrl.append(contextPath);
        }
        if (servletPath != null) {
            absoluteUrl.append(servletPath);
        }
        // 將相對地址加入。
        absoluteUrl.append(url);
        if (queryString != null) {
            absoluteUrl.append(queryString);
        }
        // 跳轉到絕對地址。
        response.sendRedirect(absoluteUrl.toString());
    }

我們自己了一個sendRedirect()方法,但是還有一點小小的瑕疵,我們將所有相對地址都轉化成http開頭的絕對地址,對於那些我們即支援https由支援http的網站來說,這樣就不適合了,所以我們需要和前端請求做一個預定,讓前端再發類似於Ajax訪問的時候,自定義一個request的header,告訴我們是https訪問還是http訪問,我們在後端程式碼中判斷這個自定義header,決定程式碼行為。

/**
     * 重新實現sendRedirect。
     * @param request
     * @param response
     * @param url
     * @throws IOException
     */
    public static void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException{

        if(url.startsWith("http://")||url.startsWith("https://")){
            //絕對路徑,直接跳轉。
            response.sendRedirect(url);
            return;
        }

        //假設前端請求頭為http_https_scheme,可以傳入的值有http或https,不傳預設為https。
        if(("http").equals(request.getHeader("http_https_scheme"))){
            //http請求,預設行為。
            response.sendRedirect(url);
            return;
        }

        // 收集請求資訊,為拼接絕對地址做準備。
        String serverName = request.getServerName();
        int port = request.getServerPort();
        String contextPath = request.getContextPath();
        String servletPath = request.getServletPath();
        String queryString = request.getQueryString();

        // 拼接絕對地址
        StringBuilder absoluteUrl = new StringBuilder();
        // 強制使用https
        absoluteUrl.append("https").append("://").append(serverName);
        //80和443位http和https預設介面,無需拼接。
        if (port != 80 && port != 443) {
            absoluteUrl.append(":").append(port);
        }
        if (contextPath != null) {
            absoluteUrl.append(contextPath);
        }
        if (servletPath != null) {
            absoluteUrl.append(servletPath);
        }
        // 將相對地址加入。
        absoluteUrl.append(url);
        if (queryString != null) {
            absoluteUrl.append(queryString);
        }
        // 跳轉到絕對地址。
        response.sendRedirect(absoluteUrl.toString());
    }

以上為改造之後的程式碼,增加了請求頭判斷邏輯。這樣我們的方法就支援http和https混合模式了。

更進一步:

讓我們對上面的程式碼更進一步,其實我們就是對sendRedirect的邏輯重新編排,只不過我們使用的靜態方法的模式,可不可以直接重寫response中的sendRedirect()方法?

/**
 * 重寫sendRedirect方法。
 *
 */
public class HttpsServletResponseWrapper extends HttpServletResponseWrapper {
    private final HttpServletRequest request;

    public HttpsServletResponseWrapper(HttpServletRequest request,HttpServletResponse response) {
        super(response);
        this.request=request;
    }

    @Override
    public void sendRedirect(String location) throws IOException {
        if(location.startsWith("http://")||location.startsWith("https://")){
            //絕對路徑,直接跳轉。
            super.sendRedirect(location);
            return;
        }

        //假設前端請求頭為http_https_scheme,可以傳入的值有http或https,不傳預設為https。
        if(("http").equals(request.getHeader("http_https_scheme"))){
            //http請求,預設行為。
            super.sendRedirect(location);
            return;
        }

        // 收集請求資訊,為拼接絕對地址做準備。
        String serverName = request.getServerName();
        int port = request.getServerPort();
        String contextPath = request.getContextPath();
        String servletPath = request.getServletPath();
        String queryString = request.getQueryString();

        // 拼接絕對地址
        StringBuilder absoluteUrl = new StringBuilder();
        // 強制使用https
        absoluteUrl.append("https").append("://").append(serverName);
        //80和443位http和https預設介面,無需拼接。
        if (port != 80 && port != 443) {
            absoluteUrl.append(":").append(port);
        }
        if (contextPath != null) {
            absoluteUrl.append(contextPath);
        }
        if (servletPath != null) {
            absoluteUrl.append(servletPath);
        }
        // 將相對地址加入。
        absoluteUrl.append(location);
        if (queryString != null) {
            absoluteUrl.append(queryString);
        }
        // 跳轉到絕對地址。
        super.sendRedirect(absoluteUrl.toString());
    }
}

具體邏輯一樣,我們只是繼承了HttpServletResponseWrapper 這個包裝類,在這裡使用了一個觀察者模式重新編寫了sendRedirect()方法邏輯。
我們可以這樣使用我們自定義等HttpsServletResponseWrapper

    public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String location="/login";
        new HttpsServletResponseWrapper(request, response).sendRedirect(location);

    }

再進一步:

既然我們有了新的HttpServletResponseWrapper ,我們在需要的地方手動包裝HttpServletResponse 就顯得有點多餘了。我們可以利用servlet的filter機制來自動包裝。

public class HttpsServletResponseWrapperFilter implements Filter{

    @Override
    public void destroy() {
        // TODO Auto-generated method stub

    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

            chain.doFilter(request, new HttpsServletResponseWrapper((HttpServletRequest)request, (HttpServletResponse)response));

    }

    @Override
    public void init(FilterConfig arg0) throws ServletException {
        // TODO Auto-generated method stub

    }

}

在web.xml中設定filter對映,可以直接使用HttpServletResponse 物件,無需包裝,因為在請求經過HttpsServletResponseWrapperFilter 的時候response已經被包裝為HttpsServletResponseWrapper。

    public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String location="/login";
        response.sendRedirect(location);

    }

至此,我們已經程式碼邏輯無縫的嵌入到我們的後端程式碼中,看上去更優雅了。

2.解決方案2.0

在1.0版本中我們的關注點都是Nginx上游服務中執行的後端程式碼,我們通過對程式碼的改造達到我們的目的。現在我們轉換一下思路,將關注點放在Nginx上,既然是Nginx代理之後,我們的scheme丟失,那麼Nginx有沒有給我們提供一種機制保留代理之後的scheme呢,答案是肯定的。

location / {
    proxy_set_header X-Forwarded-Proto $scheme;
}

一行簡單的配置,就解決了我們的問題,Nginx在代理的時候保留了scheme,這樣我們在跳轉的時候可以直接使用HttpServletResponse.sendRedirect()方法。

通過解決方案1.0的修改程式碼方式和2.0的修改配置方式,我們都解決了問題。在日常開發中解決問題的方式很多,只要你瞭解產生問題的原理,在產生問題的任意環節都可以尋求解決方案。這篇工作記錄就寫到這裡,當然這個問題還有其他的解決方式,如果你有其他的解決方案可以留言告訴我。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章