CORS 预检用于询问浏览器能否发送某种具体形态的跨源请求。它是一条 OPTIONS 请求,携带计划采用的方法及相关请求头名称,浏览器检查服务端答复后才决定是否发送实际请求。这与实际响应到达之后,检查应用脚本能否读取响应,是两个不同阶段。它们都可能失败,但对服务端是否执行过业务操作的影响不同,诊断时必须先确定错误发生在哪个边界。
触发预检的条件并不是简单的“带有凭据”或“会产生副作用”。在其他条件同时满足时,安全列表中的 GET、HEAD 或 POST 可以不预检。PUT、应用自己设置的 Authorization 等请求头通常需要预检,JSON 内容类型也不属于安全列表里的内容类型。因此,即使目标地址和业务操作没变,只改变请求形态,也可能新增一次 OPTIONS 交互。不能把这种变化误认为服务端突然多执行了一次业务请求。
把每项权限放到对应阶段理解
预检携带计划使用的方法,并在需要时携带计划设置的请求头名称。响应中的允许方法和允许请求头必须覆盖浏览器提出的内容。这个阶段成功之后,实际响应仍然要满足自己的 CORS 暴露条件。当获准的前端需要读取错误状态和正文时,错误响应也可能需要恰当的暴露头。后端处理函数可以被普通客户端成功调用,并不足以证明浏览器中的真实前端能够访问它。
凭据模式又增加了独立约束。使用 include 时,响应暴露需要匹配的明确来源,以及值为 true 的允许凭据头;即使本次测试根本没有 Cookie 可发送,允许来源通配符仍然不成立。浏览器对 Cookie 的其他限制也独立存在。允许凭据头不能替代 Cookie 配置、用户认证或来源允许列表,也不应为了回答浏览器的预检元数据查询,就要求预检先携带会话 Cookie。
坏掉的样子
应用路由可能允许 PUT,但预检响应遗漏了 PUT 或所需自定义请求头。浏览器此时只发送 OPTIONS,根本不会派发实际 PUT。重试业务处理器或修改数据库代码,并不能修复这个协议边界。反过来,一个采用凭据模式的简单 GET 可以在没有 OPTIONS 的情况下到达处理器,然后因为服务端返回通配符而无法向脚本暴露响应。仅凭前端的失败提示,无法确定哪一种情况发生了。
预检缓存还会让配置变更难以观察。最大缓存时长控制的是浏览器专门维护的许可缓存,并且受到浏览器自身限制;它不是业务接口正文缓存。判断修改是否生效之前,应查看真实请求交互及缓存影响。如果响应会根据请求来源选择不同的允许值,还要设置恰当的缓存变化规则,避免共享缓存把某个来源对应的许可响应直接复用于其他请求。不能靠反复修改接口正文来排查权限缓存。
快速验证
保存代码为 cors-preflight.py,执行 python3 cors-preflight.py –serve,打开打印出的页面地址,结束后按控制键加 C 关闭两个本地服务。本次实际使用 Python 3.14.4 与 Chrome 152,两个来源位于不同的本机回环端口,所有响应内容均为合成数据。实验有意在不设置 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 reply(self, preflight=False):
events.append({"method": self.command, "path": self.path,
"requested_method": self.headers.get("Access-Control-Request-Method"),
"requested_headers": self.headers.get("Access-Control-Request-Headers")})
body = b"" if preflight else b"readable result"
self.send_response(204 if preflight else 200)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*" if self.path == "/wildcard" else app_origin)
self.send_header("Access-Control-Allow-Credentials", "true")
self.send_header("Vary", "Origin")
if preflight and self.path == "/permit":
self.send_header("Access-Control-Allow-Methods", "PUT")
self.send_header("Access-Control-Allow-Headers", "X-Lab")
self.send_header("Access-Control-Max-Age", "0")
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self):
self.reply(preflight=True)
def do_GET(self):
self.reply()
def do_PUT(self):
self.rfile.read(int(self.headers.get("Content-Length", "0")))
self.reply()
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 preflight lab</title>
<h1>Credentials mode and preflight</h1><pre id="result">Running...</pre><script>
const api=""" + json.dumps(api_origin) + """;
async function run() {
const cases=[
['wildcardInclude','/wildcard',{credentials:'include'}],
['wildcardOmit','/wildcard',{credentials:'omit'}],
['exactInclude','/exact',{credentials:'include'}],
['deniedPut','/deny',{method:'PUT',headers:{'X-Lab':'demo'},body:'synthetic'}],
['allowedPut','/permit',{method:'PUT',headers:{'X-Lab':'demo'},body:'synthetic'}]
];
const results={};
for(const [name,path,options] of cases) {
try {const response=await fetch(api+path,options);results[name]={readable:true,status:response.status,body:await response.text()};}
catch(error) {results[name]={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:
headers = {"Origin": app_origin, "Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "x-lab"}
for path in ("deny", "permit"):
with urlopen(Request(api_origin + "/" + path, method="OPTIONS", headers=headers), timeout=5) as response:
assert response.status == 204
assert response.headers.get("Access-Control-Allow-Methods") == ("PUT" if path == "permit" else None)
assert response.headers.get("Access-Control-Allow-Headers") == ("X-Lab" if path == "permit" else None)
events.clear()
print(json.dumps({"app_url": app_origin, "wire_preflight_checks": "PASS",
"real_authentication_or_cookies": False}), 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)
页面依次执行五种 fetch:通配符配合 include 时不可读,相同通配符配合 omit 时可读,明确来源配合 include 时也可读。缺少许可信息的 PUT 无法通过预检,而明确允许方法与 X-Lab 请求头的 PUT 成功。页面同时展示服务端记录的事件,避免只根据脚本成功或失败标志来猜测实际请求有没有执行。每项观察都可以与浏览器网络面板里的具体交互对应起来。
预期服务端记录三个 GET、两个 OPTIONS,以及恰好一个发往 permit 路径的 PUT。两个预检都声明 PUT 方法和小写的 x-lab 请求头,deny 路径则没有收到 PUT。允许响应把预检缓存时长设为零,避免实验依赖之前缓存的许可。不带服务参数的 Python 执行只检查响应头,浏览器执行才验证真正的派发与暴露行为。用于实际前端时,应保留这两类证据,并测试真实采用的方法、内容类型、自定义头、凭据模式和响应路径。
延伸阅读
来源
- MDN CORS,预检条件与携带凭据模式的请求交互,已核验 2026-09-13。
- MDN Access-Control-Allow-Credentials,暴露凭据模式响应所需的条件,已核验 2026-09-13。
- WHATWG Fetch Standard,方法、请求头权限检查及预检缓存,已核验 2026-09-13。