跨源资源共享(Cross-Origin Resource Sharing,CORS

是一种基于 HTTP 的机制(划重点),该机制通过允许服务器标示除了它自己以外的其他(域、协议或端口),使得浏览器允许这些源访问加载自己的资源。

跨源 HTTP 请求的一个例子:运行在 domain-a.com 的 JavaScript 代码使用 XMLHttpRequest 来发起一个到 domain-b.com/data.json 的请求。

出于安全性,浏览器限制脚本内发起的跨源 HTTP 请求。例如,XMLHttpRequest 和 Fetch API 遵循同源策略。这意味着使用这些 API 的 Web 应用程序只能从加载应用程序的同一个域请求 HTTP 资源,除非响应报文包含了正确 CORS 响应头


CORS 响应头

前面提到 CORS 是一种基于 HTTP 头的机制,这些 HTTP 头决定了浏览器是否阻止前端 JavaScript 代码获取跨域资源请求的响应

因此想要了解跨域必须先了解有哪些相关的 header、

Access-Control-Allow-Origin

指示响应的资源是否可以被给定的来源共享。

有效值 :* | | null

对于不包含凭据的请求,服务器会以“*”作为通配符,从而允许任意来源的请求代码都具有访问资源的权限。

尝试使用通配符来响应包含凭据的请求会导致报错。

凭据(Credentials) 通常是指 Cookie、HTTP 认证、TLS 客户端证书等敏感信息

指定一个来源(只能指定一个)。如果服务器支持多个来源的客户端,其必须以与指定客户端匹配的来源来响应请求。

Access-Control-Allow-Credentials

指示当前请求的凭证标记为 true 时,是否可以公开对该请求响应。

用于在请求要求包含凭据(credentials)时,告知浏览器是否可以将请求的响应暴露给前端 JavaScript 代码。

当作为对预检请求的响应的一部分时,这能表示是否真正的请求可以使用 credentials。

Access-Control-Allow-Credentials标头需要与XMLHttpRequest.withCredentials或 Fetch API 的Request()构造函数中的credentials选项结合使用。Credentials 必须在前后端都被配置才能使带 credentials 的 CORS 请求成功。

有效值:true 唯一的有效值。

Access-Control-Allow-Headers

用于对预检请求的响应中,指示实际的请求中可以使用哪些 HTTP 标头。

如果请求中含有Access-Control-Request-Headers字段,那么这个首部是必要的。

注意以下特定首部是一直允许的:Accept,Accept-Language,Content-Language,Content-Type(只在值属于 MIME 类型 application/x-www-form-urlencoded,multipart/form-datatext/plain中的一种时)。这些被称作 simple headers,无需特意声明它们。

有效值:

* (wildcard 通配符)

对于没有凭据的请求(没有 HTTP cookie 或 HTTP 认证信息的请求),值”*”仅作为特殊通配符值。在具有凭据局的请求中,它被视为没有特殊语义的文字标头名称 ”*”。

<header-name> header 字段名

Authorization 标头不能使用通配符,并且始终需要明确列出。

Access-Control-Allow-Methods

响应部首 Access-Control-Allow-Methosd在对 preflight request (预检请求)的应答中明确了客户端所要访问的资源允许使用的方法或方法列表。

有效值: <method> 用逗号隔开的允许使用的 HTTP request methods 列表。

Access-Control-Expose-Headers

允许服务器指示哪些响应标头可以暴露给浏览器中运行的脚本,以响应跨源请求。

默认情况下,仅暴露 CORS 安全列表的响应标头,如果想要让客户端可以访问到其他的标头,服务器必须将它们在 Access-Control-Expose-Headers 里列出来。

有效值:

<header-name> 允许客户端从响应中访问的 0 个或多个使用逗号分隔的标头名称

*(wildcard 通配符)若没有携带凭据才会被当做一个特殊的通配符。对于带有凭据的请求,会被简单地当作标头名称”*”,没有特殊的语义。不会匹配 Authorization,如果要暴露它需要显式指定。

Access-Control-Max-Age

表示预检请求的结果可以被缓存多久。

有效值:<delta-seconds>

返回结果可以被缓存的最长时间(秒)。在 Firefox 中上限是 24 小时(即 86400 秒)。在 Chromium v76 之前,上限是 10 分钟(即 600 秒),之后是 2 小时(即 7200 秒)。Chromium 同时规定了一个默认值 5 秒。如果值为 -1,则表示禁用缓存,则每次请求前都需要使用 OPTIONS 预检请求。

Access-Control-Request-Headers

出现于 preflight request(预检请求)中,用于通知服务器在真正的请求中会采用哪些请求头。

有效值:<header-name> 在实际请求中将要包含的一系列 HTTP 头,以逗号分隔。

Access-Control-Request-Method

出现于 preflight request(预检请求)中,用于通知服务器在真正的请求中会采用哪种 HTTP 方法。因为预检请求所使用的方法总是 OPTIONS,与实际请求所使用的方法不一样,所以这个请求头是必要的。

有效值: <method> 一种 HTTP 请求方法,例如 GET、POST 或 DELETE。

Origin

表示请求的来源(协议、主机、端口)。例如,如果一个用户代理需要请求一个页面中包含的资源,或者执行脚本中的 HTTP 请求(fetch),那么该页面的来源(origin)就可能被包含在这次请求中。

有效值:

null请求来源是“隐私敏感”的,或者是 HTML 规范定义的不透明来源

<scheme>请求所使用协议,通常是 HTTP 协议或者它的安全版本(HTTPS 协议)。

<hostname>源站的域名或 IP 地址。

port(可选)服务器正在监听的端口号。缺省为服务器的默认端口(对于 HTTP 请求而言,默认端口为 80)。


代码实践

先创建一个 node 服务器

Hello world!

  1. 安装 express.js

    1. yarn init
    2. yarn add express
  2. 应用代码
    新建 app.js 写入下面代码

 1const express = require('express')
 2const app = express()
 3const port = 3000
 4
 5app.get('/', (req, res) => {
 6  res.send('Hello World!')
 7})
 8
 9app.listen(port, () => {
10  console.log(`Example app listening on port ${port}`)
11})
  1. 启动程序
    运行 node app.js 控制台会打印出 ‘Example app listening on port 3000’
    打开浏览器,访问http://localhost:3000/即可看到程序响应 ‘Hello World!’

使用 express-generator 搭建程序框架

  • npx express-generator

  • 启动服务

    • DEBUG=myapp:* & yarn start(在 zsh 中会报错,因为 ’&’ 是一个特殊字符表示将命令放入后台运行,可以使用分号分割命令,效果是相同的。)
    • DEBUG=myqpp 是配置环境变量用于调试
  • 【可选】使用 nodemon 配置热更新,不配置热更新时每次修改后需要手动重启程序

    • yarn add nodemon
    • 添加 nodemon.json 配置
 1{
 2  "watch": ["app.js", "routes/", "views/", "bin/"],
 3  "ext": "js,json",
 4  "ignore": ["node_modules/"]
 5}
    • 使用 nodemon 启动程序 nodemon ./bin/www,也可配置在package.json中配置
 1"scripts": {
 2  "start": "nodemon ./bin/www"
 3},

注意:热更新需要修改 bin 目录下的 www 文件名为 www.js,才能正确的监听变化

服务端代码

查看服务端代码的目录结构如下

bin/www 是程序入口,http 服务在这个文件中创建并启动

public/ 静态资源

routes/ 子路由存放位置,程序 API 都以模块的形式组织在这个文件夹中

views/ 存放视图模板(404 等基础页面)

app.js 程序主体,负责创建程序,挂载路由等操作

bin/www 的主要内容

 1var app = require('../app')
 2var http = require('http')
 3
 4var port = normalizePort(process.env.PORT || '3000')
 5app.set('port', port)
 6
 7var server = http.createServer(app)
 8
 9server.listen(port)

app的主要内容

 1var express = require("express")
 2var path = require("path")
 3var cookieParser = require("cookie-parser")
 4
 5var app = express()
 6
 7
 8app.set("views", path.join(__dirname, "views"))
 9app.set("view engine", "jade")
10
11
12app.use(express.json()) 
13app.use(express.urlencoded({ extended: false })) 
14app.use(cookieParser()) 
15
16
17
18app.use('/static',express.static(path.join(__dirname, "public"),{
19  setHeaders:function(res,path,stat){
20    res.header('Access-Control-Allow-Origin','*') 
21  }
22}))
23
24
25var indexRouter = require("./routes/index")
26var usersRouter = require("./routes/users")
27const testRouter = require('./routes/test')
28
29
30app.use("/", indexRouter)
31app.use("/users", usersRouter)
32app.use('/api', testRouter)
33
34
35app.use(function (req, res, next) {
36  next(createError(404))
37})
38
39
40app.use(function (err, req, res, next) {
41  
42  res.locals.message = err.message
43  res.locals.error = req.app.get("env") === "development" ? err : {}
44
45  
46  res.status(err.status || 500)
47  res.render("error")
48})
49
50module.exports = app

get 请求

创建一个支持跨域的 get 请求只需要在 responseheader 上添加对应的标识即可,routes/test.js 文件内容

 1var express = require('express')
 2var router = express.Router()
 3
 4/* GET home page. */
 5router.get('/', function(req, res, next) {
 6  // 允许跨域配置,get 的跨域请求也需要配置
 7  res.header('Access-Control-Allow-Origin', '*')
 8  res.send('success response')
 9})
10
11module.exports = router

前端 axios 请求代码

 1axios.get('/api',{ params: { name: "Ginlon" } }).then(res => {
 2  console.log(res.headers)
 3})

post 请求

服务端

 1router.post("/update", function (req, res, next) {
 2  res.header("Access-Control-Allow-Origin", "*")
 3  res.send("success update")
 4})

前端

 1axios.post("/api/update").then((res) => {
 2  console.log(res.data)
 3})

简单地 post 请求依然可以通过配置 Access-Control-Allow-Origin: * 来进行跨域请求,但是我们的 post 请求携带参数时,如果只配置 Access-Control-Allow-Origin 仍然会被跨域拦截,并且可以看到浏览器发起了一个 preflight 预检请求

由于跨域机制是由 httpheader 控制的因此通过对比两次 post 请求的 header 不难发现,携带参数的 post请求的 header 中多了一个 Content-Type 项,这是 axios 为了使服务端可以正确的解析字符串而自动添加的标识。

由于整个跨域系统都是基于 header 的,查看前面的 header 说明,不难发现 Access-Control-Allow-Headers中的描述

Content-Type只在值属于 MIME 类型 application/x-www-form-urlencoded,multipart/form-datatext/plain中的一种时,才被称作 simple headers,而无需特意声明

而我们此处的application/json 显然不在其中,因此我们只要在服务端添加一个对应的 options 请求的配置即可

 1router.options("/update", function (req, res, next) {
 2  res.header("Access-Control-Allow-Origin", "*") 
 3  res.header("Access-Control-Allow-Headers", "Content-Type") 
 4  res.send() 
 5  res.end()
 6})

这样 post 请求就可以正常访问了。

为了知道 ContentType 的作用可以使用 XMLHttpRequest 自己创建一个 POST 请求,不添加Content-Type

 1const xhr = new XMLHttpRequest()
 2xhr.onreadystatechange = function () {
 3  if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
 4    console.log(xhr.responseText)
 5  }
 6}
 7xhr.open("POST", "http://localhost:3000/api/update", true)
 8xhr.send(
 9  JSON.stringify({
10    name: "Ginlon",
11    age: "18",
12  })
13)

