拓冰建站拓冰建站
首页 / 资讯中心 / 正文

PHP跨域请求解决实战:CORS与JSONP两种方案详解

搞前后端分离项目的时候跨域请求问题几乎是每个人都会撞上的一堵墙。前端的报错信息里那句 No Access-Control-Allow-Origin header is present 看多了真的会让人头皮发麻。尤其是PHP作为后端接口时这类问题出现频率特别高。这篇文章我就把自己在实战里常用的两种PHP解决跨域的方案完整拆开讲清楚包括实现原理、完整代码、踩过的坑和排查思路希望能帮你少走点弯路。不管是刚入行的新手还是写了好几年接口的老手这篇内容应该都能让你有收获。1. 跨域问题到底是怎么来的1.1 同源策略是这一切的源头要解决跨域先得明白浏览器为什么要拦你。浏览器有一个核心安全机制叫同源策略它规定只有协议、域名、端口三者完全一致的两个页面才允许互相访问资源。任何一个不同就算跨域。举个例子https://www.a.com:8080和http://www.a.com:8080虽然域名一样但协议不同https和http就已经构成跨域了。更别说a.com和b.com这种域名完全不同的情况。同源策略限制的不只是Ajax请求还包括Cookie的读取、DOM的操作、localStorage的访问等。但目前我们开发中感知最强烈的就是Ajax请求被拦截。有一个点很多人容易忽略跨域拦截是浏览器做的不是服务器做的。你的PHP接口其实已经正常处理了请求、也正常返回了数据但浏览器看了下响应头里没有允许跨域的标识就直接把响应给吞了在控制台抛一个CORS错误。这就是为什么你用curl、用Postman测接口完全正常一放到浏览器里就报错。1.2 现在为什么跨域问题这么频繁早些年PHP写网站大多是服务端渲染页面和接口在同一个域名下很少遇到跨域。但现在的前后端分离架构完全改变了这个局面前端用Vue或React开发部署在www.a.com后端PHP接口部署在api.b.com天然跨域。本地开发时前端跑在localhost:8080后端在localhost:8081端口不同也算跨域。微服务架构下一个页面要请求多个不同域名的接口服务跨域场景更多。App端内嵌H5页面H5请求的接口域名和页面域名不一致的情况也很常见。说白了跨域是前端工程化、前后端分离普及之后PHP后端开发必须正面应对的基础问题。你说你绕不过去那就只能想办法解决。1.3 PHP解决跨域的两条主流路线目前生产环境里最常用的方案就两种CORS响应头方案和JSONP方案。CORS跨域资源共享是W3C的标准方案通过在HTTP响应头中声明允许跨域的规则来告诉浏览器这个响应可以放行。它是目前的主流几乎支持所有类型的请求。JSONP则是一种曲线救国的古老技巧利用script标签不受同源策略限制的特性来加载数据。它实现简单、兼容性好但只支持GET请求。这两种方案各有适用场景我下面分别详细讲并给出可以直接拿去用的完整代码。2. 方法一CORS响应头方案最标准也最常用2.1 CORS的核心原理CORS全称是Cross-Origin Resource Sharing它的思路很直接既然同源策略是浏览器做安全检查那就给浏览器一个放行凭证。这个凭证就是服务器在HTTP响应里携带的Access-Control-Allow-Origin头。浏览器收到响应后会检查这个头。如果响应头里的值包含当前请求的来源Origin就把响应交给前端JavaScript如果没有或者不匹配就拦截响应并报错。这里要理解一个关键点浏览器在Ajax请求发出时会自动带上Origin头标明当前页面的来源。比如你的页面跑在http://localhost:8080请求发出时请求头里就会带Origin: http://localhost:8080。PHP接口要做的就是读取这个Origin然后在响应头里把它返回回去。2.2 最简实现几行header()搞定最简单的写法就是在PHP接口文件的最顶部加上这几个header?php header(Access-Control-Allow-Origin: *); header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With); // 你的业务代码... echo json_encode([code 0, msg success]);第一行Access-Control-Allow-Origin: *表示允许所有来源跨域访问。这个写法最省事但有两个隐患一是任何网站都能调用你的接口外部可以有风险二是*和Access-Control-Allow-Credentials: true不能同时使用也就是说如果你要跨域携带Cookie就不能用*。第二行声明允许的HTTP方法第三行声明允许的自定义请求头。前端如果加了自定义头比如Authorization而后端没声明浏览器会判定预检失败。注意header()必须在任何实际输出之前调用包括echo、HTML代码、甚至一个换行。否则会报headers already sent错误。这也是很多新手最容易踩的坑。2.3 预检请求OPTIONS必须单独处理很多人写了上面的header发现还是报错原因就是没处理预检请求Preflight。情况是这样的当你的请求不是简单请求时浏览器会先发一个OPTIONS请求来探测服务器允不允许。什么情况算非简单请求呢典型的有使用application/json作为Content-Type、携带自定义请求头、使用PUT/DELETE等方法。这个OPTIONS请求到了PHP后端如果你的代码没有针对它做处理框架就会把它当成普通请求去路由多半会返回404或者走不到你的公共header代码导致浏览器收不到CORS响应头直接把真正的请求给拦了。正确做法是在入口处拦截OPTIONS请求直接返回不再继续执行业务逻辑?php // 统一跨域配置 header(Access-Control-Allow-Origin: *); header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With); header(Access-Control-Max-Age: 86400); if ($_SERVER[REQUEST_METHOD] OPTIONS) { http_response_code(204); exit; } // 业务代码...Access-Control-Max-Age这个头很实用它表示预检请求的结果可以缓存多长时间单位是秒。我这里设置86400秒也就是一天。缓存生效后这段时间内同样的请求就不会再频繁发OPTIONS了能减少一次网络往返接口响应速度也会快一些。注意http_response_code(204)预检请求不需要返回body204 No Content是最规范的状态码。有些写法直接exit;不设置状态码也行浏览器一般也能接受但规范起见建议用204。2.4 需要跨域携带Cookie时的写法如果接口需要读取Cookie来维持用户登录态情况就复杂一些。这时前端需要设置xhr.withCredentials trueaxios里是withCredentials: true同时后端也必须配合?php $origin isset($_SERVER[HTTP_ORIGIN]) ? $_SERVER[HTTP_ORIGIN] : ; header(Access-Control-Allow-Origin: . $origin); header(Access-Control-Allow-Credentials: true); header(Access-Control-Allow-Methods: GET, POST, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization); if ($_SERVER[REQUEST_METHOD] OPTIONS) { http_response_code(204); exit; }注意这里有两个关键细节第一Access-Control-Allow-Origin不能再写*了必须写具体的来源。我这里是直接回传请求的Origin相当于放行所有来源你自己使用时最好限制成白名单。第二Access-Control-Allow-Credentials: true告诉浏览器允许携带凭证信息。这两个头缺一不可否则浏览器依然会拦截。前端配合的写法也需要留意// 原生写法 var xhr new XMLHttpRequest(); xhr.withCredentials true; xhr.open(GET, http://api.example.com/user, true); xhr.send(); // axios写法 axios.get(http://api.example.com/user, { withCredentials: true }) .then(res console.log(res.data));如果前端忘记设置withCredentials而后端又要求携带Cookie虽然请求能发出去但后端拿不到Cookie会导致登录态失效。这类问题不像跨域报错那么直观排查起来反而更费劲。2.5 动态Origin白名单的正确姿势实战里我不建议一直用*更不建议直接回显任意Origin。开发环境无所谓但线上接口最好只放行自己的前端域名。白名单写法其实很简单?php $allowed_origins [ https://www.a.com, https://admin.a.com, http://localhost:8080, ]; $origin isset($_SERVER[HTTP_ORIGIN]) ? $_SERVER[HTTP_ORIGIN] : ; if (in_array($origin, $allowed_origins)) { header(Access-Control-Allow-Origin: . $origin); header(Vary: Origin); header(Access-Control-Allow-Credentials: true); } header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With); if ($_SERVER[REQUEST_METHOD] OPTIONS) { http_response_code(204); exit; }这里加了一个Vary: Origin头很多人不理解为什么。简单说HTTP缓存服务包括CDN和浏览器缓存会依据URL来缓存响应。如果同一个URL因为Origin不同而返回了不同的Access-Control-Allow-Origin那就可能导致缓存串数据。Vary: Origin告诉缓存系统这个响应是根据Origin来区分的避免缓存错乱。我实际项目中一般把这段跨域逻辑封装成一个公共函数或中间件所有接口入口统一调用。在ThinkPHP里可以做成一个中间件在Laravel里可以做一个全局中间件简单点的原生PHP项目就在公共入口文件include一个cors.php。这样就不用在每个接口重复写这些header了。3. 方法二JSONP方案老技术但依然能打3.1 JSONP的工作原理说完了标准方案再聊JSONP。JSONP全称是JSON with Padding它的思路非常巧HTML中的script标签加载外部脚本是不受同源策略限制的你可以在任何页面通过script标签加载其他域名的JavaScript文件。于是聪明人就想既然Ajax被拦那我不走XHR改用一个script标签去请求接口行不行接口返回的不再是纯JSON而是一段JavaScript代码——一个函数调用把数据作为参数传进去。这个函数是前端提前定义好的回调函数。整个过程是这样的前端动态创建一个script标签src指向接口地址并带上一个回调函数名参数比如http://api.example.com/user.php?callbackhandleResult。服务器收到请求把数据拼接成handleResult({...json数据...})这样的JavaScript代码返回。浏览器加载这段脚本并执行等于调用了前端定义好的handleResult函数数据就自然传到前端了。整个过程根本没有使用XMLHttpRequest所以同源策略管不着它。3.2 PHP端的JSONP接口实现PHP端实现JSONP其实非常简洁?php $callback isset($_GET[callback]) ? $_GET[callback] : ; $data [ code 0, msg success, data [user_id 10001, name 张三] ]; // 校验回调函数名防止XSS注入 if ($callback || !preg_match(/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/, $callback)) { http_response_code(400); exit(invalid callback); } header(Content-Type: application/javascript; charsetutf-8); echo $callback . ( . json_encode($data) . );这里两个细节必须注意第一返回的Content-Type要用application/javascript不是application/json因为返回的本质上是一段JS代码。第二callback参数必须做格式校验。我见过不少人在这个位置直接用$_GET[callback]拼进返回结果如果攻击者构造一个callbackscriptalert(1)/script之类的值就很容易造成反射型XSS。用正则限制只能包含字母、数字、下划线就能把这个风险挡在外面。3.3 前端怎么调用JSONP接口jQuery时代调用JSONP特别方便ajax方法里指定dataType: jsonp就行$.ajax({ url: http://api.example.com/user.php, dataType: jsonp, jsonp: callback, success: function(res) { console.log(res.data.name); } });jQuery会自动生成一个随机的回调函数名并帮你在请求地址后面拼上callbackjQueryxxx。原生JavaScript也不复杂我这里写一个封装好的Promise版本方便在Vue或React项目里直接使用function jsonp(url, params {}, timeout 10000) { return new Promise((resolve, reject) { const callbackName jsonp_cb_ Date.now() _ Math.floor(Math.random() * 1000); let timer null; window[callbackName] function(data) { delete window[callbackName]; document.head.removeChild(script); clearTimeout(timer); resolve(data); }; const query Object.keys(params).map(key encodeURIComponent(key) encodeURIComponent(params[key]) ).join(); const script document.createElement(script); script.src url ? query callback callbackName; script.onerror function() { delete window[callbackName]; document.head.removeChild(script); clearTimeout(timer); reject(new Error(JSONP request failed)); }; document.head.appendChild(script); timer setTimeout(function() { delete window[callbackName]; document.head.removeChild(script); reject(new Error(JSONP request timeout)); }, timeout); }); } // 使用示例 jsonp(http://api.example.com/user.php, { id: 10001 }) .then(res console.log(res)) .catch(err console.error(err));这里有几个细节需要注意回调函数要挂到window对象上因为服务器返回的代码是全局执行的请求完成后要清理掉这个临时函数和script标签加上超时控制避免接口没响应时前端一直等着。这些细节虽然小但处理不到位就会留下内存泄漏或者不好排查的隐藏问题。3.4 两种方案怎么选优缺点一次说清先看一张对比表对比维度CORS方案JSONP方案请求方法GET、POST、PUT、DELETE等所有方法仅支持GET实现复杂度服务端配置略复杂需要处理预检前后端都很简单浏览器兼容性IE10及主流现代浏览器IE6全兼容错误处理支持onerror回调可捕获HTTP状态码只能靠脚本加载错误判断不够精细传参方式支持请求体、文件上传、各种Content-Type只能通过URL参数传递长度受限安全性同一套跨域规则XSS风险低回调未校验时存在XSS风险携带Cookie支持需开启Credentials不支持操作跨域Cookie从表里能看出来CORS是全面优于JSONP的那为什么JSONP还没有被淘汰主要两个原因一是某些老系统里第三方接口还在用JSONP你需要对接它二是极少数环境因为历史原因不方便改动nginx配置JSONP作为临时方案可以快速顶上。我在实际项目中的建议是新写的接口一律用CORS只有当你在对接别人已经写好的JSONP接口、或者数据量小的GET类接口且对方无法配合改CORS配置时才考虑JSONP方案。4. 实操复盘完整调试流程与踩坑记录4.1 一个真实的排查过程复盘我之前帮一个朋友排查过他项目里的跨域问题场景很典型前端Vue项目跑在localhost:8080PHP接口跑在localhost:8081。前端用了axios请求时设了Authorization头接口一直报跨域错误。第一次我看到他的PHP代码只写了这么一行header(Access-Control-Allow-Origin: *);问题很明显请求带了Authorization头属于非简单请求浏览器会先发一个OPTIONS预检。但接口收到OPTIONS后还要走完整个业务逻辑并且后面还跟了其他一些输出预检响应里没有Access-Control-Allow-Headers所以预检直接失败。我让他改成下面这样?php header(Access-Control-Allow-Origin: *); header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With); if ($_SERVER[REQUEST_METHOD] OPTIONS) { http_response_code(204); exit; }结果呢预检通过了但真正请求时又报了个新错误说的是Authorization头不被允许。这就很奇怪了明明Access-Control-Allow-Headers里写了Authorization。后来我让他在浏览器开发者工具里看了下网络面板发现接口返回了两个Access-Control-Allow-Headers头一个是nginx配置里加的另一个是PHP代码里的。我把nginx里那行重复配置去掉问题立刻解决。这个案例告诉我们排查跨域问题一定不要只看代码要看浏览器实际收到的响应头。双击网络请求在Response Headers里能看到完整信息这是最可靠的。4.2 高频问题与排查技巧速查表我把这几年积累的跨域排查经验整理成一个速查表按症状可以直接对照症状可能原因解决办法报错Response to preflight request doesnt pass access control checkOPTIONS请求没得到有效CORS响应头在入口处拦截OPTIONS返回204并带上Allow-*头报错No Access-Control-Allow-Origin header is present接口没设置该头或设置了但没生效检查PHP是否输出后调header检查nginx是否覆盖了响应头请求带了自定义头就被拦Allow-Headers里没声明该头在Access-Control-Allow-Headers加上对应的头名跨域设置用了*但前端开了Credentials浏览器规范禁止*与Credentials共存改为返回具体Origin并加Access-Control-Allow-Credentials: true接口返回正常但控制台报错且response为空多半是响应被拦截不是接口没返回用curl模拟请求确认接口能正常返回再用浏览器Network面板看完整响应头页面报headers already sent在输出内容后调用了header()检查include文件或模板中是否有空格、BOM、echo等输出OPTIONS请求返回404或者被引导到登录页框架路由把OPTIONS当普通请求处理了在路由或中间件层面对OPTIONS提前放行接口成功但Cookie没带上前端没设置withCredentials或后端Allow-Origin是*前端设置withCredentials: true后端设置白名单并开启Credentialsnginx环境下PHP配置生效但浏览器看到重复头nginx的add_header和PHP的header重复检查nginx配置删除重复的CORS相关add_header其中headers already sent这个报错在新手里特别常见这里多说一句。PHP的header()函数要求在任何输出之前调用这里的输出包括echo的内容、HTML代码、甚至PHP文件开头BOM头留下的不可见字符。如果你用编辑器保存文件时选了带BOM的UTF-8编码也会中招。解决办法是把编码改成UTF-8无BOM。4.3 方案落地时容易被忽视的安全细节跨域配置本质上是在给外部开窗户开多大、开给谁必须有明确的边界意识。以下几点是我在项目上线前一定会检查的第一禁止无脑用*。如果你的接口不涉及用户敏感数据用*确实省事但一旦接口里涉及用户信息、订单数据、支付回调等就必须改成白名单模式。黑客完全可以构造一个恶意站点在你的用户登录着其他网站时通过浏览器发起跨域请求白名单能在源头上把这种攻击挡在门外。第二JSONP接口的callback参数必须校验。这个前面代码里已经写了用正则限制字符集。不复述代码只讲一个原则任何会拼进响应内容的前端输入都必须经过白名单式校验而不是黑名单过滤。第三注意CORS与CSRF的叠加风险。跨域放宽之后原本同源限制能挡住一部分CSRF攻击现在等于把这层保护拆了一部分。所以允许跨域的接口一定要做好身份验证比如校验Token、校验签名。如果你的接口允许跨域但本身没有Token机制外部站点完全可以先请求你的接口如果你的后端逻辑里依赖Cookie识别身份那配合Access-Control-Allow-Credentials: true就可能被恶意利用。第四区分线上环境和开发环境的配置。我建议开发环境可以宽松一点把localhost各种端口都加到白名单里线上环境严格收紧只留真实域名。这个可以用PHP的$_SERVER[SERVER_NAME]或者配置文件在不同环境分别定义避免上线后忘记收紧。还有一个细节容易被忽略CDN或nginx开了响应头缓存时CORS头可能会被缓存下来导致配置更新不生效。遇到改了配置但线上行为没变的诡异情况先清一下CDN缓存再看nginx的proxy_hide_header是否能覆盖掉源服务器的头再考虑是不是浏览器本地缓存了预检结果。5. 写在最后的实操体会文章写到这里把CORS和JSONP两种方案都讲透了。说实话刚入行那会儿我也被跨域问题折磨得不轻后来理解了浏览器整个机制之后发现跨域其实就是一个找对头、加对头的过程。所谓找对头是搞清楚浏览器到底在拦截什么、看什么所谓加对头就是把你需要的那几个响应头加对、加全、加在正确的地方。根据我个人经验如果你正在用PHP写接口并被跨域问题困扰别急着在代码里各种乱试先把浏览器Network面板打开看预检请求OPTIONS的状态码和响应头再看真正请求的响应头大部分问题在这一步就能定位。如果拿到响应头发现啥CORS信息都没有那问题多半出在入口或代理层如果头信息有了但条件不匹配那就逐项对照表的规则一点点查。另外提醒一句跨域配置最好不要散落在各个接口文件里统一的中间件或公共文件才是维护性最好的做法。等你项目里接口多了就会发现在一处统一维护跨域规则比在几十个文件里各自写几行header要省心太多后续调整白名单也不用挨个找。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门