关于 CORS 的一个问题,大家怎么看

2020-09-02 17:28:15 +08:00
 mitu9527

昨天先去了解了下同源策略,然后又去看了一些和 CORS 相关的文章,几乎所有的文章都告诉我只要在响应头中加入几个 Access-Control-* 头部就可以了。比如像这样:

header('Access-Control-Allow-Origin:http://www.startphp.cn');
header('Access-Control-Allow-Methods:POST');
header('Access-Control-Allow-Headers:x-requested-with, content-type'); 

这段代码是我从别的地方复制过来的,不同文章的具体代码不一样,但都是差不多的,只不过多几个头部或者某个头部的值不一样罢了,没什么本质区别。

服务端这样做确实可以通知客户端,让其拒绝服务端返回的响应,但是服务端关于 CORS 的全部工作就这些么?我觉得不是!至少还应该加一些处理,起码应该在适当的时候结束程序的执行,如果服务端都准备告诉客户端拒绝自己的响应了,那发完响应头部,就没必要再执行后续的业务逻辑了,不是么?反过来说,所有的逻辑都执行完了,然后你再加几个头部去告诉客户端把自己拒绝掉,这不是多此一举么?

我知道客户端只要不发送 Origin 头部,就可以绕过服务端的 CORS 处理;我也知道要想提升安全性,必须得做鉴权才行,但就是觉得服务端的 CORS 处理这块还是应该稍微完善一点,不应该只是加几个头部就草草的结束了才对。如果你觉得我的这种想法是有问题且多余的,欢迎留言指正!

最后我列出我的一点代码,代码并没有做过测试,也更没在开发环境和生产环境中使用过,只是想说明一下我觉得完善的 CORS 处理大概应该是什么样子。具体的逻辑主要参考的是阮一峰的这篇文章:《跨域资源共享 CORS 详解》 。有兴趣的伙伴可以去看看。

<?php

// CORS 配置
$config = [
    'allow_origin' => ["http://b.example.com", "http://c.example.com"],
    'allow_method' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    'allow_headers' => ['X-Custom-Header', 'Accept'],
    'expose_headers' => ['Content-Range'],
    'allow_credentials' => true,
    'max_age' => 86400,
];

// CORS 预检请求
if (isCorsPreflightRequest()) {
    // 检查预检请求
    if (checkCorsPreflightRequest($config)) {
        // 通过
        addCorsPreflightRequestHeaders($config);
    } else {
        // 未通过,什么也不做
    }
    // 退出,不进入业务逻辑部分
    exit;
}

// CORS 简单请求
if (isCorsSimpleRequest()) {
    // 检查简单请求
    if (checkCorsSimpleRequest($config)) {
        // 通过
        addCorsSimpleRequestHeaders($config);
    } else {
        // 没通过,退出,不进入业务逻辑部分
        exit;
    }
}

function isCorsPreflightRequest(): bool
{
    return isset($_SERVER['HTTP_ORIGIN'], $_SERVER['REQUEST_METHOD']) &&
        'OPTIONS' === strtoupper($_SERVER['REQUEST_METHOD']);
}

function checkCorsPreflightRequest(array $config): bool
{
    return checkCorsOrigin($config) && checkCorsRequestMethod($config) && checkCorsRequestHeaders($config);
}

function checkCorsOrigin(array $config): bool
{
    return '*' === $config['allow_origin'] || in_array($_SERVER['HTTP_ORIGIN'], $config['allow_origin'], true);
}

