跨域问题:CORS、JSONP 与反向代理

跨域问题是 Web 开发中经常遇到的一个挑战,解决方法多样,例如使用反向代理、CORS、JSONP 方案。本文将为大家深入探讨一下跨域方案的具体实现。

同源策略

浏览器通过实施同源策略(Same Origin Policy)保障用户安全:若两个页面来自同一源,则允许彼此交互。这里的“同一源”指的是页面的域名端口协议完全一致。

小练习

以下 URL 分别与 https://www.example.com 是否同源?

URL 同源?
https://www.example.com/profile - 协议、域名和端口匹配,即使路径不同
http://www.example.com - 协议不同
https://www.anotherwebsite.com - 域名不同
https://www.example.com:8080 - 端口不同

同源策略可防止恶意网站在用户被诱骗访问其他网站时从其他网站读取敏感数据。例如,黑客的网站不能包含来自 Facebook 的 HTML,不然的话访问他们的网站,则可以轻松抓取您的个人资料。

1
2
3
4
5
6
7
8
// hack-attempt.js
/**
 * 尝试访问用户资料
 */
fetch("https://www.facebooke.com/profile").catch((err) => {
  // 浏览器会因为页面位于不同来源(origin)而导致此代码失败。
  // 这样可以防止黑客抓取数据。
});

解决跨域问题

当网页中的 JavaScript 发起跨域 Ajax 请求时,这个请求会被发送出去吗?

实际上,大多数情况下,即便浏览器检测到跨域,请求依然会被发送至服务器。不过,请求成功发出并不意味着一定能获取到结果。当然,像 CSP(Content-Security-Policy,内容安全策略)这类特殊场景,不在本次讨论范围内。

当服务器接收到请求后,会返回一个响应,该响应随后到达浏览器。浏览器会执行一些校验,以确认请求是否允许通过。

如果校验成功,浏览器将把请求的结果,即服务器的响应结果交付给 JavaScript,使得 JavaScript 能够正常触发事件并接收服务器的响应。相反,如果浏览器的校验未通过,将引发一个跨域错误。我们平时遇到的各类跨域错误,根源都是浏览器的校验未通过

此外对于静态资源,如图片、视频、音频,默认允许跨域加载,无需 CORS;但当你需要使用相关 API (如:Canvas)对其进行处理时,又会需要启用 CORS。

理解了跨域问题的产生原因后,解决思路其实很清晰:只要让请求通过浏览器的校验即可。

要实现这一点,首先需要明确校验的依据,即 CORS 规则。采用 CORS 方案解决跨域问题,本质上就是让请求满足 CORS 校验规则,从而顺利通过校验。接下来,我们将深入剖析 CORS 规则的具体内容,明确其核心机制,并探讨如何让请求符合规则以通过校验。

CORS

跨源资源共享(CORS,Cross-Origin Resource Sharing)是一种基于 HTTP 头的机制,用于浏览器校验跨域请求,它的基本理念是:

  1. 只要服务器明确表示允许,则校验通过
  2. 服务器明确拒绝没有表示,则校验不通过

使用 CORS 的前提:必须保证服务器是“自己人”。

校验规则

CORS 将请求分为两类:简单请求预检请求

简单请求和预检请求的校验方式不同,简单请求的校验过程较为宽松,而预检请求的校验过程则更为严格

那么,什么请求是简单请求,什么请求是预检请求?简单请求怎么校验,预检请求又是怎么校验的?

简单请求和预检请求

