JSONP请求拦截

251 阅读6分钟

前言:一种被遗忘的请求方式

在浏览器同源策略主导的早期Web开发中,JSONP(JSON with Padding)曾是一把突破疆界的密钥。它通过动态创建<script>标签的取巧方式,绕过跨域限制实现数据交互——客户端定义回调函数,服务端将数据包裹于函数调用的语法结构中,这种"脚本装载"机制支撑了无数异步数据请求场景。
尽管现代Web已普遍采用CORS协议作为跨域解决方案,但JSONP仍未完全退出历史舞台。在维护遗留系统或兼容特定低版本浏览器时,开发者仍可能被迫启用这种缺乏安全防护的请求模式。其本质缺陷(如隐式的脚本执行逻辑、缺失的请求头控制)不仅可能引发XSS攻击,更会形成难以监控的数据泄露通道。
本文聚焦于JSONP请求的全链路拦截技术,从动态脚本注入的特征识别,到请求响应内容的解析重构,系统探讨如何通过代理层拦截、函数劫持、内容过滤等工程化手段。

需求背景:JSONP拦截的特殊性解析

假设我们有着这样的一个需求,对某个网站的数据请求进行监听,并对响应的数据进行修改,该网站是通过 JSONP 完成数据传输

相较于主流的XHR(XMLHttpRequest)和 Fetch API 请求,JSONP的实现机制存在本质差异,这直接导致传统拦截方法失效。以下从技术实现维度揭示核心区别:

  1. 协议层工作原理对比
  • XHR/Fetch:通过浏览器内置API发起请求,支持POST/GET等多种方法,受同源策略限制但可通过CORS协议解除。请求过程可被open()send()等方法暴露,易于通过重写原型方法实现拦截。
  • JSONP:利用<script>标签的跨域加载特性,通过GET请求注入动态脚本(如<script src="https://api.com/data?callback=handleResponse">)。响应内容直接作为JS代码执行(如handleResponse({data:1})),整个过程完全脱离XHR请求栈。
  1. 通信过程差异
// 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
  1. 安全管控缺口
  • XHR/Fetch:受CORS策略保护,可通过Access-Control-Allow-Origin等响应头控制权限,支持自定义请求头及CSRF令牌校验
  • JSONP:无跨域限制但完全开放,回调函数名通过URL参数暴露,易被篡改为恶意函数(如?callback=alert(1)//),响应内容直接执行无沙箱隔离
  1. 拦截技术难点
    传统方案通过劫持XMLHttpRequest.sendwindow.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 })),该脚本加载后立即在全局作用域触发函数执行(响应执行阶段)

  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;
};
  1. 利用属性描述符(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);
  },
});

注意事项

  1. 属性描述符精确处理
    采用Object.getOwnPropertyDescriptor获取HTMLScriptElement原型链上src属性的完整访问器描述符(Accessor Descriptor),通过...descriptor展开运算符保留原生属性的configurableenumerablegetter/setter等完整特性。此操作可避免因直接定义属性导致的访问器属性降级为数据属性(Data Property),从而规避以下潜在问题:
    • 破坏浏览器引擎对<script>元素的原生属性处理逻辑
    • 丢失原型链上的动态属性更新机制
    • 规避因属性可枚举性(enumerable)改变导致的兼容性问题
  2. 原生执行链路保障
    通过descriptor.set.call(this, modifiedUrl)显式调用原始setter方法,此操作严格遵循以下设计原则
    1. 原型链完整性:确保浏览器内部属性赋值流程正常执行(包括网络请求触发、资源加载状态变更等原生行为)
    2. 执行上下文绑定:通过call(this)保持setter方法在目标<script>元素实例上的正确作用域
    3. 副作用隔离:在自定义拦截逻辑执行后,仍按标准流程完成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 的请求拦截也是相同的原理。

什么,你问怎么在网站中注入我们自己的脚本?

...未完待续