CORS 预检询问浏览器是否可以发送某个具体的跨源请求,内容包括发起页面的源、计划使用的方法和相关请求头名称。浏览器会先判断预检响应是否允许,再发送实际请求;已有有效预检缓存时,则可能省去这次询问。CORS 同时约束脚本能否读取实际响应。发送许可与响应读取是相互关联的两次检查,但任何一次都不能代替业务身份认证和权限控制。

预检由请求形式触发

源由协议、主机和端口组成,任一部分变化都可能使请求跨源。浏览器不会猜测服务器是否修改数据,再据此决定要不要预检。符合条件的 text/plain 类型 POST 可以直接发送;反而一个仅仅读取数据、却带有 Authorization 头的请求通常需要预检。方法、头部取值以及 Fetch 定义的其他条件共同决定请求是否属于安全列出的范围,“读取不用、写入必用”不是正确规则。

JSON POST 通常需要预检,因为 application/json 不是安全列出的内容类型。浏览器先向目标发出 OPTIONS,携带 Origin、值为 POST 的 Access-Control-Request-Method,以及包含 content-type 的 Access-Control-Request-Headers。这里发送的是未来头部的名称,不是其中可能包含的凭据值。服务器需要允许对应源、方法和相关字段,浏览器才会继续发送这一次实际请求。

两个响应都需要正确配置

受限接口应按完整源进行精确白名单匹配,并在 Access-Control-Allow-Origin 中返回一个允许的源。不能返回逗号分隔的源列表,也不能无条件照抄任意输入。公开给所有来源、且请求不带凭据的资源可以使用星号;携带凭据的访问则需要明确的源和 Access-Control-Allow-Credentials: true。浏览器自己的 Cookie 限制仍然独立生效,CORS 放行不会自动突破这些限制。

实际响应也必须包含适用的 CORS 字段,希望脚本能读取的错误响应同样如此。预检通过,并不能授权脚本读取一个后来缺少许可字段的响应。若返回内容或头部依赖 Origin,还应添加 Vary: Origin,让共享缓存区分不同来源的变体。Access-Control-Max-Age 控制专用预检缓存,并受到浏览器上限约束;不能仅靠普通 HTTP 缓存规则解释为什么某次请求没看到 OPTIONS。

允许客户端发送哪些请求头,与允许脚本读取哪些响应头,是两项独立权限。例如允许 JSON POST,并不自动允许脚本读取自定义的请求标识响应头;需要这类元数据时,应通过 Access-Control-Expose-Headers 明确暴露。把 fetch 改成 mode: "no-cors" 也不是查看被拒响应的办法:它限制请求形式,并返回脚本无法读取状态、头部与正文的不透明响应。调试时应确认需要开放的是发送、正文读取还是响应元数据读取,避免改错字段。

坏掉的样子

认证网关可能因为预检没有携带会话 Cookie 而直接拒绝 OPTIONS。Fetch 的预检通常不会带这些凭据,因此应在要求实际操作登录之前先判断跨源策略。后续业务请求依旧必须通过正常的身份认证和权限检查。为了让预检通过就把真正的写入接口开放给匿名用户,会扩大权限范围,并没有修复原来的策略顺序问题。

符合普通表单条件的 POST 即使无法被脚本读取响应,也可能已经到达服务器。把 CORS 报错解释成“没有发生写入”,可能导致重复提交,也可能让接口缺少必要的 CSRF 防护。这些问题应由业务的防伪请求机制与幂等规则处理。另一个常见故障是代理和应用分别追加允许源字段,最终形成重复字段;两边单独看都像正确配置,组合后的响应却会被浏览器拒绝。

快速验证

以下 Python 3 实验只监听回环地址,预检允许一个源、POST 方法和内容类型字段,并分别测试不允许的源与请求头。实验中的 POST 处理器故意不做业务认证,用来展示响应读取限制不能保护写入;它不是生产接口,也不应直接对外部署。

from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread

ORIGIN = "https://reader.example"

class Handler(BaseHTTPRequestHandler):
    def do_OPTIONS(self):
        names = {s.strip().lower() for s in self.headers.get(
            "Access-Control-Request-Headers", "").split(",") if s.strip()}
        allowed = (self.headers.get("Origin") == ORIGIN
                   and self.headers.get("Access-Control-Request-Method") == "POST"
                   and names <= {"content-type"})
        self.send_response(204 if allowed else 403)
        self.send_header("Content-Length", "0")
        self.send_header("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
        if allowed:
            self.send_header("Access-Control-Allow-Origin", ORIGIN)
            self.send_header("Access-Control-Allow-Methods", "POST")
            self.send_header("Access-Control-Allow-Headers", "content-type")
        self.end_headers()

    def do_POST(self):
        self.rfile.read(int(self.headers.get("Content-Length", "0")))
        self.server.posts += 1
        self.send_response(200)
        self.send_header("Content-Length", "2")
        self.end_headers()
        self.wfile.write(b"ok")

    def log_message(self, *args):
        pass

with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server:
    server.posts = 0
    worker = Thread(target=server.serve_forever, daemon=True)
    worker.start()
    client = HTTPConnection(*server.server_address, timeout=3)
    try:
        for origin, names, expected in [
            (ORIGIN, "content-type", 204),
            ("https://untrusted.example", "content-type", 403),
            (ORIGIN, "authorization", 403),
        ]:
            client.request("OPTIONS", "/", headers={"Origin": origin,
                "Access-Control-Request-Method": "POST",
                "Access-Control-Request-Headers": names})
            response = client.getresponse()
            response.read()
            assert response.status == expected
            assert response.getheader("Access-Control-Allow-Origin") == (
                ORIGIN if expected == 204 else None)
            print("preflight", expected)
        client.request("POST", "/", body="test", headers={
            "Origin": "https://untrusted.example", "Content-Type": "text/plain"})
        response = client.getresponse()
        assert response.read() == b"ok"
        assert response.getheader("Access-Control-Allow-Origin") is None
        assert server.posts == 1
        print("POST reached server without a readable CORS response")
    finally:
        client.close()
        server.shutdown()
        worker.join()

预期依次打印预检状态 204、403、403,然后确认 POST 确实到达了处理器。这验证了服务端的允许判断及响应字段。Python 客户端不执行 CORS,不能用该结果证明浏览器表现。浏览器集成测试中应分别检查 OPTIONS 和后续业务请求:拒绝 JSON 预检应阻止 POST,而符合条件的简单 POST 可以执行后仍让脚本读取失败。解释错误时保留服务端收到请求的证据,才能分清“未发送”与“已执行但不可读”。

延伸阅读

来源