背景
最近一段时间,公司的安全部门在推动业务集中治理安全风险问题,过程中我们遇到一个挺有意思的问题,在此记录和大家分享一下。
我们有一张对外开放的、无需登录的页面,给商家或运营提供地址查询服务,其上线时间距今有一定历史了,业务和开发都轮换了一圈,我们废了点周折才了解业务的原委。页面中引用了一个第三方 SDK 来提供地址服务,且需要通过一个按量收费的 API 密钥进行使用。
问题是这样的,早期的技术方案没有考虑到安全性,直接将 API 密钥传入 SDK,而 SDK 内部会发起 JSONP 请求并同时带上这个密钥信息,如:xxx?key=xxxx,由此可能会带来密钥暴露的风险。
浅析
那么如何保障 API 密钥安全呢?
首先,查看 SDK 的官方文档,官方推荐使用代理服务器的方式来保护 API 密钥,即通过代理服务与第三方服务交互,由代理服务再做一些加密解密的事情。
其次,我们请后端同学帮忙搞定代理服务,接下来如何将 SDK 的内置 JSONP 请求替换成后端的代理服务请求呢?一般就有以下两种办法:****
- 通过 SDK 配置修改。 我们查看了 SDK 文档,似乎没有找到可以配置请求 API 的地方,最后通过翻阅源码发现了可以通过修改 API 配置来替换为代理服务的接口。
- 通过魔改 SDK 代码。魔改 SDK 代码把内部 JSONP 请求都修改成代理服务的 API,但实际发出的请求不止一个,这么做不仅容易遗漏,而且还不利于后期的维护和升级。
- 通过 HTTP 请求拦截。 虽然实际发出的 JSONP 请求不止一个,但所幸域名前缀是一致的,我们可以通过在页面中注入 HTTP 请求拦截代码,把问题相关的第三方请求的域名前缀替换成代理服务 API 的域名前缀。
最后,对比分析显然第 1 种方式更合适一些,也是我们实际采用的方案。
不过,你不觉得第 3 种方案其实挺有趣的嘛,有趣的问题是,如果第三方的 SDK 没有提供配置能力,那么我们又如何去实现全局拦截 JSONP 请求呢?我们接着往下看。。
深究
首先,我们来回顾一下,前端发起 HTTP 请求有哪几种方式?
聪明如你肯定能立马想到:xhr、fetch、jsonp这些核心科技,又能即刻操起axios“码走龙蛇”。但是,请等一下,我们躲在axios这些高阶库身后久矣,现在还能分清楚xhr、fetch、jsonp的特点、区别和联系吗?
Ajax、xhr、fetch、jsonp 特性与联系
Ajax
我们必须先介绍一下鼎鼎大名的 Ajax,诞生于 2005 年,全称是 Asynchronous JavaScript and XML,意为异步 JavaScript 和 XML,是一种将一些现有技术结合起来的一种 Web 应用架构,包括:HTML 或 XHTML、CSS、JavaScript、DOM、XML、XSLT、以及最重要的XMLHttpRequest对象。
异步是 Ajax 的核心要义,异步意味着应用程序可以与网络服务器交换数据并更新部分网页内容,而无须重新加载整个页面!正是 Ajax 的问世才开启了 Web2.0 蓬勃发展的大门。
xhr
xhr最早被应用于 1999 年发布的 IE5 中,随后所有主流的浏览器都引入了该特性。xhr全称是XMLHttpRequest,XMLHttpRequest 是 Ajax 中最重要的对象,正是它让我们可以使用 JavaScript 向服务器发起 GET 或 POST 请求,并处理响应而不阻塞用户的其他操作。
function request(url = '', data = {}) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
console.log(this.responseText);
}
};
xhr.open("POST", url, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send(data);
}
另一方面,虽然xhr解决了当时很大的痛点问题,增强了 JavaScript 在浏览器上操作 HTTP 的能力,但是它毕竟太底层了,以至于我们不得不通过封装的网络库来使用它,如社区流行已久的axios。
fetch
为了提供一种更加现代化、灵活的方式来处理网络请求,基于 Promise 的方式设计的 Fetch API 应运而生,又恰逢 ES6 的标准化进程加快,Fetch API 也被迅速推广开来。
相较于传统的xhr,Fetch API 返回的是 Promise 对象,解决了使用xhr时需要设置多个事件监听器的问题,使代码更加简洁易读。
async function request(url = '', data = {}) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
return response.json();
}
主流浏览器都天然支持了 Fetch API,可以直接通过window.fetch使用,目前兼容性也比较乐观,特别情况如果你需要比较兼容 IE 低版本可以考虑使用 polyfill,如 whatwg-fetch。
jsonp
众所周知,Ajax 受同源策略约束是禁止跨域的,而 JSONP(JSON with Padding) 就是解决跨域问题的经典方法之一。不同于xhr和fetch的是,前两者都是 HTTP 请求方法,而 JSONP 是一种跨域数据交互协议,JSONP 允许在不同域之间请求数据,但只支持 GET 请求。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>JSONP 示例</title>
</head>
<body>
<div id="divCustomers"></div>
<script type="text/javascript">
function callbackFunction(result) {
var html = '<ul>';
for(var i = 0; i < result.length; i++) {
html += '<li>' + result[i] + '</li>';
}
html += '</ul>';
document.getElementById('divCustomers').innerHTML = html;
}
</script>
<script type="text/javascript" src="https://www.runoob.com/try/ajax/jsonp.php?jsoncallback=callbackFunction"></script>
</body>
</html>
如果觉得手撸 JSONP 代码比较繁琐,也可以使用社区开源库,如:jsonp。
综上所述,我们认为 Ajax 是一种支持网页内容异步更新的 Web 应用架构,Ajax 架构下包括了xhr和 Fetch API 两种 HTTP 请求方法,而jsonp一种跨域数据交互协议,不过也可以用来实现 HTTP 请求。它们之间的关系如下:
HTTP 请求劫持的几种方式
Monkey patching in javascript
在浏览器环境中,不管是xhr还是fetch本质来说都是 JavaScript 对象,那么可以用 monkey patching) 的思路 来拦截和改写对象的属性或方法,进而实现全局 HTTP 请求劫持。而在 JavaScript 中拦截和改写对象的属性或方法,具体对应到实现层面,通常可以是 ES5 的 defineProperty 方法或 ES6 的 Proxy 方法。
xhr
整体思路是:
- 首先,实现一个新的 XMLHttpRequest 代理对象;
- 然后,在请求发送前和请求返回后进行一些预处理;
- 最后,用代理 XMLHttpRequest 对象覆盖全局的原始的 XMLHttpRequest 对象。
这样,在页面中调用new XMLHttpRequest时,其实创建的是我们代理对象实例。
其实对于 XMLHttpRequest 的拦截,社区已经有一些比较成熟的实现方案,这里我们以 Ajax-hook 举例,其使用方法如下:
import { proxy } from "ajax-hook";
const { unProxy, originXhr } = = proxy({
//请求发起前进入
onRequest: (config, handler) => {
console.log(config.url)
handler.next(config);
},
//请求发生错误时进入,比如超时;注意,不包括http状态码错误,如404仍然会认为请求成功
onError: (err, handler) => {
console.log(err.type)
handler.next(err)
},
//请求成功后进入
onResponse: (response, handler) => {
console.log(response.response)
handler.next(response)
}
})
// 取消拦截
unProxy();
Ajax-hook 内部经过了封装后对外暴露了易于理解的钩子:onRequest、onError、onResponse,其内部也还是会转换成对xhr的open、send等方法的代理。
win.XMLHttpRequest = function () {
// We shouldn't hookAjax XMLHttpRequest.prototype because we can't
// guarantee that all attributes are on the prototype。
// Instead, hooking XMLHttpRequest instance can avoid this problem.
var xhr = new originXhr();
// Generate all callbacks(eg. onload) are enumerable (not undefined).
for (var i = 0; i < events.length; ++i) {
var key='on'+events[i];
if (xhr[key] === undefined) xhr[key] = null;
}
for (var attr in xhr) {
var type = "";
try {
type = typeof xhr[attr] // May cause exception on some browser
} catch (e) {
}
if (type === "function") {
// hookAjax methods of xhr, such as `open`、`send` ...
this[attr] = hookFunction(attr); // 这里代理 xhr 方法(笔者注)
} else if (attr !== OriginXhr) {
Object.defineProperty(this, attr, { // 这里代理 xhr 属性(笔者注)
get: getterFactory(attr),
set: setterFactory(attr),
enumerable: true
})
}
}
var that = this;
xhr.getProxy = function () {
return that
}
this[OriginXhr] = xhr;
}
// Hook methods of xhr.
function hookFunction(fun) {
return function () {
var args = [].slice.call(arguments);
if (proxy[fun]) {
var ret = proxy[fun].call(this, args, this[OriginXhr])
// If the proxy return value exists, return it directly,
// otherwise call the function of xhr.
if (ret) return ret;
}
return this[OriginXhr][fun].apply(this[OriginXhr], args);
}
}
总之,Ajax-hook 通过 ES5 getter 和 setter 特性实现对 XMLHttpRequest 对象的代理,大致设计思路如下图所示:
更多详情: Ajax-hook 原理解析
fetch
同样地,运用代理的思路也很容易实现fetch的拦截,大致代码如下:
window.fetch = async (...args) => {
let [resource, config ] = args;
// 1 request interceptor here
const response = await originalFetch(resource, config);
// 2 response interceptor here
return response;
};
考虑到易用性和健壮性,也可以使用社区成熟方案,这里以 fetch-intercept 举例,其使用方法如下:
import fetchIntercept from 'fetch-intercept';
const unregister = fetchIntercept.register({
request: function (url, config) {
// Modify the url or config here
return [url, config];
},
requestError: function (error) {
// Called when an error occured during another 'request' interceptor call
return Promise.reject(error);
},
response: function (response) {
// Modify the reponse object
return response;
},
responseError: function (error) {
// Handle an fetch error
return Promise.reject(error);
}
});
// Call fetch to see your interceptors in action.
fetch('http://google.com');
// Unregister your interceptor
unregister();
jsonp
前文有提到 JSONP 是一种跨域数据交互协议,并不是一种 JavaScript 对象,所以代理 JavaScript 对象的方式就不适用了。那么 JSONP 请求如何实现全局拦截呢?
JSONP 最常见的实现方案是动态创建 script 标签的形式,大体流程是:
- 首先,动态创建一个 script 标签并添加到 HTML 文档上,而这个 script 标签的
src会指向一个后端接口,形如:xxx?callback=xxx; - 然后,浏览器请求后端后拿到返回数据,形如:
callback(${data})的 JS 代码; - 最后,浏览器中执行返回的 JS 代码并完成业务逻辑。
那么对于这种动态创建 script 标签的 JSONP 实现方案,我们就可以通过拦截 document 创建标签、赋值 src 的操作来实现请求拦截,示例代码如下:
(function () {
var originalCreateElement = document.createElement
function proxy(dom) {
var src
Object.defineProperty(dom, 'src', {
get: function () {
return src
},
set: function (newVal) {
src = newVal
dom.setAttribute('src', newVal)
}
})
var originalSetAttribute = dom.setAttribute
dom.setAttribute = function () {
var args = Array.prototype.slice.call(arguments)
var key = args[0]
var val = args[1]
// 根据val即url地址上是否存在callback=回调函数字样粗略的判断,是否请求的是JSONP的调用,但是此方法不是很可靠,因为像jquery里面的JSONP实现是可以指定callback的命名的。
if (key === 'src' && val.includes('callback=')) {
let url = val
console.log('请求地址:', url)
// 在调用之前做一些事情,比如修改url
originalSetAttribute.apply(dom, [key, url])
}
else {
originalSetAttribute.apply(dom, args)
}
}
}
// 重写创建节点函数
document.createElement = function (tagName) {
var dom = originalCreateElement.call(document, tagName)
dom.onload = function (evt) {
console.log("onload:", evt)
}
dom.onerror = function (err) {
console.log("onerror:", err)
}
if(tagName.toLowerCase() === 'script'){
proxy(dom)
}
return dom
}
})()
这个方法仅适用于动态创建 script 标签的 JSONP 实现,而对于非动态创建 script 标签的 JSONP 实现则暂时没有发现好的办法可以进行拦截。。
注:如果读者有比较好的方法,欢迎留言指点,谢谢!
Service Worker
众所周知,在 Service Worker 中当页面发起 HTTP 请求时 fetch 事件会被触发,而xhr和fetch本质都是 HTTP 请求,可以借助 Service Worker 通过监听 fetch 事件实现全局 HTTP 请求拦截。
- 首先,需要在页面中注册 Service Worker。
// 在页面中注册 Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('Service Worker registered successfully:', registration);
})
.catch(error => {
console.log('Service Worker registration failed:', error);
});
}
- 其次,在
service-worker.js文件中预处理 HTTP 请求。
// 在 service-worker.js 文件中
self.addEventListener('fetch', event => {
// 检查请求的 URL
if (event.request.url.includes('/my-api/')) {
// 修改请求
const newRequest = new Request('/my-api/modified-endpoint', {
method: 'POST',
body: JSON.stringify({ data: 'new data' })
});
// 修改响应
event.respondWith(
fetch(newRequest)
.then(response => response.json())
.then(data => {
data.modified = true;
return new Response(JSON.stringify(data));
})
);
}
});
上述例子中,首先,我们在事件处理函数中检查请求的 URL 是否包含/my-api/,如果是则表示我们需要拦截这个请求。然后,我们创建了一个新的 Request 对象来修改原始请求,我们指定了新的 URL 和请求方法,并添加了新的请求体。最后,我们使用event.respondWith方法来拦截请求并返回自定义响应,这里甚至可以重新发起一个全新的请求。
小结
综上所述,我们介绍了Ajax、xhr、fetch、jsonp的特性与联系,并演示了 Monkey patching 对象代理和 Service Worker 两种实现 HTTP 全局请求拦截的方式,希望对你有所帮助。
在业务开发中,如果你遇到一些需要全局 HTTP 请求拦截的需求,比如:
- 统一检测第三方代码发送的请求是否合规,对不合规请求进行拦截;
- 统一进行接口加固改造,对请求添加额外参数,对返回值添加错误处理;
- 统一进行埋点统计或者性能监控。
那么不妨试试文中的几种方法。
注:由于笔者功力有限,本文仍然可能存在理解纰漏,欢迎留言指正。 欢迎关注笔者的语雀数字空间,工作之余一起探讨和学习。