简单请求有三个条件,这些条件必须全部得到满足

  1. 请求方法为:GETHEADPOST(列入 CORS 白名单的请求标头方法,CORS-safelisted method
  2. 头部字段满足 CORS 安全规范(列入 CORS 白名单的请求标头,CORS-safelisted request header
  3. 请求头的 Content-Type 为:
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded

如果有一项条件未被满足,那么该请求将被视为预检请求(Preflight)。

接下来,我们将通过一些示例来区分简单请求与预检请求。

首先需要确保浏览器开发者工具的网络中已勾选显示“方法”一列。

例 1

1
fetch("https://www.douyin.com");

请求方法默认为 GET,头部未动,且 Content-Type 未更改,则该请求为简单请求。

例 2

1
2
3
4
5
fetch("https://douyin.com", {
  headers: {
    a: 1,
  },
});

这是一个 GET 请求,但操作了头部,导致头部不再满足安全规范,那么这个请求就变成了一个预检请求。

所以,GET 请求不一定是预检请求。

例 3

1
2
3
4
fetch("https://www.douyin.com", {
  method: "POST",
  body: JSON.stringify({ a: 1, b: 2 }),
});

这是一个 POST 请求,那 POST 一定是预检吗?我们通过测试发现这是一个简单请求。上面说的 POST 方法是满足的,也没有动头部,所以这是一个简单请求。

因此,POST 请求也不一定是预检请求。

部分第三方库,如 axios,在发送 POST 请求时,通常内部会有一些处理,会自动修改 Content-Type,不满足上述第三条,因此会变成预检请求,等同于例 4。

1
2
axios.post("https://www.douyin.com", { a: 1, b: 2 });
// Content-Type: application/json

例 4

1
2
3
4
5
6
7
fetch("https://www.douyin.com", {
  method: "POST",
  headers: {
    "content-type": "application/json",
  },
  body: JSON.stringify({ a: 1, b: 2 }),
});

预检请求

简单请求的校验方式

简单请求的校验过程非常简单。当用户发起请求时,浏览器会自动添加 Origin 头,其中包含请求来源页面的完整信息(协议、域名、端口)。服务器接收请求后,将依据该头信息判断是否允许跨域访问。

若服务器允许访问,会通过以下两种方式告知浏览器:

  1. Access-Control-Allow-Origin 响应头设置为请求时的 Origin 值;
  2. 直接将 Access-Control-Allow-Origin 响应头设为 *,表示允许所有域名访问。

在实际应用中,切忌为图简便而直接使用星号,应明确指定具体的 Origin,否则可能引发安全隐患及细节问题。

此外需要注意一个细节:Access-Control-Allow-Origin 仅能定义单个 Origin。若需允许多个 Origin 跨域且不使用星号,需在服务器端配置动态规则,仅返回当前请求对应的 Origin 即可。

预检请求的校验方式

预检请求的执行分为两个步骤,流程如下:

  1. 发送预检请求

    • 浏览器首先向服务器发起询问(采用OPTIONS方法)。例如,来源为http://my.com的页面计划通过POST方法请求服务器,且需携带abcontent-type等请求头时,会先触发预检。
    • 此时不会发送真实的请求体。
    • 若服务器拒绝该预检请求,真实请求将不会发送。
    • 若服务器同意,则会返回以下响应头:
      • Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers:明确允许的来源、方法和请求头
      • Access-Control-Max-Age:指定预检结果的缓存时间(TTL),在该时间内,相同情况的请求无需重复执行预检,可减少请求次数

  2. 发送真实请求

    • 流程与简单请求一致

小练习

  1. 领导要求前端通过 CORS 自行解决页面跨域问题,前端能否独立完成?
  2. 前端在页面中用 Ajax 调用抖音 API 时遇到跨域问题,能否通过 CORS 自行解决?
  3. 跨域上传图片正常,但提交普通表单却出现跨域问题,可能的原因是什么?

JSONP

JSONP(JSON with Padding)是解决跨域问题的古老方案。

JSONP 利用了同源策略中,对 script 标签的跨域请求限制较小这一点实现跨域。

实现

JSONP 通过动态创建 script标签,绕过浏览器同源策略限制,服务器返回执行回调函数的 JavaScript 代码,客户端预先定义函数接收数据,实现跨域数据获取,适用于无 CORS 支持的旧项目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function callback(resp) {
  console.log(resp);
}

function request(url) {
  const script = document.createElement("script");
  script.src = url;
  script.onload = function () {
    script.remove();
  };
  document.body.appendChild(script);
}

document.querySelector("button").onclick = function () {
  request("http://localhost:8000/api/user");
};

服务器返回:

1
2
3
4
5
callback([
  { name: "monica", age: 17, sex: "female" },
  { name: "姬成", age: 27, sex: "male" },
  { name: "邓旭明", age: 37, sex: "male" },
]);

示例

这里以 Hitokoto 一言接口为例:

1
2
3
4
5
6
7
<p id="hitokoto">
  <a href="#" id="hitokoto_text">:D 获取中...</a>
</p>
<script
  src="https://v1.hitokoto.cn/?encode=js&select=%23hitokoto"
  defer
></script>

其 JavaScript 中的内容如下,其var hitokoto= 后面的内容每次都不一样,从而实现动态随机的效果:

1
2
3
4
5
6
7
(function hitokoto() {
  var hitokoto = "刀剑无眼,匠人有情。人情二字。不就是人类最引以为傲的事物吗?";
  var dom = document.querySelector("#hitokoto");
  Array.isArray(dom)
    ? (dom[0].innerText = hitokoto)
    : (dom.innerText = hitokoto);
})();

可以看到这个东西权限很大,甚至可以改 DOM,所以存在一定安全风险,尤其是外站链接。网站开发者和管理员应该警惕这种方式。

反向代理

对于一些来自外部的接口,我们通常无法直接为其添加 Access-Control-Allow-* 系列响应头。此时,可通过反向代理方案解决:将目标接口代理至我们的服务器,再由我们的服务器为其补充所需的响应头。下面会给出一些具体的配置示例。

使用 Express

对于 NodeJS 开发,可以使用 Express 的 cors 中间件实现跨域反向代理。

https://expressjs.com/en/resources/middleware/cors.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const express = require("express");
const cors = require("cors");
const { createProxyMiddleware } = require("http-proxy-middleware");

const app = express();
app.use(cors());
app.use(
  createProxyMiddleware({
    router: (req) => new URL(req.path.substring(1)),
    pathRewrite: (path, req) => new URL(req.path.substring(1)).pathname,
    changeOrigin: true,
    logger: console,
  })
);

app.listen(8088, () => {
  console.info("proxy server is running on port 8088");
});

使用 nginx

这里以 nginx 为例,给出下面的参考配置:

前端和后端都在同一个域名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
server {
    listen 80;
    server_name a.com;

    location / {
        root html;
        index index.html index.htm;
    }

    location /api {
        proxy_pass http://api:8000;
        # proxy_pass http://api:8000/;

        # HTTP 协议版本和连接设置
        proxy_http_version 1.1;
        proxy_set_header Connection "";

        # 传递客户端信息
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

前端和后端不在同一个域名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
server {
    listen 80;
    server_name a.com;

    location / {
        root html;
        index index.html index.htm;
    }

}

server {
    listen 80;
    server_name api.a.com;

    location / {
        proxy_pass http://api:8000;

        # HTTP 协议版本和连接设置
        proxy_http_version 1.1;
        proxy_set_header Connection "";

        # 传递客户端信息
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # CORS 设置
        add_header Access-Control-Allow-Origin "http://a.com";
        add_header Access-Control-Allow-Credentials true;
        # OPTIONS 预检请求需要完整的 CORS 头部
        if ($request_method = 'OPTIONS') {
            add_header Access-Control-Allow-Origin "http://a.com";
            add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
            add_header Access-Control-Allow-Headers "Content-Type, Authorization";
            add_header Access-Control-Allow-Credentials true;
            return 204;
        }
    }
}

上述代码仅供参考,请根据实际情况调整。注意 NGINX 中的 if is evil,需要测试是否能够正常工作。

使用 Vite

在项目的开发环境中,如果遇到跨域问题,可以使用 Vite 对接口进行反代。

详情请看官方文档:https://cn.vite.dev/config/server-options.html#server-proxy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
export default defineConfig({
  server: {
    proxy: {
      // 字符串简写写法:
      // http://localhost:5173/foo
      // -> http://localhost:4567/foo
      "/foo": "http://localhost:4567",
      // 带选项写法:
      // http://localhost:5173/api/bar
      // -> http://jsonplaceholder.typicode.com/bar
      "/api": {
        target: "http://jsonplaceholder.typicode.com",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
      // 正则表达式写法:
      // http://localhost:5173/fallback/
      // -> http://jsonplaceholder.typicode.com/
      "^/fallback/.*": {
        target: "http://jsonplaceholder.typicode.com",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/fallback/, ""),
      },
      // 使用 proxy 实例
      "/api": {
        target: "http://jsonplaceholder.typicode.com",
        changeOrigin: true,
        configure: (proxy, options) => {
          // proxy 是 'http-proxy' 的实例
        },
      },
      // 代理 websockets 或 socket.io 写法:
      // ws://localhost:5173/socket.io
      // -> ws://localhost:5174/socket.io
      // 在使用 `rewriteWsOrigin` 时要特别谨慎,因为这可能会让
      // 代理服务器暴露在 CSRF 攻击之下
      "/socket.io": {
        target: "ws://localhost:5174",
        ws: true,
        rewriteWsOrigin: true,
      },
    },
  },
});

总结

graph TD
    A[前端遇到跨域问题] --> B{是否能更改后端?}
    B --是--> C{浏览器是否支持跨域?}
    B --否--> D[反向代理]
    C --支持--> E[CORS]
    C --不支持--> F[JSONP]