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 可以执行后仍让脚本读取失败。解释错误时保留服务端收到请求的证据,才能分清“未发送”与“已执行但不可读”。
延伸阅读
来源
- Fetch 标准:CORS 协议,浏览器发送许可和响应共享规则,已核验 2026-09-06。
- MDN:CORS,预检、凭据和安全列出的请求示例,已核验 2026-09-06。
- MDN:预检请求,请求字段和独立预检缓存,已核验 2026-09-06。