背景
阿里云HTTPDNS是避免dns劫持的一种有效手段,在许多特殊场景如HTTPS/SNI、okhttp等都有最佳实践,但在webview场景下却一直没完美的解决方案。
拦截方案是目前已知的一种在webview上应用httpdns的可行方案,本文从拦截方案的基本原理出发,尝试分析该方案背后存在的局限,并给出一些可行性上的建议。
基本原理
拦截方案是指通过对webview
进行配置WebViewClient
来做到对网络请求的拦截:
void setWebViewClient (WebViewClient client);
拦截方案的的调用流程如下图所示:
Webview
相关的网络请求由系统的chromium网络库发起,Webview
调用loadUrl
方法时,chromium网络库会构造URLRequest
实例,经过c层到java层,最终请求参数会回调给上层WebViewClient
的shouldInterceptRequest
方法,而我们的目标是在shouldInterceptRequest
方法中通过HTTPDNS进行URL中域名到ip的替换,并且构造和返回合法的WebResourceResponse
,让webview在避免dns劫持的同时,也能正常地进行展示。
局限
首先,当Android API < 21时,WebViewClient
提供的拦截API如下:
public WebResourceResponse shouldInterceptRequest(WebView view, String url);
此时shouldInterceptRequest
只能拿到URL,而请求方法、头部等这些信息是拿不到的,强行拦截会造成请求信息的丢失,由此可知局限1:
局限1:Android API < 21只能拦截网络请求的URL,请求方法、请求头等无法拦截;
其次,对于Android API >= 21的情况,其拦截API为:
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request);
第2个参数变成了WebResourceRequest
,其结构如下:
public interface WebResourceRequest {
Uri getUrl();
boolean isForMainFrame();
boolean isRedirect();
boolean hasGesture();
String getMethod();
Map<String, String> getRequestHeaders();
}
相比之下WebResourceRequest
能给的多了Method
,Header
以及是否重定向
,但是没有Body
,也无法预知该请求是否可能携带body,对于带body的符合协议但非标的Get请求一样无法拦截,因此局限2:
局限2:Android API >= 21无法拦截body,拦截方案只能正常处理不带body的请求;
接下来,我们看下要构造的WebResourceResponse
,这个类需要我们提供MIME
,encoding
和InputStream
。其中,MIME
和encoding
可以通过解析响应头的content-type
来获得:
content-type:text/html;charset=UTF-8
但是,对于js/css/image
等类型的资源请求是没有charset
的,强行拦截会因为编码问题造成js/css/image
的加载/显示异常,因此局限3:
局限3:
MIME
和encoding
可通过解析响应头的content-type
来获得,但有时会拿不到;
可行性
看完了上面的各种局限,是不是拦截方案就完全行不可行呢?其实也不尽然,我们来看下面一段代码:
@SuppressLint("NewApi")
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
final String scheme = request.getUrl().getScheme().trim();
final String url = request.getUrl().toString();
final Map<String, String> headerFields = request.getRequestHeaders();
// #1 只拦截get方法
if (request.getMethod().equalsIgnoreCase("get") && (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) {
try {
final URL oldUrl = new URL(url);
HttpURLConnection conn;
// #2 通过httpdns替换ip
final String ip = mService.getIpByHostAsync(oldUrl.getHost());
if (TextUtils.isEmpty(ip)) {
final String host = oldUrl.getHost();
final String newUrl = url.replaceFirst(host, ip);
// #3 设置HTTP请求头Host域
conn = (HttpURLConnection) new URL(newUrl).openConnection();
conn.setRequestProperty("Host", host);
// #4 设置HTTP请求header
for (String header : headerFields.keySet()) {
conn.setRequestProperty(header, headerFields.get(header));
}
// #5 处理https场景
if (conn instanceof HttpsURLConnection) {
((HttpsURLConnection) conn).setHostnameVerifier(new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session);
}
});
}
// #6 拿到MINE和encoding
final String contentType = conn.getContentType();
final String mine = getMine(contentType);
final String encoding = getEncoding(contentType);
// #7 MINE和encoding拿不到的情况下,不拦截
if (TextUtils.isEmpty(mine) || TextUtils.isEmpty(encoding)) {
return super.shouldInterceptRequest(view, request);
}
return new WebResourceResponse(mine, encoding, conn.getInputStream());
}
} catch (Exception e) {
e.printStackTrace();
}
}
return super.shouldInterceptRequest(view, request);
}
值得注意的是,如果webview中承载的内容是app为自身业务打造的,可控的,那就完全可以通过开发规范来绕开部分局限,也能在一定程度上通过httpdns来改善webview上的dns被劫持的状况。