ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CORS诞生前夜:JSONP与图片探测的跨域原理与实战

CORS诞生前夜:JSONP与图片探测的跨域原理与实战 这问题问到我心坎里了。我大概是2012年入行做前端那会儿要是谁在控制台里看到XMLHttpRequest cannot load ... No Access-Control-Allow-Origin header is present第一反应不是上网搜而是先看看自己是不是穿越了——因为那年代你压根儿别指望后端给你加这个头更别说什么CORS预检。整个行业都在用一套野路子解决跨域说白了就是“绑架”浏览器天生的漏洞去偷数据。今天这篇就专门聊聊CORS出生之前的疯狂岁月把JSONP、图片探测这些老古董从原理到实战、从坑到血泪一次讲透。先给年轻朋友一个时间坐标CORS其实早在2009年左右就有规范草案了但真正被浏览器广泛支持、被后端团队普遍接受那得等到2015年前后。而在这漫长的六七年里中国互联网正处于PC端向移动端迁移的爆发期各种站长工具、广告统计、开放平台API多如牛毛。前端要拿第三方数据后端又不愿意动配置怎么办那就只能靠script和img这两个从HTML诞生起就存在的“合法后门”。1. 浏览器把同源锁得死死的但故意留了两扇门先聊一个基础问题浏览器为什么死活不让跨域因为安全。CSRF跨站请求伪造攻击太猖獗了你登录着银行网站又打开一个恶意页面恶意页面向银行发个请求浏览器一看cookie还在就把钱转走了。为了防这事儿浏览器定了个规矩——协议、域名、端口只要有一个不一样就不允许JS代码读写对方的数据。这个规矩就是同源策略Same-Origin Policy。同源策略锁的是XMLHttpRequest和fetch这类能“读写”数据的API。但浏览器有个历史包袱没法甩掉那就是script、img、iframe、link这类带src属性的标签天然就允许跨域加载。不信你回想一下当年谁家的网页不放一个百度统计脚本、放一个新浪天气插件如果连外部JS都不让加载整个互联网就瘫了。所以浏览器设计者留了这么两扇“门缝”一扇是script标签可以执行跨域返回的JavaScript另一扇是img标签可以加载跨域的图片文件并在请求里带上参数响应体虽然拿不到但服务端能收到请求。就是这两扇门缝让一代前端工程师活生生琢磨出了一套能“偷数据”的方案。那会儿我们管这叫“钻空子”严格来说不算浏览器漏洞而是规范里没写死、又没法禁用的特性。这里有个关键点值得新入行的朋友记住跨域限制针对的是“发请求读响应”这个完整动作而script和img让“发请求”和“读响应”被拆开了。script能发请求也能通过执行返回的JS代码来读数据img只能发请求响应体对你来说就是个图片字节流但没关系某些场景根本不需要读响应。理解了这个拆开动作的原理后面的JSONP和图片探测就一点都不神秘了。2. JSONP不是魔术它只是借用了script标签的门票JSONP的大名是“JSON with Padding”中文社区直接音译成“Jason P”。它的核心逻辑简单到令人发指你不是不让XMLHttpRequest跨域吗那我不用你了我用script srchttps://第三方域名/api?callbackfn去加载一个“JS文件”这个“JS文件”的实际内容是一段函数调用比如fn({name:张三,age:25})。浏览器把这段内容当成脚本执行全局函数fn就被带着数据调用了。这个方案的精妙之处在于后端不用做任何特殊配置只需要把返回的JSON字符串包在callback参数指定的函数名里。所以JSONP的每一次请求都得带上一个callback参数而响应头的Content-Type也最好改成application/javascript否则某些老浏览器会拒绝执行。2.1 后端只需改一行返回逻辑我拿PHP写个例子你感受下那种原始的美感。假设某个接口返回用户信息?php // api.php $callback $_GET[callback] ?? callback; $data [ name 张三, age 25, city 杭州 ]; header(Content-Type: application/javascript; charsetutf-8); // 核心就这一行把JSON塞进函数调用里 echo $callback . ( . json_encode($data, JSON_UNESCAPED_UNICODE) . );;Node.js版也顺手写上逻辑一样// Express示例 const express require(express); const app express(); app.get(/api/user, (req, res) { const callback req.query.callback || callback; const data { name: 张三, age: 25, city: 杭州 }; res.set(Content-Type, application/javascript); res.send(${callback}(${JSON.stringify(data)});); });后端这边做的事情本质上就是“把数据印在函数调用的纸片上”扔给前端。接口不用动路由不用动权限校验也照旧只是返回格式从纯JSON换成了带函数名的JS。也正因为改动极小当年很多第三方平台愿意配合。你要是今天去翻一些老平台的技术文档还能看到?callbackcb这种参数设计。2.2 前端封装的Jsonp工具当年人手一份JSONP在前端这边没有像fetch那样内置的标准API大家都是自己封工具函数。我当年封装的那个版本到现在还留着核心逻辑几句话能说清拼一个形如https://api.xxx.com/user?callback__jsonp_1234567890的URL动态创建一个script节点src指向这个URL插入到head里在window上挂一个名为__jsonp_1234567890的函数因为返回的脚本会执行这个函数等函数被调用了数据到手立刻把script节点从DOM里删掉再删掉window上的那个函数加个超时计时器防止接口挂了页面一直转圈。像这样// jsonp.js —— 我当年压箱底的老代码换了个干净版本 function jsonp(url, params {}) { const callbackName __jsonp_ Date.now() _ Math.random().toString(36).slice(2, 8); return new Promise((resolve, reject) { // 1. 拼参数 const queryString Object.entries(params) .map(([k, v]) ${encodeURIComponent(k)}${encodeURIComponent(v)}) .join(); const fullUrl url.includes(?) ? ${url}${queryString}callback${callbackName} : ${url}?${queryString}callback${callbackName}; // 2. 动态创建script const script document.createElement(script); script.src fullUrl; // 3. 挂全局回调script加载后返回的代码里会调用它 window[callbackName] (data) { delete window[callbackName]; clearTimeout(timer); script.remove(); resolve(data); }; // 4. 错误和超时处理 const timer setTimeout(() { delete window[callbackName]; script.remove(); reject(new Error(JSONP请求超时)); }, 10000); script.onerror () { delete window[callbackName]; clearTimeout(timer); script.remove(); reject(new Error(JSONP请求失败)); }; document.head.appendChild(script); }); } // 使用示例 jsonp(https://api.xxx.com/api/user, { id: 1001 }) .then(user console.log(用户信息:, user)) .catch(err console.error(请求失败:, err));jQuery当年把这件事变成了标配$.ajax只要配了dataType: jsonp内部就自动用script标签发请求$.ajax({ url: https://api.xxx.com/api/user, data: { id: 1001 }, dataType: jsonp, jsonp: callback, // 指定callback参数名 jsonpCallback: handleUser, // 不写则自动生成随机函数名 success: function(data) { console.log(jQuery版:, data); } });这里要提醒一句jQuery自动生成随机函数名虽然方便但在监控网络面板的时候非常痛苦——你根本看不出哪个请求是哪个所以设计接口的时候尽量自己传jsonpCallback。这个看似不起眼的细节当年在排查线上问题的时候帮我省了起码两个小时。2.3 为什么脚本能变出数据来关键在这三步我知道有人可能还是懵JSONP到底怎么“绕过去”的把它拆成三步来看就清楚了第一步浏览器的同源策略只管XMLHttpRequest管不着script src所以URL可以指向任意域名第二步后端返回的不是纯数据而是一个函数调用浏览器解析脚本时会执行它第三步这个函数是你在window上预定义好的所以执行的时候就把数据“塞”给了你。这其实是把“跨域数据传输”问题转化成了“跨域脚本加载”问题。一个标签加载、一个函数调用两头一碰数据就从第三方服务器流到了你的JS里。至于名字为什么叫JSONP而不是“ScriptP”纯粹是因为数据格式是JSON、外面包了一层函数调用Padding连起来就是JSON with Padding。3. 图片探测没有响应体但埋点统计全靠它续命如果说JSONP是“偷数据”的主力那么图片探测Image Beacon就是那匹任劳任怨干活的老马而且只在特定场景出场——埋点统计。原理比JSONP还简单就一行new Image().src https://统计服务器/track.gif?eventpageviewpage/index.htmlts Date.now();浏览器加载图片是不受同源限制的你的页面在a.com图片可以请求b.com的任何资源反正浏览器只是默默把图片字节流接收下来画到页面上。如果不要响应体就扔一个new Image()对象不用把它加到DOM里只要设置src请求就发出去了。这时候有人要问请求是发出去了可我拿不到响应数据有啥用用处大了。对统计方来说它压根不需要给你返回数据它只需要“收到请求”这件事本身。服务端看到/track.gif?eventpageviewpage/index.html就记下一条日志某年某月某日某IP访问了首页。这就是最原始的网站统计。百度统计、CNZZ如今叫友盟、Google Analytics在早期都是这么干的后来才改成了sendBeacon或上报接口。你去看那些老统计脚本的源码一行(new Image).src ...的痕迹随处可见。从数据交互的角度说图片探测还干过一件正经事——页面跨域发通知。比如A系统提交订单成功后需要通知B系统“有用户下单了”又不想让用户等待就悄悄在页面里生成一张img请求https://b.com/order/notify?orderId123。B系统只要接口能处理GET请求收到请求就更新状态完成通知。整个流程体验极佳页面零感知。3.1 一个完整的埋点上报实战我给你手写一个当年常见的埋点上报模块用图片探测上报页面浏览和按钮点击// track.js —— 老式像素埋点后来被sendBeacon取代 const Tracker { // 上报核心逻辑永远是这一招new Image src send(event, extra {}) { const params new URLSearchParams({ event, page: window.location.pathname, referrer: document.referrer, time: Date.now(), ...extra }); // 拼一个1x1透明gif的URL参数全挂在查询字符串上 // 这个像素地址实际上是个服务端记录日志的接口 const pixelUrl https://stat.example.com/pixel.gif?${params.toString()}; // 为什么用new Image而不是直接document.createElement(img) // 因为不需要插入DOM不需要页面渲染浏览器照样发请求 const img new Image(); img.src pixelUrl; }, trackPageView() { this.send(pageview); }, trackClick(elementName) { this.send(click, { element: elementName }); } }; // 页面加载时上报一次 window.addEventListener(load, () { Tracker.trackPageView(); }); // 按钮点击时上报一次 document.getElementById(submit-btn).addEventListener(click, () { Tracker.trackClick(submit-btn); });注意几个细节都是踩过坑才懂的第一URL参数要用encodeURIComponent或URLSearchParams编码。当年我见过有人直接拼接URL结果用户昵称里带个中文、引号或空格后端日志全花了数据也不准。第二永远带上Date.now()时间戳。有些ISP或代理会缓存图片请求导致统计缺失加个时间戳能强制绕过缓存。第三一定要设置img.src但别设置onload。如果设置了onload而图片加载失败404、网络超时控制台会刷一片红用户不找你麻烦你都得自己烦。埋点本来就是“尽力而为”的事失败了也不影响主流程没必要让前端感知错误。3.2 除了JSONP和图片那段年月还有几样“民间偏方”既然聊到老办法就不能不提另外几个同年代偏方虽然它们不像JSONP和图片探测那样主流但攻坚时刻真的能救命window.name跨域利用window.name属性在页面跳转后依然保留的特性在A域名页面里先跳转到B域名的一个代理页面B把数据写进window.name跳回A后读取。能做大段数据传输但实现复杂早没人用了。window.postMessageHTML5正式提供配合iframe使用可以安全地跨域传递消息到现在都是主方案之一。document.domain专门解决a.qq.com和b.qq.com这种“同主域不同子域”的场景两边都设置document.domain qq.com然后就能互相操作DOM和发XHR。这些方案当年都有应用场景但都不像JSONP那样“简单到让人懒得防御”。生产环境里用得最多的还是JSONP和图片探测其他都是救火队员。4. 血泪实录那些年我在生产环境里翻过的三次车光讲原理不过瘾分享几段真实踩坑经历吧。这些坑现在看起来又土又明显但当年都是把线上环境搞得鸡飞狗跳的存在。4.1 callback参数是注入重灾区差点让接口沦为别人的肉鸡那是在2014年我们给一家租房平台做开放平台提供一个查房源信息的JSONP接口给第三方站长调用。当时图省事后端只校验了token没校验callback参数的内容。结果有一天运维发现这个接口的PQS每秒请求数暴涨到正常情况的50倍CPU报警。排查链路是这样的先看Nginx访问日志发现大部分请求的callback参数都是一个固定函数名怀疑有流量脚本在疯狂调用。跟着IP查过去是个爬虫站在循环拉房源数据。由于接口返回的内容是可控JSON还没酿成大祸但如果当时有人在callback参数里注入类似alert(1)的脚本被用户浏览器执行了那就是典型的存储型XSS。修复方案不复杂——后端对callback做白名单校验?php $callback $_GET[callback] ?? callback; // 只允许字母、数字、点、下划线、中括号组成直接过滤掉一切奇怪字符 if (!preg_match(/^[a-zA-Z0-9_.\[\]]$/, $callback)) { http_response_code(400); exit(invalid callback); }前端那边也要养成习惯用固定的回调函数名而不是把随机字符串暴露给参数。很多时候你以为自己在调接口实际上已经不知不觉成了一个攻击链条的入口。4.2 第三方服务宕机整个页面白屏转圈另一件事发生在做广告联盟DSP的时候。我们在页面上通过JSONP调一个第三方广告创意接口想着拿点展示素材。结果某天第三方接口挂了返回500但script标签请求它时既不会触发onload也不会触发onerror——在某些老浏览器里脚本加载失败几乎无感知只有那个回调函数永远等不到执行。场景还原一下用户打开页面广告区域永远是一个loading图标回调没执行后续初始化广告位的逻辑全停但页面没有报错文案控制台偶尔刷一条Uncaught ReferenceError: callback is not defined普通用户根本看不出问题。从那以后我所有JSONP封装都强制加超时处理就像上文代码里的setTimeout默认10秒超时就重置广告位、显示默认广告图。这个习惯帮我避开了至少三次类似的第三方故障。还要提醒一个问题超时时间和回调函数清理一定要在同一个逻辑层处理。我见过同事把清超时的函数写在then里、把清回调写在finally里结果接口异常时清除顺序颠倒导致页面出现一个永远不会触发的定时器内存一直在泄漏。4.3 后端把格式改成JSON前端直接报错“Unexpected token”有一次合作方升级接口运维顺手把响应头的Content-Type从application/javascript改成了application/json。就这么一个头的变化全站所有用JSONP的页面瞬间全挂。浏览器执行script标签时对MIME类型的校验在老IE里极其严格返回JSON内容但没包JS函数调用直接报语法错误。新版Chrome和Firefox宽容一点能勉强执行但响应体一旦被某些代理或CDN识别成“非脚本”同样会被拦截。这个坑给的教训是JSONP接口的响应头必须固定为application/javascript或text/javascript而且这个设置最好不要交给运维去手工配要在应用代码里强制声明。否则后端顺手改个格式前端又要排查半天。顺带说一句如果你们用的是Nginx代理记得别在JSONP接口上开启gzip压缩后再缓存压缩结果不然脚本解析出来是一堆乱码这个问题在低版本浏览器上尤为明显。下面是当年我总结的一张JSONP排错表今天看依然好用现象可能的直接原因排查路径所有跨域请求无响应后端返回了JSON但没有包裹callback看响应体是不是{...}而不是fn({...})报语法错误 Unexpected token响应头Content-Type不是application/javascriptF12看Network面板检查响应头回调一直不执行接口挂了、超时、返回500直接浏览器访问URL看返回内容控制台刷函数未定义回调函数名冲突被覆盖全局搜索一下回调名看有没有同名函数参数带中文乱码拼接URL时未编码用encodeURIComponent处理参数5. CORS一来世界清净了但老方案没完全退场2015年之后CORS支持度终于上来了理想中的代码变干净了fetch(https://api.xxx.com/api/user, { method: GET, mode: cors, credentials: include }) .then(res res.json()) .then(data console.log(CORS拿到的数据:, data)) .catch(err console.error(err));而且后端也不用去改变返回格式只要在Nginx或网关层统一加响应头# Nginx跨域配置当年学会的第一段“魔法” location /api/ { add_header Access-Control-Allow-Origin https://allowed-site.com; add_header Access-Control-Allow-Methods GET, POST, OPTIONS; add_header Access-Control-Allow-Headers Content-Type, Authorization; add_header Access-Control-Allow-Credentials true; if ($request_method OPTIONS) { return 204; } }有了这个头前端爱用什么方法就用什么方法GET、POST、带Header、带Cookie都行。CORS的工作原理其实一句话能说清浏览器在发出真正的跨域请求之前先问一下服务器“我能不能跨域访问你”服务器通过响应头回答“可以”或“不可以”。对于简单请求GET、POST、HEAD且Content-Type为text/plain等浏览器直接带上请求头对于复杂请求比如带自定义Header、Content-Type为application/json浏览器先发一个预检请求OPTIONS服务器确认后才发正式请求。这个机制下面是几个CORS时代的坑今天依然有人中招顺手列出来火狐的Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource几乎每次都是后端忘了加Access-Control-Allow-OriginLaravel或Spring这类框架如果做了权限拦截OPTIONS预检请求没有放行就会导致前端明明看着接口配置挺全实际请求却一直失败存储PDF或其他文件时如果浏览器直接访问文件URL报CORS错误大多是Nginx没给/storage路径加头Laravel上特别常见Access-Control-Allow-Origin设为*时就无法携带Cookie想带Cookie必须写成具体的、不带通配符的源地址并且要同时设置Access-Control-Allow-Credentials: true。但即便CORS已经成了标准操作JSONP和图片探测依然没有完全退场。为什么因为它们的“成本”太低了。第一种情况老系统不能动。很多企业内部OA、政府平台上的接口还是十年前写的后端没人敢动响应头前端也不能随便换协议。这时候前端只能继续用JSONP。我这个月还帮一个朋友排查过故障因为某老系统里依然在用$.ajax的dataType:jsonp。项目经理说当初就是看中它“后端零改动”才选的现在成了历史包袱。第二种情况统计SDK与第三方组件。百度统计、友盟这类SDK为了适配全站所有页面不会依赖CORS头因为它们拿图片或sendBeacon就能完成数据上报。你看那些埋点代码依然有new Image().src的影子。在需要“发了就行、不需要响应”的场景没有任何方案比图片探测更省事。第三种情况后端打死不想开CORS。有些合作方压根就不想让你跨域访问但你又确实需要一个数据。这时候JSONP反而成了一种“绕过后端意志”的方案——因为只要他们返回了JS格式前端就能执行。当然这有合规风险我们不鼓励这么干。所以我的态度是JSONP不是洪水猛兽而是历史演进的产物但它绝不能成为日常首选。新项目一律走CORS或代理只有遇到老系统迁移、第三方限制等极端情况才考虑祭出老方案。6. 聊聊现在的替代方案跨域问题应该怎么优雅地解如果你今天依然在为跨域发愁其实有几个比JSONP优雅得多的现代姿势这里按照“后端改写难度从低到高”列一遍。首选是反向代理。在Nginx层面配置把跨域请求在代理层转发到目标服务器前端请求的URL和当前域同源根本不触发跨域检查。开发阶段Webpack的devServer.proxy也是这个思路。这种方式对目标服务器零改动是目前最推荐的方案。配置示例// Vite开发代理 export default defineConfig({ server: { proxy: { /api: { target: https://api.xxx.com, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } });生产环境用Nginxlocation /api/ { proxy_pass https://api.xxx.com/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }其次是服务端中间层也就是BFFBackend For Frontend模式。让后端封装一层自己的接口再由这层服务去请求第三方接口然后返回给前端。这样前端请求的永远是同域接口等于把跨域问题消灭在后端最彻底也最好排查问题。再者是现代新特性navigator.sendBeacon专门用来做埋点上报解决了图片探测的不足// sendBeacon在页面卸载时也能可靠发送这是onbeforeunload里发AJAX做不到的 navigator.sendBeacon( https://stat.example.com/track, new Blob([JSON.stringify({ event: pageleave, page: /home })], { type: application/json }) );它只发数据、不关心响应所以不用处理CORS天生适合统计、日志上报。如果哪天你所在的项目统计代码还在用老式new Image().src建议逐步迁到sendBeacon至少能把数据量做大一点也不至于在弱网环境下丢像素。最后如果你维护的是一个开放平台要给别人提供跨域API那就老老实实配合CORS做好来源校验、预检放行让调用方写得舒服点。这既是技术债也是平台体验的一部分。提示不管用哪种现代方案老代码迁移的时候一定注意灰度。先让5%的流量走新逻辑对比一下数据和异常日志确认没问题再全量切换。跨域问题不像普通Bug那样能一眼看到错误很容易埋下统计缺失的隐患。关于跨域这件事我自己从JSONP用到了CORS再从CORS用到了代理与BFF最大的感悟是技术方案永远是被当时的平台能力逼出来的。JSONP和图片探测虽然土但它们的思路——拆解浏览器规则的边界、用最小的成本解决问题——放到现在依然值得学习。我至今还在工具库里保留着那套jsonp.js和track.js偶尔迁移老系统的时候还能用到。它们不完美但正是这段“不完美”的历史才让今天的方案显得如此理所应当。如果你现在还在维护老项目也别急着全盘重构多花几分钟理解一下当年它们为什么长这样等你真要动手迁移的时候会比别人少踩很多坑。
返回列表