前言:一种被遗忘的请求方式
在浏览器同源策略主导的早期Web开发中,JSONP(JSON with Padding)曾是一把突破疆界的密钥。它通过动态创建<script>标签的取巧方式,绕过跨域限制实现数据交互——客户端定义回调函数,服务端将数据包裹于函数调用的语法结构中,这种"脚本装载"机制支撑了无数异步数据请求场景。
尽管现代Web已普遍采用CORS协议作为跨域解决方案,但JSONP仍未完全退出历史舞台。在维护遗留系统或兼容特定低版本浏览器时,开发者仍可能被迫启用这种缺乏安全防护的请求模式。其本质缺陷(如隐式的脚本执行逻辑、缺失的请求头控制)不仅可能引发XSS攻击,更会形成难以监控的数据泄露通道。
本文聚焦于JSONP请求的全链路拦截技术,从动态脚本注入的特征识别,到请求响应内容的解析重构,系统探讨如何通过代理层拦截、函数劫持、内容过滤等工程化手段。
需求背景:JSONP拦截的特殊性解析
假设我们有着这样的一个需求,对某个网站的数据请求进行监听,并对响应的数据进行修改,该网站是通过 JSONP 完成数据传输
相较于主流的XHR(XMLHttpRequest)和 Fetch API 请求,JSONP的实现机制存在本质差异,这直接导致传统拦截方法失效。以下从技术实现维度揭示核心区别:
- 协议层工作原理对比
- XHR/Fetch:通过浏览器内置API发起请求,支持POST/GET等多种方法,受同源策略限制但可通过CORS协议解除。请求过程可被
open()、send()等方法暴露,易于通过重写原型方法实现拦截。 - JSONP:利用
<script>标签的跨域加载特性,通过GET请求注入动态脚本(如<script src="https://api.com/data?callback=handleResponse">)。响应内容直接作为JS代码执行(如handleResponse({data:1})),整个过程完全脱离XHR请求栈。
- 通信过程差异
// XHR/Fetch 显式通信流程
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.com/data');
xhr.onload = () => console.log(xhr.responseText);
xhr.send();
// JSONP 隐式通信流程
function handleResponse(data) { console.log(data) }
const script = document.createElement('script');
script.src = 'https://api.com/data?callback=handleResponse';
document.body.appendChild(script); // 响应自动执行handleResponse
- 安全管控缺口
- XHR/Fetch:受CORS策略保护,可通过
Access-Control-Allow-Origin等响应头控制权限,支持自定义请求头及CSRF令牌校验 - JSONP:无跨域限制但完全开放,回调函数名通过URL参数暴露,易被篡改为恶意函数(如
?callback=alert(1)//),响应内容直接执行无沙箱隔离
- 拦截技术难点
传统方案通过劫持XMLHttpRequest.send或window.fetch实现监听,但JSONP存在两大拦截盲区:
① 请求触发层:动态创建的<script>标签无法通过XHR监控体系捕获
② 响应执行层:回调函数在全局作用域立即执行,敏感数据在拦截前可能已完成处理
理解这些底层差异,是构建有效JSONP拦截方案的技术前提。下文将聚焦如何通过脚本加载追踪、函数执行代理等关键技术破解这些难题。
完整代码
ps: 大佬可跳过后续的代码解析和扩展
(originalCreateElement => {
document.createElement = function (tagName) {
if (tagName.toLowerCase() !== "script") return originalCreateElement.call(document, tagName);
const script = originalCreateElement.call(document, tagName);
const descriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, "src");
Object.defineProperty(script, "src", {
...descriptor,
set: function (value) {
const reg = /\callback=([^&]+)/i;
const callbackName = value.match(reg)?.at(1) || "";
const modifiedUrl = value.replace(reg, `&callback=custom_${callbackName}`);
window[`custom_${callbackName}`] = data => {
// ...处理逻辑
window[callbackName](data);
};
return descriptor.set.call(this, modifiedUrl);
},
});
return script;
};
})(document.createElement);
步骤分析
JSONP 通信机制通过document.createElement动态创建<script>标签并设置src属性发起跨域请求(请求初始化阶段),服务端返回预置回调函数调用的JavaScript代码(如mtopjsonp({ data: 1 })),该脚本加载后立即在全局作用域触发函数执行(响应执行阶段)
- 重写
document.createElement方法,在保留原生功能的基础上注入拦截逻辑:
const originalCreateElement = document.createElement;
document.createElement = function(tagName) {
// 仅处理script元素创建
if (tagName.toLowerCase() !== "script") return originalCreateElement.call(document, tagName);
// 创建原生script元素
const script = originalCreateElement.call(document, tagName);
// 后续注入逻辑...
return script;
};
- 利用属性描述符(Property Descriptor)劫持
src属性赋值过程,实现请求特征提取与参数重定向
const descriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, "src");
Object.defineProperty(script, "src", {
...descriptor,
set: function (value) {
// 1. 提取出回调函数名
const reg = /\callback=([^&]+)/i;
const callbackName = value.match(reg)?.at(1) || "";
// 2. 替换掉 url 中的回调函数名,指向我们自定义的函数
const modifiedUrl = value.replace(reg, `&callback=custom_${callbackName}`);
// 3. 声明自定义的函数
window[`custom_${callbackName}`] = data => {
// 这里的 data 为响应的数据,可在这里进行处理
// 4. 调用原始回调函数
window[callbackName](data);
};
return descriptor.set.call(this, modifiedUrl);
},
});
注意事项
- 属性描述符精确处理:
采用Object.getOwnPropertyDescriptor获取HTMLScriptElement原型链上src属性的完整访问器描述符(Accessor Descriptor),通过...descriptor展开运算符保留原生属性的configurable、enumerable、getter/setter等完整特性。此操作可避免因直接定义属性导致的访问器属性降级为数据属性(Data Property),从而规避以下潜在问题:- 破坏浏览器引擎对
<script>元素的原生属性处理逻辑 - 丢失原型链上的动态属性更新机制
- 规避因属性可枚举性(enumerable)改变导致的兼容性问题
- 破坏浏览器引擎对
- 原生执行链路保障:
通过descriptor.set.call(this, modifiedUrl)显式调用原始setter方法,此操作严格遵循以下设计原则- 原型链完整性:确保浏览器内部属性赋值流程正常执行(包括网络请求触发、资源加载状态变更等原生行为)
- 执行上下文绑定:通过
call(this)保持setter方法在目标<script>元素实例上的正确作用域 - 副作用隔离:在自定义拦截逻辑执行后,仍按标准流程完成
src属性赋值,避免因绕过原生逻辑导致的资源加载异常或内存泄漏
扩展
在真实的开发中,我们肯定会处理多个接口,并且过滤掉那些不需要的接口,如何去优化我们的代码呢?
使用发布订阅模式将请求拦截与数据处理之间解耦,并加入一个白名单
// 一个基础的发布订阅
const events = {};
const subscribe = (event, handler) => {
!events[event] && (events[event] = []);
events[event].push(handler);
};
const publish = (event, ...args) => {
events[event] && events[event].forEach(handler => handler(...args));
};
subscribe("接口a的标识", ({ data, name }) => { // 接口 a 的数据处理 });
subscribe("接口b的标识", ({ data, name }) => { // 接口 b 的数据处理 });
subscribe("接口c的标识", ({ data, name }) => { // 接口 c 的数据处理 });
(originalCreateElement => {
document.createElement = function (tagName) {
if (tagName.toLowerCase() !== "script") return originalCreateElement.call(document, tagName);
const script = originalCreateElement.call(document, tagName);
const descriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, "src");
Object.defineProperty(script, "src", {
...descriptor,
set: function (value) {
// 1. 巧妙的运用 events 对象中的 key 做一个白名单列表
const interface = Object.keys(events);
// 2. 从 url 中提取出一个唯一的标识,这里以回调函数名作为那个唯一标识
const reg = /\callback=([^&]+)/i;
const callbackName = value.match(reg)?.at(1) || "";
// 3. 检测是否在白名单里面
if (!callbackName || !interface.includes(callbackName)) return descriptor.set.call(this, value);
const modifiedUrl = value.replace(reg, `&callback=custom_${callbackName}`);
window[`custom_${callbackName}`] = data => {
// 4. 根据唯一的 callbackName 去执行不用的处理逻辑
publish(callbackName, { data, name: callbackName });
window[callbackName](data); // 调用原始回调
};
return descriptor.set.call(this, modifiedUrl);
},
});
return script;
};
})(document.createElement);
写在最后
上面我们讲解了如何对 JSONP 的请求进行拦截,并结合真实的开发场景对代码进行了优化,最后我们只需要将整个JS脚本注入到特定的网站中,既可实现对网站的数据请求进行拦截。XHR和Fetch 的请求拦截也是相同的原理。
什么,你问怎么在网站中注入我们自己的脚本?
...未完待续