大家好,我是你们的老朋友,那个手里永远拿着保温杯,满嘴“情怀”和“架构”的资深PHP老兵。
今天咱们不聊什么高大上的微服务架构,也不扯那些让实习生头秃的DDD领域驱动设计。咱们聊一个让无数前后端分离项目“死机”的元凶,一个让前端兄弟在浏览器控制台前抓耳挠腮的噩梦——CORS(跨域资源共享)。
先别急着划走,我知道你可能觉得“切,CORS不就是加几个header嘛,这谁不会?” 嘿,兄弟,如果你觉得它简单,那说明你大概率还没在半夜两点被一个 OPTIONS 请求折磨疯过。在这个主题里,我要教你怎么把CORS这个“调皮捣蛋鬼”变成你API的“乖宝宝”,并且保证你的接口安全、稳定,连网络管理员看了都要竖大拇指。
准备好了吗?系好安全带,我们开始今天的“CORS生存指南”。
第一章:这是谁家的“狗”?—— 理解同源策略(SOP)
在解决问题之前,我们得先搞清楚“敌人”是谁。这就像你在家里做饭(服务端),你做好了满汉全席,端到客厅(浏览器端)去吃,结果厨房门(浏览器)死活不开,非说你是“外人”。
这就是同源策略(Same-Origin Policy, SOP)。
什么叫“同源”?简单来说,就是三个“子集”:协议(http/https)、域名(google.com/baidu.com)、端口(80/8080)完全一致。如果有一丁点不一样,比如我从 api.a.com 调用 www.a.com,或者从 http 调用 https,浏览器就会像防贼一样,拦住你的请求。
于是,现代Web开发为了安全,让浏览器默认拒绝所有跨域请求。这时候,你作为一个后端PHP开发者,你就得站出来说:“喂!别挡着!我这边API好得很,数据就是那啥,信我!”
而CORS(Cross-Origin Resource Sharing),就是浏览器允许服务器发出这个“信任声明”的协议。它不是后端必须遵循的硬性规定,而是浏览器用来保护用户的工具。如果服务器(你的PHP)不配合地加上一些特殊的HTTP头信息,浏览器就会把你的JSON数据当成垃圾邮件扔进回收站。
第二章:手把手教你“驯服”CORS —— 原生PHP实现
好,理论说多了容易睡着。咱们直接上干货。在PHP里,实现CORS的核心就两步:发请柬和把门。
2.1 那个最简单、最危险的方法:允许一切
在开发环境,有时候为了图省事,或者为了调试那些该死的移动端App,你可能会想:“我就允许所有人进来吧,爱谁谁。”
这时候,你会在PHP文件的最顶部写下这几行代码:
<?php
// 这是一个非常粗暴的做法,仅限本地开发或极不敏感的内网测试
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Max-Age: 3600'); // 缓存预检请求1小时
注意了! 我在前面加了“警告”。把 * 放在生产环境,就像你家大门没锁,还挂了个牌子“欢迎光临,东西随便拿”。这会给你的网站带来巨大的CSRF(跨站请求伪造)安全风险。如果你的接口是用来登录或者修改密码的,黑客可以直接构造一个恶意网页,诱导用户点击,然后利用这个CORS漏洞拿到数据。
所以,这是为了演示原理,千万别在生产环境这么干!
2.2 稳健的方法:白名单机制
在生产环境,我们必须精准控制。假设你的前端部署在 https://app.yourdomain.com,后端API部署在 https://api.yourdomain.com,而且你还有一些管理后台在 https://admin.yourdomain.com。
我们需要根据请求的 Origin 头信息来决定是否放行。PHP代码应该长这样:
<?php
// 1. 获取请求来源
$origin = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '';
// 2. 定义白名单(这里硬编码,实际中建议从数据库或配置文件读)
$allowedOrigins = [
'https://app.yourdomain.com',
'https://admin.yourdomain.com'
];
// 3. 验证来源
if (in_array($origin, $allowedOrigins)) {
// 只有在白名单里的域名才能通过
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
// 如果需要携带Cookie或Token,必须开启Credentials
header('Access-Control-Allow-Credentials: true');
} else {
// 如果不在白名单,什么都不输出,或者返回403 Forbidden
// 浏览器会收到一个没有Access-Control-Allow-Origin头的响应,从而拦截
http_response_code(403);
exit('Access Denied');
}
// 4. 处理 OPTIONS 预检请求(这个是重灾区,后面细说)
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit(0);
}
第三章:那个烦人的“幽灵请求” —— 预检请求
这是CORS里最让PHP新手抓狂的地方。
当你发起一个GET或POST请求时,浏览器是直接发的,这叫简单请求。但在CORS机制下,有些请求比较敏感,比如:
- 你用了自定义头信息(比如
X-My-Custom-Header: value)。 - 你用了
PUT,DELETE这种“危险”的HTTP动词。 - 你发送了
Content-Type: application/json。
这时候,浏览器不会直接发你的请求,而是先发一个 OPTIONS 请求(也叫“预检请求”)。
它的逻辑是这样的:
浏览器:“嘿,服务器,我想发个POST请求,带个JSON,还得动刀动枪(PUT),你能接受吗?我先探个底。”
服务器(你的PHP):“哦?要发JSON?还要动刀动枪?行吧,我先告诉浏览器:‘我允许这些方法,我允许这些头信息’。”
如果服务器没有正确响应这个OPTIONS请求,告诉浏览器“没问题”,浏览器就会直接在你的代码里报错,根本不会去执行你的 insert into users 语句。
所以,处理 OPTIONS 请求是PHP实现CORS的关键一环。
上面的代码里有一行:if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit(0); }。这行代码的意思是:“如果这是个预检请求,我就给你个‘OK’,然后立刻断开,不要执行后面的业务逻辑。”
但是,光退出还不够。因为OPTIONS请求也会触发上面的 header() 调用。也就是说,你必须先给OPTIONS请求设置好CORS头信息,然后才能退出。 顺序不能乱!这是很多框架的中间件容易踩的坑。
第四章:不仅仅是“跨域” —— 认证与Cookie
有时候,你的前端和后端虽然在同一个域名下(比如 api.yourdomain.com),但因为HTTP协议变了(比如从HTTP变成了HTTPS),或者端口不同,浏览器依然会认为是跨域。
更常见的情况是,你需要在前端请求中携带用户的登录信息。这就是 CORS with Credentials。
4.1 必须开启 Credentials
如果你的前端代码是这样的:
// 前端
fetch('https://api.yourdomain.com/user/profile', {
method: 'GET',
credentials: 'include', // 关键!告诉浏览器带上Cookie
})
那么,你的PHP必须加上这两行:
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Origin: https://app.yourdomain.com'); // 注意:这里不能是 *,必须是具体域名
这里有个极其重要的反直觉点:
当 Access-Control-Allow-Credentials: true 开启时,Access-Control-Allow-Origin 绝对不能 是 *(通配符)。
为什么?因为如果允许所有人,而我又要信任你的Cookie,那黑客的网站也能读取你的Cookie,那还得了?
这就意味着,如果你的前端有多个子域名(比如 app.com 和 blog.com 都需要访问API),你就不能在PHP里写死一个 Origin。你必须动态判断,甚至把白名单存到Redis里,实时查询。
4.2 暴露响应头
有时候,你的后端PHP代码里设置了某些特殊的头信息,比如缓存状态或者自定义统计信息,你想让前端JS能读到,必须在响应里显式告诉浏览器:
header('Access-Control-Expose-Headers: X-My-Response-Header, X-Cache-Status');
否则,前端JS用 response.get('X-My-Response-Header') 只能拿到 null,就像你隔着墙喊话,对方非但不理你,还把耳朵堵上了。
第五章:框架中的CORS —— 别重复造轮子
写原生PHP很爽,但如果你用的是Laravel、Symfony或者ThinkPHP这类现代框架,千万别傻乎乎地去手动写那些 header()。
以 Laravel 为例,它内置了CORS支持,而且做得比大多数开发者想得都要好。你只需要在 config/cors.php 里稍微配置一下:
return [
'paths' => ['api/*'],
'allowed_domains' => [
'https://app.yourdomain.com',
// ...
],
'allowed_methods' => ['*'],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];
Laravel会自动处理那个讨厌的OPTIONS预检请求,甚至还能帮你处理子域名。用框架的好处就是:框架比你更懂CORS规范,而且它的代码通常是经过成千上万项目验证过的。 你去写原生CORS,遇到一个边缘bug(比如CORS头顺序不对,或者缓存没清),排查起来能让你怀疑人生。
第六章:防御性编程 —— Nginx/Apache 的角色
别忘了,PHP通常不是直接跑在浏览器里的,它是跑在Nginx或Apache后面的。很多时候,CORS报错,不是PHP的问题,是Web服务器的问题。
6.1 Nginx 的拦截
如果你在PHP里写了完美的CORS代码,但是浏览器还是报错,90%的情况是Nginx把OPTIONS请求拦截了。
Nginx默认可能会把 OPTIONS 请求转发给PHP,但如果你没有正确配置Nginx处理这些请求,或者Nginx开启了某种安全插件,它可能直接返回405 Method Not Allowed,或者空响应,导致浏览器认为“服务器不支持CORS”。
正确的Nginx配置通常是让Nginx直接处理OPTIONS请求,或者确保它把请求正确转发给PHP:
location /api {
# 如果是OPTIONS预检请求,直接返回200 OK,不要转发给PHP
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
# 正常的PHP请求处理
fastcgi_pass php-upstream;
include fastcgi_params;
}
6.2 Apache 的 Mod Rewrite
Apache用户可能需要利用 mod_rewrite 来辅助处理CORS,尤其是在处理动态Origin的时候。
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP:Origin} ^(https?://(.*).(yourdomain.com)) [NC]
RewriteRule ^(.*)$ - [E=HTTP_ORIGIN:$1]
</IfModule>
然后在PHP里,你就可以使用这个环境变量了:$origin = getenv('HTTP_ORIGIN');。
第七章:那些“坑爹”的报错与排查
好了,代码都写了,配置都对了,为什么还是报错?
7.1 “No ‘Access-Control-Allow-Origin’ header is present on the requested resource.”
这是最经典的报错。意思就是:你跟浏览器说“我是好人”,但浏览器回头一看响应头,根本没看到 Access-Control-Allow-Origin 这行字。
排查清单:
- 你的代码真的执行了吗?断点跟一下。
- 是不是被中间件拦截了?比如某个登录检查没过,直接
return false,导致header没发出去。 header()之前有没有输出?PHP有个规矩,header() 之前不能有任何空格或echo。哪怕是一个<?php后面紧跟着一个空格,都会导致header失效。- SSL问题?如果你的前端是HTTPS,后端是HTTP,某些浏览器(特别是Chrome)会拦截混合内容请求,报CORS错误。
7.2 “Request header field X is not allowed by Access-Control-Allow-Headers in preflight response.”
这个错误是专门针对预检请求的。你前端发了一个自定义头 X-Token,后端没在 Access-Control-Allow-Headers 里声明允许这个头。
解决: 在PHP里加上 header('Access-Control-Allow-Headers: X-Token');。
7.3 “The value of the ‘Access-Control-Allow-Origin’ header must not be the wildcard ‘*’ when credentials mode is ‘include’.”
这个错误专治那些“懒人”。你想用 credentials: 'include',又想用 Access-Control-Allow-Origin: *。浏览器会直接把你挂起来。
解决: 哪怕只针对一个域名,也要写具体的Origin,不要用 *。
第八章:终极实战——一个生产级的CORS中间件
为了让大家彻底放心,我写了一个可以直接拿去用的PHP函数。这个函数考虑了白名单、预检请求、凭证模式、缓存策略。它就像一个尽职尽责的保安,既不放走坏人,也不把好人挡在门外。
<?php
/**
* 生产环境CORS配置
* @param bool $isPreflight 是否为预检请求
* @return void
*/
function handleCORS($isPreflight = false) {
// 1. 获取来源
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// 2. 定义你的白名单域名(支持泛域名匹配更高级,这里简化为精确匹配)
$allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
'https://www.example.com'
];
$isAllowed = false;
// 3. 验证
foreach ($allowedOrigins as $allowed) {
// 简单的字符串包含判断,生产环境建议用正则或校验域名
if (strpos($origin, $allowed) !== false) {
$isAllowed = true;
break;
}
}
// 如果不是预检请求,且来源不在白名单,直接死亡
if (!$isPreflight && !$isAllowed) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: CORS policy does not allow access.']);
exit;
}
// 4. 设置响应头
if ($isAllowed) {
header('Access-Control-Allow-Origin: ' . $origin);
}
// 允许的方法(PUT, DELETE, PATCH 等都要加)
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS');
// 允许的请求头(必须包含Content-Type,通常还要包含自定义认证头)
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-CSRF-Token');
// 是否允许携带凭证
header('Access-Control-Allow-Credentials: true');
// 预检请求缓存时间(秒),减少OPTIONS请求频率
header('Access-Control-Max-Age: 86400'); // 24小时
// 5. 如果是预检请求,直接结束
if ($isPreflight) {
// 确保没有任何输出,只返回204 No Content
exit(0);
}
}
在你的API入口文件(比如 index.php 或 api.php)最顶部,第一行就是:
<?php
// 必须在输出任何内容前调用
handleCORS(isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'OPTIONS');
结语
CORS看起来是个小问题,但它实际上连接着Web安全的核心。它既是浏览器为了安全筑起的高墙,也是前后端协作的桥梁。
通过今天的“讲座”,我们了解了:
- SOP 的存在是因为安全。
- CORS 是服务器给浏览器的“通行证”。
- OPTIONS预检请求 是浏览器为了安全进行的“试错”。
- 白名单机制 是防止数据泄露的防火墙。
- Credentials 和 通配符 是一对死对头。
记住,CORS不是后端的负担,而是Web标准的基石。写好CORS,你的接口才能真正面向世界。
好了,今天的“CORS生存指南”就到这里。希望下次当你再遇到那个该死的 Access-Control-Allow-Origin 报错时,能笑着把它修好。如果你觉得这篇文章帮到了你,记得转发给你那个还在手动写 header() 的后端同事。
Code with style, CORS with care. 拜拜!