通過Android SDK接入流程這篇文檔,您已經瞭解了Android SDK匯入、配置、解析IP、應用到網路程式庫和接入驗證的完整流程,本文主要介紹Android WebView情境下接入HTTPDNS的具體方案。
1. 背景說明
阿里雲 HTTPDNS 是一種有效避免 DNS 劫持的手段。在進行網路請求時,可以通過調用 HTTPDNS 提供的 API 來繞過系統預設的 DNS 解析,從而減少或杜絕 DNS 劫持風險。
在 WebView 載入的網頁中,當網頁發起網路請求時,我們可以通過攔截 WebView 內的請求,讓原生層(App)來執行實際的網路請求。在原生層發起網路請求的過程中,即可使用 HTTPDNS 提供的解析結果,替換系統 DNS 解析,以防止 DNS 劫持。
目前 Android 端主流的網路請求庫是 OkHttp,因此本文主要介紹基於 OkHttp 的實現。
本文檔為Android WebView情境下接入HTTPDNS的參考方案,提供的相關代碼也為參考代碼,非線上生產環境直接可用的正式代碼,建議您仔細閱讀本文檔,進行合理評估後再進行接入。
由於Android生態片段化嚴重,各廠商也進行了不同程度的定製,建議您灰階接入,並監控線上異常。如有問題歡迎您通過 支援人員向我們反饋,方便我們及時最佳化。
當前文檔只針對“在 WebView 情境下如何結合 HTTPDNS 解析出的 IP 進行請求”做說明,若需瞭解 HTTPDNS 本身的解析服務,請先查看Android SDK接入流程。
2. WebView只攔截GET請求的限制及影響
在 Android 的 WebView 中,通過 WebViewClient.shouldInterceptRequest() 方法,我們只能擷取到 GET 請求的詳細資料。對於 POST 等其他請求方式,body 並不提供給開發人員,因此無法全面攔截並替換為 HTTPDNS 解析。
然而,這並不代表接入 HTTPDNS 毫無意義,主要原因如下:
絕大部分網頁靜態資源均使用 GET 請求 一般網頁所需的靜態資源(例如圖片、指令碼、樣式表等)幾乎全部通過 GET 請求擷取。通過攔截這些 GET 請求並使用 HTTPDNS,可以覆蓋主要的資源載入過程,大幅降低被劫持的風險。
API 呼叫中也有相當比例使用 GET 不少介面(尤其是查詢類或可被緩衝的介面)會使用 GET 請求。因此攔截這些請求,同樣可通過 HTTPDNS 避免 DNS 劫持。
資源多託管在 CDN,HTTPDNS 提升就近調度 大多數靜態資源通常分發在 CDN 節點上,CDN 的就近接入能力依賴網域名稱解析的準確性。通過 HTTPDNS,可以繞過系統 DNS 劫持,確保網域名稱解析指向最優 CDN 節點,讓使用者獲得更好的訪問速度和體驗。
只攔截 GET 請求也能規避絕大部分劫持情境 DNS 劫持往往針對熱門網域名稱或海量靜態資源網域名稱,而這些網域名稱的訪問幾乎都是 GET 請求。即使目前只能在 GET 請求上使用 HTTPDNS,也能覆蓋絕大多數流量,實現 80% 甚至更高比例的防護。
綜上所述,雖然 WebView 層面目前只能攔截並改寫 GET 請求,但在實際業務中,這種方式通常已足以涵蓋主要流量,並在 CDN 情境下實現更精準的就近調度,從而在安全和效能上都發揮顯著的提升作用。
3. Demo程式碼範例
HTTPDNS+WebView+OkHttp最佳實務完整代碼請參考WebView+HTTPDNS+OkHttp Android Demo。
4. 實現說明
以下步驟展示如何基於 OkHttp 和 WebView 進行 HTTPDNS 接入,並在原生層攔截並處理網頁中發起的請求。
4.1 OkHttp配置
4.1.1 自訂DNS解析
OkHttp提供了介面Dns,可以讓調用者自訂DNS解析。您可以在這個介面中整合HTTPDNS來實現自訂DNS解析,範例程式碼實現如下:
OkHttpClient.Builder()
//自訂dns解析邏輯
.dns(object : Dns {
override fun lookup(hostname: String): List<InetAddress> {
val inetAddresses = mutableListOf<InetAddress>()
HttpDns.getService(accountId)
.getHttpDnsResultForHostSyncNonBlocking(hostname, RequestIpType.auto)?.apply {
if (!ipv6s.isNullOrEmpty()) {
for (i in ipv6s.indices) {
inetAddresses.addAll(
InetAddress.getAllByName(ipv6s[i]).toList()
)
}
} else if (!ips.isNullOrEmpty()) {
for (i in ips.indices) {
inetAddresses.addAll(
InetAddress.getAllByName(ips[i]).toList()
)
}
}
}
if (inetAddresses.isEmpty()) {
inetAddresses.addAll(Dns.SYSTEM.lookup(hostname))
}
return inetAddresses
}
})
.build()
new OkHttpClient.Builder()
//自訂dns解析邏輯實現
.dns(new Dns() {
@NonNull
@Override
public List<InetAddress> lookup(@NonNull String hostname) throws UnknownHostException {
ArrayList<InetAddress> inetAddresses = new ArrayList<>();
HTTPDNSResult result = HttpDns.getService(accountiD)
.getHttpDnsResultForHostSync(hostname, RequestIpType.auto);
if (result != null) {
if (result.getIpv6s() != null && result.getIpv6s().length > 0) {
for (int i = 0; i < result.getIpv6s().length; i++) {
InetAddress[] ipV6InetAddresses = InetAddress.getAllByName(result.getIpv6s()[i]);
inetAddresses.addAll(Arrays.asList(ipV6InetAddresses));
}
} else if (result.getIps() != null && result.getIps().length > 0) {
for (int i = 0; i < result.getIps().length; i++) {
InetAddress[] ipV4InetAddresses = InetAddress.getAllByName(result.getIps()[i]);
inetAddresses.addAll(Arrays.asList(ipV4InetAddresses));
}
}
}
if (inetAddresses.isEmpty()) {
inetAddresses.addAll(Dns.SYSTEM.lookup(hostname));
}
return inetAddresses;
}
})
.build();建議在HTTPDNS網域名稱解析失敗的情況下,使用Local DNS作為網域名稱解析的兜底邏輯。
4.1.2 禁用重新導向
本方案在網路層不處理重新導向,由 WebView 按系統預設行為自行處理,以避免重新導向情境下 Cookie、資源載入等異常,範例程式碼實現如下:
OkHttpClient.Builder()
.followRedirects(false) // 禁用HTTP重新導向
.followSslRedirects(false) // 禁用HTTPS重新導向
.build()new OkHttpClient.Builder()
.followRedirects(false) // 禁用HTTP重新導向
.followSslRedirects(false) // 禁用HTTPS重新導向
.build();在不可信網路環境中,重新導向請求仍可能被異常篡改,業務側需做好安全防護。
4.2 WebView攔截網路請求
4.2.1 實現WebViewClient
WebView 提供了 WebViewClient 介面,重寫 shouldInterceptRequest() 方法即可攔截網頁的資源載入請求。通過此方法,可將網頁發起的請求改為使用 原生層 (OkHttp) 來請求資料,然後再將結果返回給 WebView。範例程式碼實現如下:
webview.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
if (shouldIntercept(request)) {
return getResponseByOkHttp(request)
}
return super.shouldInterceptRequest(view, request)
}
}webview.setWebViewClient(new WebViewClient(){
@Nullable
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
if (shouldIntercept(request)) {
return getResponseByOkHttp(request);
}
return super.shouldInterceptRequest(view, request);
}
});4.2.2 判斷能否攔截請求
在 shouldIntercept() 方法中,只有以下情況才做攔截:
只攔截
http或https協議的請求。其他協議不攔截,仍然由WebView自己處理.只攔截
GET請求(原因詳見上文:“WebView只攔截GET請求的限制及影響”)。
範例程式碼如下:
private fun shouldIntercept(webResourceRequest: WebResourceRequest?): Boolean {
if (webResourceRequest == null) {
return false
}
val url = webResourceRequest.url ?: return false
//非http協議不攔截
if ("https" != url.scheme && "http" != url.scheme) {
return false
}
//只攔截GET請求
if ("GET".equals(webResourceRequest.method, true)) {
return true
}
return false
}private boolean shouldIntercept(WebResourceRequest request) {
if (request == null || request.getUrl() == null) {
return false;
}
//非http協議不攔截
if (!"http".equals(request.getUrl().getScheme()) && !"https".equals(request.getUrl().getScheme())) {
return false;
}
//只攔截GET請求
if ("GET".equalsIgnoreCase(request.getMethod())) {
return true;
}
return false;
}4.2.3 使用OkHttp進行網路請求
使用 OkHttp 根據請求 URL、Header 等資訊發起網路請求。
將響應結果通過
WebResourceResponse封裝後返回給 WebView。
範例程式碼實現如下:
private fun getResponseByOkHttp(webResourceRequest: WebResourceRequest?): WebResourceResponse? {
if (webResourceRequest == null) {return null}
try {
val url = webResourceRequest.url.toString()
val requestBuilder =
Request.Builder().url(url).method(webResourceRequest.method, null)
val requestHeaders = webResourceRequest.requestHeaders
if (!requestHeaders.isNullOrEmpty()) {
requestHeaders.forEach {
requestBuilder.addHeader(it.key, it.value)
}
}
val response = okHttpClient.newCall(requestBuilder.build()).execute()
val code = response.code
if (code != 200) {
return null
}
val body = response.body
if (body != null) {
val contentType = body.contentType()
val encoding = contentType?.charset()
val mediaType = contentType?.toString()
var mimeType = "text/plain"
if (!TextUtils.isEmpty(mediaType)) {
val mediaTypeElements = mediaType?.split(";")
if (!mediaTypeElements.isNullOrEmpty()) {
mimeType = mediaTypeElements[0]
}
}
val responseHeaders = mutableMapOf<String, String>()
for (header in response.headers) {
responseHeaders[header.first] = header.second
}
var message = response.message
if (message.isBlank()) {
message = "OK"
}
val resourceResponse =
WebResourceResponse(mimeType, encoding?.name(), body.byteStream())
resourceResponse.responseHeaders = responseHeaders
resourceResponse.setStatusCodeAndReasonPhrase(code, message)
return resourceResponse
}
} catch (e: Throwable) {
e.printStackTrace()
}
return null
}private WebResourceResponse getResponseByOkHttp(WebResourceRequest request) {
try {
String url = request.getUrl().toString();
Request.Builder requestBuilder = new Request.Builder()
.url(url)
.method(request.getMethod(), null);
Map<String, String> requestHeaders = request.getRequestHeaders();
if (requestHeaders != null) {
for (Map.Entry<String, String> entry : requestHeaders.entrySet()) {
requestBuilder.addHeader(entry.getKey(), entry.getValue());
}
}
Response response = okHttpClient.newCall(requestBuilder.build()).execute();
if (200 != response.code()) {
return null;
}
ResponseBody body = response.body();
if (body != null) {
MediaType contentType = body.contentType();
if (contentType != null) {
Charset encoding = contentType.charset();
String mediaType = contentType.toString();
String mimeType = "text/plain";
if (!TextUtils.isEmpty(mediaType)) {
String[] mediaTypeElements = mediaType.split(";");
if (mediaTypeElements.length > 0) {
mimeType = mediaTypeElements[0];
}
}
Map<String, String> responseHeaders = new HashMap<>();
for (String key : response.headers().names()) {
responseHeaders.put(key, response.header(key));
}
String message = response.message();
if (TextUtils.isEmpty(message)) {
message = "OK";
}
WebResourceResponse resourceResponse = new WebResourceResponse(mimeType, encoding.name(), body.byteStream());
resourceResponse.setResponseHeaders(responseHeaders);
resourceResponse.setStatusCodeAndReasonPhrase(response.code(), message);
return resourceResponse;
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}4. 驗證
接入完成後,請參考驗證網路程式庫驗證成功文檔,通過劫持類比或錯誤注射測試等方式,驗證整合是否成功。
5. 總結
HTTPDNS 可以有效降低 DNS 劫持風險,在 WebView 中通過攔截請求並使用原生網路程式庫(OkHttp)再結合 HTTPDNS,能顯著提升網域名稱解析的安全性。
因 WebView 機制所限,只能在 GET 請求上實現攔截,但這對於載入靜態資源、常用 GET 介面等已覆蓋大部分情境。
強烈建議 開發人員在實際專案中灰階接入,關注線上異常與適配情況,並根據業務需要做進一步最佳化或定製化處理。
如有更多疑問或需要支援人員,歡迎通過 支援人員 反饋給我們。我們將持續最佳化 HTTPDNS 的接入體驗和相容性。