跨源资源共享允许服务端选择是否向其他来源的脚本暴露响应。一个来源由协议、主机和端口组成,因此即使在同一台机器上,端口不同的两个服务也可以属于不同来源。浏览器会结合发起页面的来源检查 CORS 响应头。这套机制不会认证当前用户,也不会把接口变成只能从私有网络访问的服务。它回答的是特定页面的脚本能否获得响应内容,而不是业务操作本身是否获准。同站 Cookie 规则与同源响应访问也不是同一条边界,不能只看主机名相似,就推断两种机制都会采用相同结果。

对于允许访问的页面,接口可以在 Access-Control-Allow-Origin 中返回该页面序列化后的来源,浏览器再检查其他相关条件,决定是否暴露响应。某些公开供无凭据跨源读取的资源可以使用通配符,但通配符不能授权凭据模式为 include 的请求。服务端应根据明确规则选择允许的来源,不能把客户端任意传入的来源原样回显,就当成已经完成了访问策略判断。

分清请求送达与响应可读

一部分通常称为简单请求的操作不需要 CORS 预检。例如使用纯文本内容类型、且脚本只设置安全列表内请求头的 POST,可以满足相关条件。这里的安全列表是浏览器协议术语,并不意味着接口只读,也不意味着请求不会修改数据。普通 HTML 表单早已能够跨来源发送相似请求,而无须事先取得读取目标响应的权限,因此不能把预检有无理解成业务副作用大小。

浏览器完全可能先发送请求,收到二百状态响应,然后因为响应暴露没有获准而让脚本的 fetch 失败。此时服务端可能已经执行完操作。普通非浏览器 HTTP 客户端也不会执行这项页面来源规则。认证、授权以及防止非预期跨站操作,需要独立的服务端设计;不能依靠是否返回某个 CORS 响应头,替代对请求身份、权限和业务意图的检查。

坏掉的样子

常见故障从“浏览器已经拦截,所以服务端什么都没做”这个误判开始。用户重试一个会改变状态的请求,结果因为第一次已经成功执行而造成重复操作。重试之前应检查服务端证据,并明确业务操作自己的重复执行语义。对于依靠 Cookie 认证的状态修改,还要采用合适的跨站请求伪造防护。仅仅不返回允许来源头,不能代替判断这次修改是否应该被接受。

另一种错误,是把所有 fetch 类型错误都当成 CORS 配置问题。网络失败也会导致请求承诺被拒绝,因此要结合浏览器诊断信息、实际收到的响应头和服务端请求记录判断。把请求改成 no-cors,也不会让应用取得普通可读响应;不透明响应的限制决定了它不能成为需要消费接口数据时的通用修复方法。应修正真实的协议或权限配置,而不是只让某条错误提示消失。

快速验证

将代码保存为 cors-simple.py,运行 python3 cors-simple.py –serve,再打开打印出的本机回环页面地址。本次使用 Python 3.14.4 和 Chrome 152 检查。脚本在不同临时端口启动页面服务与接口服务,唯一的状态修改是往内存列表追加合成事件。结束后按控制键加 C 关闭两个服务。实验不涉及账号、Cookie 或生产接口,两个来源只是为了直接观察浏览器真实执行的跨源规则。

import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.request import Request, urlopen

events = []


class API(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_POST(self):
        body = self.rfile.read(int(self.headers["Content-Length"])).decode()
        events.append({"method": "POST", "path": self.path, "body": body})
        payload = b"synthetic action recorded"
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(payload)))
        if self.path == "/allowed":
            self.send_header("Access-Control-Allow-Origin", app_origin)
            self.send_header("Vary", "Origin")
        self.end_headers()
        self.wfile.write(payload)

    def do_OPTIONS(self):
        events.append({"method": "OPTIONS", "path": self.path})
        self.send_response(403)
        self.send_header("Content-Length", "0")
        self.end_headers()


class App(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_GET(self):
        if self.path == "/events":
            body = json.dumps(events).encode()
            kind = "application/json"
        else:
            body = ("""<!doctype html><meta charset="utf-8"><title>CORS simple request lab</title>
<h1>Read permission and request delivery</h1><pre id="result">Running...</pre><script>
const api=""" + json.dumps(api_origin) + """;
async function run() {
  const results={};
  for(const path of ['allowed','blocked']) {
    try {
      const response=await fetch(api+'/'+path,{method:'POST',headers:{'Content-Type':'text/plain'},body:'synthetic-change'});
      results[path]={readable:true,status:response.status,body:await response.text()};
    } catch(error) {results[path]={readable:false,error:error.name};}
  }
  results.serverEvents=await (await fetch('/events')).json();
  window.labResult=results;
  document.getElementById('result').textContent=JSON.stringify(results,null,2);
}
run().catch(error=>{window.labError=String(error);});
</script>""").encode()
            kind = "text/html; charset=utf-8"
        self.send_response(200)
        self.send_header("Content-Type", kind)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)


api = HTTPServer(("127.0.0.1", 0), API)
app = HTTPServer(("127.0.0.1", 0), App)
api_origin = f"http://127.0.0.1:{api.server_port}"
app_origin = f"http://127.0.0.1:{app.server_port}"
threads = [Thread(target=server.serve_forever, daemon=True) for server in (api, app)]
for thread in threads:
    thread.start()
try:
    request = Request(api_origin + "/blocked", data=b"synthetic-change",
                      headers={"Origin": app_origin, "Content-Type": "text/plain"})
    with urlopen(request, timeout=5) as response:
        assert response.status == 200 and response.read() == b"synthetic action recorded"
        assert response.headers.get("Access-Control-Allow-Origin") is None
    assert len(events) == 1 and events[0]["method"] == "POST"
    events.clear()
    print(json.dumps({"app_url": app_origin, "nonbrowser_read_without_cors": True}), flush=True)
    if "--serve" in sys.argv:
        Event().wait()
except KeyboardInterrupt:
    pass
finally:
    for server in (api, app):
        server.shutdown()
        server.server_close()
    for thread in threads:
        thread.join(timeout=5)

页面向 allowed 和 blocked 两个路径发送完全相同的 POST 内容。前一个响应允许页面来源,因此脚本能够读取;后一个没有允许来源头,fetch 会以类型错误失败。然而服务端事件列表应同时包含这两个 POST,且没有任何 OPTIONS 请求。浏览器检查要求这些观察结果同时成立,不能只看到脚本拒绝读取,就把它改写成第二次请求被阻止发送的证据。

在开始供浏览器访问之前,Python 探针也会成功读取缺少允许来源头的接口响应,直接展示普通 HTTP 客户端与浏览器之间的边界差异。探针产生的事件随后被清空,不会混入页面实验的计数。本例验证的是所给简单请求的送达与响应暴露,没有实现认证或跨站请求伪造防护系统。实际配置接口时,应分别审阅允许的页面来源、请求形态和被接受的业务动作,再让验证经过完整的浏览器到服务端路径。

延伸阅读

来源