可以在服务端使用 console.log 打印 request.body 来查看两次请求什么不一样

 1router.post("/update", function (req, res, next) {
 2  console.log(req.body)
 3  res.header("Access-Control-Allow-Origin", "*")
 4  
 5  res.send("success response api")
 6  res.end()
 7})

可以看到上面的日志是 axios 发出的带有 Content-Type 的 post 请求的输出,

下面是我们自己创建的 不带 Content-Type 的 post 请求,可以发现当缺少 Content-Type 时服务端负责解析的中间件就不能够识别到 request 中携带的数据。

vite 的跨域配置

由于 vite 服务器默认运行在 127.0.0.1:5137 端口,与服务器的 http://localhost:3000 不一致,因此会触发浏览器的跨域机制。

有两种解决方式,一是服务端进行配置,在接口中添加跨域相关的 http header,这样前端就可以直接访问服务器地址无需额外的处理。

服务端无法提供支持时,前端可以自己搭建服务器转发请求,再将结果返回给浏览器从而避免浏览器的跨域限制。

通过添加配置,vite 可以帮我们快速的搭建一个本地服务器转发请求。

 1 defineConfig({
 2   server: {
 3    proxy: {
 4      // 带选项写法:http://127.0.0.1:5173/api/bar -> http://localhost:3000/bar
 5      "/api": {
 6        target: "http://localhost:3000",
 7        changeOrigin: true,
 8        rewrite: (path) => path.replace(/^/api/, ""),
 9      },
10    },
11  },
12 })

这是一个典型的配置,官网有很详细的说明这里不做重点展开。

只看配置似懂非懂,我们可以通过发起一个跨域请求,简单地分析一下 vite 是如何完成转发的来加深理解

整个过程大体可以分为三步

  1. 浏览器向 vite 服务器发送请求
  2. vite 服务器接收请求并根据配置的转发规则转发请求
  3. vite 服务器收到响应,并将响应返回给浏览器

首先我们发起一个请求

 1axios.get('/api/someApi')

js 发起的 get 请求也会触发跨域,在地址栏中直接访问 get 地址不会跨域(因为不是 js 发起的请求,而是直接由浏览器内部进程发起的请求)

首先这个请求的地址并不是一个完整的 URL,因此浏览器会将它视为一个相对路径,而我们的页面根目录本就是 vite 服务器,于是就完成了第一步,浏览器向 vite 服务器发送请求,完整的请求地址可以在网络面板看到

值得一提的是,很多项目会将服务器地址封装到 axios 的 baseURL 中,这会使路径成为绝对路径,而无法发送到 vite 服务器,自然就不会通过 vite 服务器转发,因此如果后端不进行对应的配置,直接添加 baseURL 会导致跨域问题。

第二步,vite 接收到了来自浏览器的请求,于是 vite 会在 proxy 查找匹配的转发规则,当匹配到相应的转发规则时,vite 会根据相应的配置重新拼装路径(target、rewrite),然后使用 nodejs 的 http api 向目标地址发送请求。(websocket 和 https 同理)

第三步,vite 会收到服务器的响应,由于响应是由本地 vite 服务器接收,而不是浏览器,因此即使后端不添加跨域相关的 header,我们也可以拿到响应信息,然后 vite 会将响应返回给浏览器,至此就完成了整个请求。

整个过程对浏览器而言只是与本地的 vite 服务器进行交互,浏览器自始至终都只访问了127.0.0.1:5173因此也就不存在跨域问题了。

同源请求时,服务端可以通过在响应头中添加Set-Cookie字段来设置 cookie

 1router.get("/setCookie", function (req, res, next) {
 2  res.cookie("remember-me", "2", {
 3    expires: new Date(Date.now() + 900000),
 4  })
 5  res.send()
 6})

查看 http 的 response 头可以看到Set-Cookie字段,其包含了要设置的属性值和一些配置选项,具体每个配置的作用就不在此赘述。

Set-Cookie会被浏览器从响应头中过滤掉,而不传给 javascript 脚本,但依然可以通过 document.cookie 访问到 cookie,如果不想前端访问 cookie 则可以在发送 cookie 时设置httpOnly属性,这样前端就无法通过 document.cookie 访问和修改对应的 cookie。

注意:我们本地启动的前端地址和服务端地址并不同源,因此我们可以通过配置 vite 的 server.proxy 实现转发请求,从而绕过跨域机制,完成同源设置 cookie 的请求。

上面的方法只能用于同源的请求,当跨域时即使服务端添加了Access-Control-Allow-Origin也不能通过Set-Cookie实现跨域的 cookie 设置,浏览器会自动过滤掉相关的 header。

为了限制 cookie 的滥用,浏览器禁止了跨域传递 cookie,当想要跨域传递 cookie 时则必须设置SameSite属性,只有当SameSite设置为None时才能够跨域传递 cookie,现在设置SameSite=None属性的 cookie 必须同时设置Secure属性,也就是说只能用于安全上下文(https)。

创建 https 服务,需要使用证书和密钥,自己的 demo 可以通过自签名证书来提供 https 服务。

即使使用了 https 服务也只是能够与当前跨域站点通信时设置和携带 cookie,这些 cookie 仍然不会在第三方请求时携带。

img 和 canvas 的跨域问题

通常 img 标签加载的图像数据不与 Javascript 交互,因此 img 标签是被允许加载跨域图像的,但是如果想要通过 Javascript 访问图像数据时就会被浏览器的跨域策略阻止,比如使用 canvas 的 getImageData api 访问图像数据时浏览器会报出如下错误

如果想要 canvas 可以访问 img 中的图像数据,就需要配置 img 标签的crossorigin属性,添加了corssorigin属性的图像会使用 CORS 完成图像资源的抓取,通过 CORS 获取到的图像不会被标记为“污染(tainted)”,便可以使用 Javascript 访问图像的数据。

crossorigin允许的值:

anonymous发送忽略凭据的跨域请求(不携带 cookie,X.509证书或Authorization标头)

use-credentials发送携带凭据的跨域请求,如果服务端没有配置Access-Control-Allow-Credentials:true响应标头浏览器会将图片标记为被污染,且限制对图像数据的访问。

类似的 video、audio、svg 标签也存在同样地问题,video、audio 也有crossorigin属性,前端使用了crossorigin 属性后服务端也需要添加对应的 header。

个人笔记记录 2021 ~ 2025