OPTIONS 方法的使用

OPTIONS 方法用于请求关于目标资源可用的通讯选项的信息,它允许客户端在没有指明特定操作的情况下,了解资源关联的选项、需要或者服务器功能。 OPTIONS 请求即可以针对特定的 URI 地址,也可以使用 * 来对全站使用。

OPTIONS /index.html HTTP/1.1
OPTIONS * HTTP/1.1

最常见的是使用它来检测服务器所支持的请求方法:

  curl -X OPTIONS http://example.org -i

根据 RFC7231 的定义,在使用 OPTIONS 的时候,服务端和客户端都有一些值得注意的地方:

服务器返回一个成功响应给 OPTIONS 请求时应该返回任何头部字段,表明该服务器所实现的一些可选的特性,或者可以应用到目标资源上的,比如 Allow 头部。 服务器的响应体中如果没有返回任何内容,必须返回一个值为 0 的 Content-Length 字段。以下是一个从 Medium 请求中摘取的 OPTIONS 请求相应头:

access-control-allow-credentials: true
access-control-allow-headers: LightStep-Access-Token, Content-Type
access-control-allow-methods: POST
access-control-allow-origin: *
content-length: 0
date: Mon, 23 Dec 2019 16:10:52 GMT
status: 200

客户端生成一个包含请求体的 OPTIONS 请求时,必须发送一个有效的 Content-Type 头部字段来描述响应的媒体类型。虽然目前尚未明确定义请求体的使用场景,未来 HTTP 可能使用它来对目标资源做更详细的查询。 此外,返回给 OPTIONS 方法的响应内容是不可缓存的。

CORS

因为浏览器有*同源策略*的限制,当应用从与其当前所在的地址不同的的源地址(协议不同、域名不同或端口不同)请求资源时,就会发生跨域请求。在 XHR 或者 FETCH 请求以及 ctx.drawImage 方法把图片或者视频绘制到画布的时候都有可能遇到跨域问题。

CORS(Cross-Origin Resource Sharing) 定义了在必须访问跨域资源时,浏览器应该如何与服务器进行通讯。CORS 的本质,就是使用自定义的 HTTP 头部让浏览器与服务器进行沟通,从而决定请求或响应时应该成功,还是应该失败。

CORS 预检请求

CORS 通过一种叫做 Preflighted Requests 的透明服务器验证机制支持开发人员使用自定义的头部、GET 或者 POSTHEAD 之外的方法,以及不同类型的主体内容。

意思就是,当满足某些条件时,不会触发 CORS 的预检请求,这种请求我们称之为「简单请求」,它需要满足以下条件:

使用以下任一方法:
  1. GET
  2. HEAD
  3. POST
只能使用以下头部字段 :

Fetch 规范把它们定义为 对 CORS 安全的首部字段集合 * Accept * Accept-Language * Content-Language * Content-Type (需要注意额外的限制) * DPR * Downlink * Save-Data * Viewport-Width * Width

Content-Type 的值仅限于下列三者之一:
  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

    请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。
    请求中没有使用 ReadableStream 对象。

    下面两个例子:

    const xhr = new XMLHttpRequest();
        xhr.onreadystatechange = () => {
            if (xhr.readyState === 4) {
                if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
                    document.body.innerText = xhr.responseText;
                } else {
                    console.log('Request failed: ', xhr.status);
                }
            }
        }
        xhr.open('POST', 'http://127.0.0.1:3000/message', true);
        xhr.send(null);
    
    // Request Headers
    Accept: */*
    Accept-Encoding: gzip, deflate, br
    Accept-Language: en,zh-CN;q=0.9,zh;q=0.8,zh-TW;q=0.7,fr;q=0.6,zu;q=0.5,ja;q=0.4
    Connection: keep-alive
    Content-Length: 0
    Host: 127.0.0.1:3000
    Origin: http://127.0.0.1:4000
    Referer: http://127.0.0.1:4000/
    Sec-Fetch-Mode: cors
    Sec-Fetch-Site: same-site
    User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36
    

    在上面的代码中,POST 方法符合规则,是一个简单的请求,所以不会发起预检请求。 不符合简单请求条件的请求我们称为「需预检的请求」。假设我们给刚才的请求加上一个额外的头部 Content-Type ,它不属于 text/plain multipart/form-data application/x-www-form-urlencoded 三者之一,在发起真正请求之前会先发送一个 OPTIONS 预检请求到服务器。

const xhr = new XMLHttpRequest();
        xhr.onreadystatechange = () => {
            if (xhr.readyState === 4) {
                if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
                    document.body.innerText = xhr.responseText;
                } else {
                    console.log('Request failed: ', xhr.status);
                }
            }
        }
        xhr.open('POST', 'http://127.0.0.1:3000/message', true);
        xhr.setRequestHeader("Content-Type", "application/json");
        xhr.send(null);

OPTIONS 请求头部:

Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8,zh-TW;q=0.7,fr;q=0.6,zu;q=0.5,ja;q=0.4
Access-Control-Request-Headers: content-type // new header
Access-Control-Request-Method: POST // new header
Connection: keep-alive
Host: 127.0.0.1:3000
Origin: http://127.0.0.1:4000
Referer: http://127.0.0.1:4000/
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36

可以看到,此时浏览器会在 OPTIONS 请求中发送两个往外的请求,服务器收到请求后决定是否允许该类型的请求,在响应中发送以下头部进行沟通:

  • Access-Control-Allow-Origin: 与简单的请求相同
  • Access-Control-Allow-Methods: 允许的方法,以逗号隔开
  • Access-Control-Allow-Headers: 允许头部,多个头部以逗号隔开
  • Access-Control-Max-Age: 这个 OPTIONS 请求缓存多久(以秒表示)

以下是一段简单的使用 express 的处理代码:

const express = require('express');
const app = express();
const WHITE_LIST = ['http://127.0.0.1:4000']
app.use((req, res, next) => {
    const { origin } = req.headers;
    console.log('options headers', req.headers);
    if (WHITE_LIST.includes(origin)) {
        const headers = {
            'Access-Control-Allow-Origin': origin,
            'Access-Control-Allow-Methods': '*'
        }
        const reqHeader = req.headers['access-control-request-headers'];
        if (reqHeader) {
            headers['Access-Control-Allow-Headers'] = reqHeader;
        }
        res.set(headers);
        if (req.method === 'OPTIONS') {
            res.end();
        }
    }
    next();
})

要注意的是,简单请求和预检请求的主要区别在于,是否要发送额外的 OPTIONS 请求来检验服务器是否支持发送的方法或者自定义头部。

带身份凭证的请求

默认情况下,跨域请求不发送 cookie ,如果想要发送凭据,需要为该请求指定 xhr.withCredentials = true 。此时,服务器需要在响应中返回:

Access-Control-Allow-Credentials: true

否则,请求会被忽略,浏览器不会把响应的内容返回给客户端。此外,对于附带身份凭证的请求,服务器不得设置 Access-Control-Allow-Origin 的值为 * 。这是因为请求的首部中携带了 Cookie 信息,如果设置 Access-Control-Allow-Origin: '*' ,请求将会失败,需要设置 Access-Control-Allow-Origin: req.headers.origin