function checkCorsRequestMethod(array $config): bool
{
    if (
        isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']) &&
        !in_array(strtoupper($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']), $config['allow_method'], true)
    ) {
        return false;
    }
    return true;
}

function checkCorsRequestHeaders(array $config): bool
{
    if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) {
        $requestHeaders = explode(',', $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']);
        $allowHeaders = array_map(fn($header) => strtoupper($header), $config['allow_headers']);
        foreach ($requestHeaders as $requestHeader) {
            $requestHeader = strtoupper(trim($requestHeader));
            if (!in_array($requestHeader, $allowHeaders, true)) {
                return false;
            }
        }
    }
    return true;
}

function addCorsPreflightRequestHeaders(array $config): void
{
    if ('*' === $config['allow_origin']) {
        header('Access-Control-Allow-Origin: *');
    } else {
        header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
    }
    header('Access-Control-Allow-Methods: ' . implode(',', $config['allow_method']));
    header('Access-Control-Allow-Headers: ' . implode(',', $config['allow_headers']));
    header('Access-Control-Allow-Credentials: ' . ($config['allow_credentials'] ? 'true' : 'false'));
    header('Access-Control-Max-Age: ' . $config['max_age']);
}

function isCorsSimpleRequest(): bool
{
    return isset($_SERVER['HTTP_ORIGIN']);
}

function checkCorsSimpleRequest(array $config): bool
{
    return checkCorsOrigin($config);
}

function addCorsSimpleRequestHeaders(array $config, array $exposeHeaders = []): void
{
    if ('*' === $config['allow_origin']) {
        header('Access-Control-Allow-Origin: *');
    } else {
        header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
    }
    if (empty($exposeHeaders)) {
        header('Access-Control-Allow-Headers: ' . implode(',', $config['expose_headers']));
    } else {
        header('Access-Control-Allow-Headers: ' . implode(',', $exposeHeaders));
    }
    header('Access-Control-Allow-Credentials: ' . ($config['allow_credentials'] ? 'true' : 'false'));
}

// 假设下方是业务逻辑部分的代码
// 省略...

3659 次点击
所在节点    程序员
46 条回复
Kr98
2020-09-02 19:06:07 +08:00
其实楼主想说的等于是实现一个 cors middleware 吧,但这个本来就是服务端正常的处理模式。
可以交给 nginx,也可以交给 middleware,一般不需要自己再加什么东西。
mitu9527
2020-09-02 19:07:42 +08:00
@Jirajine 我都没说框架,你非要说框架把这个处理了,对不上啊。代码都贴出来了,代码有啥问题你说说吧。
VeryZero
2020-09-02 19:09:18 +08:00
同源策略是浏览器做的,所以服务端如何知道自己的返回会被浏览器拦截。如果请求方使用代理中转不走浏览器呢?
ChanKc
2020-09-02 19:15:45 +08:00
我觉得楼主说的是对的
我猜楼主的意思是:如果没有 preflight,不合要求的 CORS 请求应该直接拒绝,不需要执行正常的后端逻辑
首先 preflight 不是必须的,比如说有 preflight cache 的情况下是不会发 preflight 的
但其实更简单些,校验 Origin,不合要求 4xx 完事。这样甚至无所谓客户端到底是不是浏览器了
VeryZero
2020-09-02 19:16:26 +08:00
另外,如果你觉得被拦截下来的响应浪费资源可以自己处理,比如像你代码里的那样。

但是你不觉得这样更加多此一举么?谁会一直请求一个不允许跨域 URL ?

跳过一个偶尔跨域失败的响应真的能省资源吗?如果省资源的意义都没有了,你做特殊处理的意义又在哪
ck65
2020-09-02 19:22:41 +08:00
@mitu9527 应该 exit 就 exit 啊,和自己较什么劲。CORS 就是一纸规范,实现跟着规范走就完了又没人不准你 exit 。
mitu9527
2020-09-02 19:28:05 +08:00
@VeryZero 我在讨论的是不应该去做,你却再说做了应该也没啥影响,这样好么?后续的业务逻辑各种各样,你怎么能确定执行了不会有问题?既然服务端自己都能判断出前端请求是跨域的,就算业务代码执行完成了也会被前端拒绝,那何必还去执行呢?
VeryZero
2020-09-02 19:29:54 +08:00
其实个人觉得预检请求出现的目的之一就是解决题主类似的问题的。

先预检下是否支持跨域,防止在不允许跨域的情况下执行了不应该执行的逻辑,修改了不该修改的数据。
workwonder
2020-09-02 19:36:11 +08:00
个人觉得因为 cors 是可选的,让浏览器做一个保证就满足要求了。又由于 cors 是可以关闭的,加上不是所有请求都是来自浏览器环境的,所以服务器端强制执行 cors 的意义被削弱了。
一般 options 请求没有业务逻辑,而 get 请求没有副作用。针对 get 请求确实可以提前识别出 cors 不满足而提前结束响应,只是可能标准没有强制,正好主流实践也没有关注而已吧。
Lax
2020-09-02 19:38:44 +08:00
ajax 运行之前的 OPTIONS 请求是由浏览器发起的,不是 js 代码发起。上面 @lalalaqwer 回答解释的已经比较清楚了。

在服务器一端来看,OPTIONS /path/to/api 和 GET/POST /path/to/api 是两种不同的请求 [区别也很明显:如果客户端代码发起 post 请求,OPTIONS 请求里不包含请求体,更不要提后面逻辑去处理了] ,本来就应该去做不同的处理。

类似的情况还有 HEAD,服务器端也不能无脑走 GET 的逻辑。
Torpedo
2020-09-02 19:41:44 +08:00
楼主思路就是对的。如果只对浏览器,我记我们后端 java 用的库就是,如果不符合 cors 。就会停止后面的逻辑,返回报错
VeryZero
2020-09-02 20:01:42 +08:00
@Torpedo 刚去试了下 Spring,确实是这样的。直接返回了,不执行后面的逻辑。
no1xsyzy
2020-09-02 20:04:00 +08:00
@mitu9527 #22 @Jirajine 说的框架是指:
上面回复的不少人通常都把 OPTIONS 的问题交给框架了,所以没发生你的问题。

OPTIONS 不应该执行业务逻辑,就好像 GET 你不会突然决定删库。
浏览器完全可能给你发一万个 OPTIONS 而一个业务逻辑相关内容都不发。

——

另外,其实你现在的这段代码就算加了两个 exit; 仍然是有问题的。给你来点恶意负载:

OPTIONS /api HTTP/1.1
Host: example.com

你仍然会执行业务逻辑。
mitu9527
2020-09-02 20:10:51 +08:00
@no1xsyzy 就算不说 OPTIONS,只谈简单请求,不也一样么,如果服务端根据自己的配置已经识别出浏览器发上来的请求是跨域的请求,直接 exit,既不会执行后续代码,浏览器也收不到 Access-Control-*,不挺好么。

这段代码没打算投入使用,就是做演示用的。我也知道框架或 web 服务器可以帮我们做处理,但是觉得讨论问题时把它们拉进来不是更复杂了么。
lalalaqwer
2020-09-02 20:15:12 +08:00
@also24 这里我的确是没有考虑简单请求的情况
JimmyChange
2020-09-02 20:21:05 +08:00
感觉楼主就是想要不向按照标准要求的正常逻辑处理,标准说跨域 Prelight 请求应该返回特定头部字段,楼主说返回啥啊,都知道是非法请求了,还返回啥啊,直接退出函数不就得了

这种思路怎么说,自己公司内部玩可以,但是云服务那种,如果第三方对接,啥头部都不返回,怕不是要被第三方烦死。。。
mitu9527
2020-09-02 20:27:14 +08:00
@JimmyChange 我确实没看过标准,我是参照阮一峰的那篇文章写的,另外我觉得那篇文章中的逻辑也没问题。客户端发请求上来问服务端跨没跨域,服务端如果发现请求没跨域,自然要告诉客户端自己允许什么,如果服务端发现请求是跨域了,都是不是自己的客户,干嘛要告诉客户端自己的规则是什么,直接返回,相当于不理客户端,不是挺合理的么?
also24
2020-09-02 20:28:07 +08:00
@lalalaqwer #35
是的,我的措辞也需要补充,应该分为 『简单请求』和 『需预检请求』来讨论。

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Access_control_CORS
JimmyChange
2020-09-02 20:36:31 +08:00
@mitu9527 所以我说嘛,公司自己的业务你咋写都行,标准里提出这玩意儿,为了保证向后兼容老系统和浏览器,肯定是你怎么做都有正确结果,但你要是考虑内部系统测试、对接第三方,不返回就会神烦
no1xsyzy
2020-09-02 20:49:45 +08:00
@mitu9527 PHP exit 是相当于 deny 么?这我倒是不知道…… 不过有效的关于错误的应答,我能想到如下几个原因:
1. 适应自动重发,不告知 Origin 有问题的话,可能会不断自动重发导致 DDOS ;
2. 考虑 OPTIONS 被缓存的可能性;
3. Service Worker 没有 deny 的选项(不清楚跨域是否会调用 Service Worker );
4. 没有任何错误的话,排障就麻烦了,域名 typo 查了一下午(

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/703603

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX